cartethyia 2.5.4 → 2.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +13 -4
- package/README.md +2 -2
- package/dist/dashboard/assets/{BannedPage-C2orBsI2.js → BannedPage-DE2607Tb.js} +1 -1
- package/dist/dashboard/assets/BannedPage-DE2607Tb.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js +1 -0
- package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-BV6pxVQo.js → CliToolsPage-C2PNYWzk.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-C2PNYWzk.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-TXP9OfAM.js +1 -0
- package/dist/dashboard/assets/CombosPage-TXP9OfAM.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-CW-wPmJ6.js → ConsoleLogPage-B-9xnAQN.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-B-9xnAQN.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-0p8uX7vB.js → CustomizationPage-xeax9nJt.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-xeax9nJt.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-BjqSMfko.js → HealthEventsModal-Df4jjaHJ.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-Df4jjaHJ.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-BvzCx8Kr.js → LoginPage-Dyojsm84.js} +1 -1
- package/dist/dashboard/assets/LoginPage-Dyojsm84.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js +1 -0
- package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-Js9jUPSb.js → ProvidersPage-DeWSOEUO.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-DeWSOEUO.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-CkpWln2S.js +7 -0
- package/dist/dashboard/assets/ProxyPage-CkpWln2S.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js +1 -0
- package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-C_klIZom.js +1 -0
- package/dist/dashboard/assets/SettingsPage-C_klIZom.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-DDbMQsoo.js → SetupPage-BYph-InK.js} +1 -1
- package/dist/dashboard/assets/SetupPage-BYph-InK.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-CB3Z7VTf.js → SortableList-CvyQCYWJ.js} +1 -1
- package/dist/dashboard/assets/SortableList-CvyQCYWJ.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-D95UCPHI.js → StudioPage-Cm1JYilI.js} +3 -3
- package/dist/dashboard/assets/StudioPage-Cm1JYilI.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-DlFnc3_S.js +56 -0
- package/dist/dashboard/assets/UsagePage-DlFnc3_S.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
- package/dist/dashboard/assets/{assert-Chi7pCnt.js → assert-C9FD_JwJ.js} +1 -1
- package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
- package/dist/dashboard/assets/{badge-D4pQapKj.js → badge-CR72dasT.js} +1 -1
- package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
- package/dist/dashboard/assets/{banner-DH6cg-_B.js → banner-CI9_eSfO.js} +1 -1
- package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
- package/dist/dashboard/assets/{card-BKqlUOe4.js → card-DS7CQEsO.js} +1 -1
- package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-fTgljTGA.js → cli-tools-CNqzrfdb.js} +1 -1
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-D5Ys_sM0.js → clipboard-button-611pSsaX.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
- package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
- package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CQBr6U9c.js → entry-B9Xvtnfj.js} +1 -1
- package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
- package/dist/dashboard/assets/entry-Bog0p925.js +4 -0
- package/dist/dashboard/assets/entry-Bog0p925.js.gz +0 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-BlptL8Uf.js → github-badge-sltk1qUi.js} +1 -1
- package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
- package/dist/dashboard/assets/{index-js6TJvdr.js → index-CZQIrdoX.js} +2 -2
- package/dist/dashboard/assets/index-CZQIrdoX.js.gz +0 -0
- package/dist/dashboard/assets/{inline-DUWDSmDV.js → inline-BhY2sSnO.js} +1 -1
- package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
- package/dist/dashboard/assets/{input-LVjgZq4f.js → input-V1umFiBk.js} +1 -1
- package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-DupvLfDx.js → list-row-1zzZYM0B.js} +1 -1
- package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
- package/dist/dashboard/assets/{live-C4pEJUqI.js → live-BivGfuUs.js} +1 -1
- package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
- package/dist/dashboard/assets/{model-bans-BRiuufkB.js → model-bans-CmffjLuG.js} +1 -1
- package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-B4ROwk8L.js → mutation-46P0oWMe.js} +1 -1
- package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-gmjzC3RS.js → query-keys-BFhGpwR1.js} +1 -1
- package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
- package/dist/dashboard/assets/{routing-C7tBT5o-.js → routing-CAb4F37R.js} +1 -1
- package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
- package/dist/dashboard/assets/{select-WUCXorXw.js → select-BPpSsBgL.js} +1 -1
- package/dist/dashboard/assets/select-BPpSsBgL.js.gz +0 -0
- package/dist/dashboard/assets/{settings-C_zKl1GS.js → settings-DNX9sydi.js} +1 -1
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/{stack-_3O0F8Xn.js → stack-BHTfXdz_.js} +1 -1
- package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
- package/dist/dashboard/assets/{state-BgTxHcJS.js → state-mPDRlQkl.js} +1 -1
- package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DEjTtOWf.js → switch-DOmJxg7H.js} +1 -1
- package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
- package/dist/dashboard/assets/tokens-COzario9.js +1 -0
- package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-BWSC0UAS.js → useMutation-Dem0lXQj.js} +1 -1
- package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +54 -54
- package/migrations/README.md +52 -52
- package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
- package/package.json +95 -95
- package/scripts/cli/admin.ts +125 -0
- package/scripts/cli/{ui/background.ts → background.ts} +1 -1
- package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -244
- package/scripts/cli/doctor.ts +37 -0
- package/scripts/cli/{support/env.ts → env.ts} +99 -99
- package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
- package/scripts/cli/instance.ts +224 -0
- package/scripts/cli/main.ts +61 -18
- package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
- package/scripts/cli/port.ts +100 -0
- package/scripts/cli/{support/services.ts → services.ts} +88 -88
- package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
- package/scripts/cli/{command/start.ts → start.ts} +10 -5
- package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
- package/scripts/cli/tray/index.ts +62 -12
- package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
- package/scripts/cli/{command/update.ts → update.ts} +39 -39
- package/scripts/cli/version.ts +67 -0
- package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
- package/src/boot/app.ts +697 -693
- package/src/boot/dependencies.ts +647 -646
- package/src/boot/timeout.ts +2 -13
- package/src/config/cooldowns.ts +186 -138
- package/src/config/network.ts +8 -14
- package/src/config/providers.ts +7 -10
- package/src/config/runtime.ts +76 -76
- package/src/config/security.ts +15 -0
- package/src/config/spec.ts +20 -30
- package/src/config/telemetry.ts +0 -20
- package/src/config/timeouts.ts +0 -6
- package/src/console/admin/model-abuse/contracts.ts +28 -6
- package/src/console/admin/model-abuse/routes.ts +56 -3
- package/src/console/api-keys/contracts.ts +410 -414
- package/src/console/api-keys/routes.ts +881 -880
- package/src/console/auth/credentials.ts +3 -3
- package/src/console/auth/setup-service.ts +2 -2
- package/src/console/backup/export-endpoint.ts +6 -2
- package/src/console/backup/nine-router.ts +2 -5
- package/src/console/backup/service.ts +4 -0
- package/src/console/backup/store.ts +192 -29
- package/src/console/backup/validate.ts +3 -45
- package/src/console/cli-tools/fs-ops.ts +487 -487
- package/src/console/console-router-guards.ts +1 -1
- package/src/console/data-management/contracts.ts +13 -6
- package/src/console/data-management/operations.ts +23 -18
- package/src/console/data-management/routes.ts +0 -1
- package/src/console/domain-registration.ts +10 -5
- package/src/console/observability/live.ts +22 -0
- package/src/console/observability/store.ts +39 -16
- package/src/console/observability/usage-dimensions.ts +4 -4
- package/src/console/providers/catalog/catalog-projections.ts +6 -7
- package/src/console/providers/catalog/model-operations.ts +5 -2
- package/src/console/providers/catalog/provider-operations.ts +4 -4
- package/src/console/providers/catalog/public-model-store.ts +8 -11
- package/src/console/providers/catalog/store.ts +1553 -1553
- package/src/console/providers/oauth/routes.ts +8 -12
- package/src/console/quota/account-quota-tenant.ts +6 -6
- package/src/console/quota/quota-view.ts +1 -1
- package/src/console/remote-support/contracts.ts +61 -0
- package/src/console/remote-support/routes.ts +65 -0
- package/src/console/remote-support/service.ts +207 -0
- package/src/console/response-compression.ts +0 -3
- package/src/console/routing/pools/probe-result.ts +3 -2
- package/src/console/routing/pools/probe-verdict.ts +132 -132
- package/src/console/routing/pools/store.ts +8 -8
- package/src/console/settings/contracts.ts +58 -2
- package/src/console/settings/store.ts +30 -3
- package/src/console/shared/cursor.ts +8 -8
- package/src/console/shared/errors.ts +1 -3
- package/src/network/http2-bidi.ts +2 -4
- package/src/network/pool/agent.ts +478 -478
- package/src/network/pool/selector.ts +5 -4
- package/src/network/retry.ts +1 -2
- package/src/network/ssrf.ts +5 -4
- package/src/observability/event-compaction.ts +80 -78
- package/src/observability/event-day-index.ts +13 -15
- package/src/observability/event-log.ts +4 -4
- package/src/observability/logger.ts +6 -6
- package/src/observability/payload-capture.ts +151 -33
- package/src/observability/payload-store.ts +397 -440
- package/src/observability/redaction.ts +5 -6
- package/src/observability/request-notify.ts +43 -0
- package/src/observability/telemetry-status.ts +8 -9
- package/src/observability/token-speed.ts +5 -4
- package/src/persistence/db-mode.ts +7 -7
- package/src/persistence/readiness.ts +1 -1
- package/src/persistence/schema.ts +1406 -1402
- package/src/persistence/shared-cache.ts +1 -2
- package/src/persistence/stores/api-key-store.ts +18 -6
- package/src/persistence/stores/share-store.ts +4 -3
- package/src/persistence/stores/telemetry-store.ts +236 -237
- package/src/protocol/canonical-model.ts +11 -3
- package/src/protocol/codecs.ts +138 -4
- package/src/protocol/gateway-error.ts +6 -4
- package/src/protocol/request/chat.ts +21 -1
- package/src/protocol/request/codex.ts +10 -1
- package/src/protocol/request/messages.ts +4 -5
- package/src/protocol/request/responses.ts +9 -1
- package/src/protocol/response/chat.ts +34 -4
- package/src/protocol/response/codex.ts +7 -4
- package/src/protocol/response/dsml.ts +140 -28
- package/src/protocol/response/responses.ts +11 -5
- package/src/protocol/stream-error-frames.ts +4 -5
- package/src/providers/authentication/jwt-validator.ts +9 -10
- package/src/providers/authentication/oauth-refresh-service.ts +9 -11
- package/src/providers/configured-openai-providers.ts +134 -136
- package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
- package/src/providers/discovery/catalog/catalog-format.ts +3 -6
- package/src/providers/discovery/models-dev-catalog.ts +354 -354
- package/src/providers/discovery/probe-phases.ts +4 -4
- package/src/providers/discovery/probe-wire.ts +6 -13
- package/src/providers/discovery/probing-service.ts +1117 -1045
- package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
- package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
- package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
- package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
- package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
- package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
- package/src/providers/integrations/buddy/buddy-media.ts +2 -2
- package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
- package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
- package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
- package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
- package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
- package/src/providers/integrations/claude/claude.ts +608 -608
- package/src/providers/integrations/cline/cline-quota.ts +132 -132
- package/src/providers/integrations/cline/cline.ts +537 -539
- package/src/providers/integrations/codex/codex-oauth.ts +1 -1
- package/src/providers/integrations/commandcode/commandcode.ts +674 -675
- package/src/providers/integrations/commandcode/discovery.ts +117 -116
- package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
- package/src/providers/integrations/cursor/cursor-quota.ts +309 -310
- package/src/providers/integrations/cursor/cursor.ts +11 -163
- package/src/providers/integrations/factory/factory-models.ts +3 -5
- package/src/providers/integrations/grok/grok-install-id.ts +2 -3
- package/src/providers/integrations/grok/grok.ts +6 -7
- package/src/providers/integrations/hermes.ts +14 -18
- package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
- package/src/providers/integrations/kiro/kiro.ts +1 -1
- package/src/providers/integrations/mistral.ts +6 -6
- package/src/providers/integrations/opencode/fingerprint.ts +3 -3
- package/src/providers/integrations/opencode/provider.ts +8 -8
- package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
- package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
- package/src/providers/integrations/qoder/qoder.ts +884 -865
- package/src/providers/integrations/shared/connect.ts +6 -6
- package/src/providers/integrations/trae/trae-catalog.ts +1 -2
- package/src/providers/integrations/trae/trae.ts +2 -2
- package/src/providers/integrations/zcode/oauth.ts +6 -1
- package/src/providers/operations/account-health-service.ts +1528 -1523
- package/src/providers/operations/account-reset-service.ts +1 -1
- package/src/providers/operations/client-versions.ts +6 -6
- package/src/providers/operations/custom-cli-headers.ts +3 -4
- package/src/providers/operations/provider-catalog-seeder.ts +95 -95
- package/src/providers/operations/provider-catalog-service.ts +3 -4
- package/src/providers/provider-metadata.ts +4 -8
- package/src/providers/provider-registry.ts +6 -7
- package/src/providers/provider-usage.ts +1 -1
- package/src/security/abuse.ts +15 -10
- package/src/security/access-control.ts +144 -146
- package/src/security/admission/contracts.ts +11 -0
- package/src/security/admission/in-memory-store.ts +4 -3
- package/src/security/admission/lease-sweep.ts +6 -6
- package/src/security/admission/reasons.ts +5 -1
- package/src/security/admission/redis-store.ts +211 -210
- package/src/security/admission/service.ts +4 -0
- package/src/security/api-key-auth.ts +504 -504
- package/src/security/ip-boundary.ts +155 -15
- package/src/security/model-abuse.ts +366 -94
- package/src/security/outbound-headers.ts +3 -4
- package/src/shared/sleep.ts +4 -0
- package/src/transport/dispatch/attempt-finalize.ts +47 -5
- package/src/transport/dispatch/attempt-loop.ts +531 -530
- package/src/transport/dispatch/stream-bridge.ts +1 -3
- package/src/transport/dispatch/streaming/finalize.ts +1 -1
- package/src/transport/dispatch/streaming/stream.ts +5 -6
- package/src/transport/failure-policy.ts +162 -8
- package/src/transport/guards/auth-guard.ts +7 -4
- package/src/transport/guards/concurrency-guard.ts +0 -5
- package/src/transport/guards/error-lifecycle.ts +13 -6
- package/src/transport/guards/pipeline.ts +15 -3
- package/src/transport/guards/request-context.ts +8 -2
- package/src/transport/request/inflight.ts +5 -6
- package/src/transport/request/preparer.ts +2 -2
- package/src/transport/request/state.ts +12 -8
- package/src/transport/routing/admission.ts +4 -0
- package/src/transport/routing/route-catalog.ts +8 -1
- package/src/transport/routing/route-model.ts +46 -14
- package/src/transport/routing/router.ts +21 -14
- package/src/transport/surface/completion.ts +2 -3
- package/src/transport/surface/dialects.ts +5 -6
- package/src/transport/surface/responses/encode.ts +38 -0
- package/src/transport/surface/responses/parse.ts +4 -0
- package/src/transport/translation/capabilities.ts +2 -3
- package/src/transport/translation/quirks.ts +3 -3
- package/src/workers/buddy-growth-tasks.ts +5 -10
- package/src/workers/daily-checkin.ts +17 -23
- package/src/workers/quota-refresh-sweep.ts +26 -26
- package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +0 -1
- package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-Bsan4679.js +0 -1
- package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js +0 -1
- package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +0 -1
- package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +0 -7
- package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +0 -1
- package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +0 -1
- package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-Cnae9SWA.js +0 -56
- package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-VY68Mhd1.js +0 -1
- package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
- package/dist/dashboard/assets/app-COlK95oi.css +0 -2
- package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
- package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
- package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
- package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
- package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BYj-8WuK.js +0 -1
- package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CmXIZ6H1.js +0 -1
- package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
- package/dist/dashboard/assets/entry-CBg5zeu8.js +0 -1
- package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
- package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
- package/dist/dashboard/assets/entry-CgdSAzEV.js +0 -4
- package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
- package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
- package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
- package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
- package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
- package/dist/dashboard/assets/layout-CPfxpsfy.js +0 -1
- package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
- package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
- package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
- package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
- package/dist/dashboard/assets/numbers-YNcHvRY7.js +0 -2
- package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-gmjzC3RS.js.gz +0 -0
- package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
- package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
- package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
- package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
- package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
- package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
- package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
- package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
- package/scripts/cli/command/admin.ts +0 -65
- package/scripts/cli/command/doctor.ts +0 -257
- package/scripts/cli/gateway/drain-token.ts +0 -78
- package/scripts/cli/gateway/instance-lock.ts +0 -105
- package/scripts/cli/gateway/stop.ts +0 -51
- package/scripts/cli/support/bun.ts +0 -27
- package/scripts/cli/support/version.ts +0 -37
- /package/dist/dashboard/assets/{app-DCQZRmHY.js → app-BIt446yE.js} +0 -0
- /package/dist/dashboard/assets/{app-DCQZRmHY.js.gz → app-BIt446yE.js.gz} +0 -0
- /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
|
@@ -1,1045 +1,1117 @@
|
|
|
1
|
-
import { captureProviderExchange, type ProviderExchangeCapture } from "../../transport/dispatch/attempt-finalize";
|
|
2
|
-
import { globalOrOwnedBy } from "../../persistence/tenant-scope";
|
|
3
|
-
/**
|
|
4
|
-
* Provider probing and model-discovery orchestration.
|
|
5
|
-
*
|
|
6
|
-
* Network probes and `/v1/models` discovery stay separate from the Drizzle
|
|
7
|
-
* catalog repository while sharing the provider catalog contract.
|
|
8
|
-
*/
|
|
9
|
-
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
10
|
-
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
11
|
-
import { models, providerAccounts, providers } from "../../persistence/schema";
|
|
12
|
-
import type { CanonicalEvent, CanonicalRequest, WireFamily } from "../../protocol/canonical-model";
|
|
13
|
-
import { WIRE_FAMILIES } from "../../protocol/canonical-model";
|
|
14
|
-
import { isRecord } from "../../protocol/codecs";
|
|
15
|
-
import type { FetchLike } from "../quota/quota-contracts";
|
|
16
|
-
import type { CompatibilityProfile } from "../provider-metadata";
|
|
17
|
-
import {
|
|
18
|
-
type ByokConnectionTestRequest,
|
|
19
|
-
type ByokConnectionTestResult,
|
|
20
|
-
type ProbeAllAccountsResult,
|
|
21
|
-
type ProbeAllModelsResult,
|
|
22
|
-
type ProbeModelRequest,
|
|
23
|
-
type ProbeModelResult,
|
|
24
|
-
} from "./discovery-types";
|
|
25
|
-
import { modelsDevCatalog } from "./models-dev-catalog";
|
|
26
|
-
import { fetchOpenAICompatibleModels } from "./openai-model-discovery";
|
|
27
|
-
import {
|
|
28
|
-
applyDiscoveredWire,
|
|
29
|
-
resolveDiscoveredWire,
|
|
30
|
-
staticEndpointForWire,
|
|
31
|
-
type DiscoveredModelWire,
|
|
32
|
-
} from "./probe-wire";
|
|
33
|
-
import {
|
|
34
|
-
buildProbeCanonicalRequest,
|
|
35
|
-
computeProbeVerdict,
|
|
36
|
-
extractSample,
|
|
37
|
-
GROK_407_PROBE_PROMPT,
|
|
38
|
-
grok407ProbeFailure,
|
|
39
|
-
hasMeaningfulOutput,
|
|
40
|
-
loadProbePreferences,
|
|
41
|
-
recordProbeHealth,
|
|
42
|
-
resolveProbeAdapter,
|
|
43
|
-
resolveProbeTarget,
|
|
44
|
-
selectProbeAccount,
|
|
45
|
-
wireContextFrom,
|
|
46
|
-
type ProbeProviderRow,
|
|
47
|
-
} from "./probe-phases";
|
|
48
|
-
import { createProbeFetch } from "../operations/probe-fetch";
|
|
49
|
-
import { authHeaders } from "../adapters/compatible-adapter";
|
|
50
|
-
import {
|
|
51
|
-
byokAuthHeaderShape,
|
|
52
|
-
modelDiscoveryBaseUrl,
|
|
53
|
-
modelListUrl,
|
|
54
|
-
resolveByokWireProfile,
|
|
55
|
-
splitEndpointBaseUrl,
|
|
56
|
-
} from "../operations/byok-wire-profile";
|
|
57
|
-
import { resolveCustomCliHeaders } from "../operations/custom-cli-headers";
|
|
58
|
-
import { resolveCredentialForAccount } from "../operations/provider-credential-service";
|
|
59
|
-
import { decryptCredentialToString } from "../../security/crypto";
|
|
60
|
-
import { GatewayError } from "../../protocol/gateway-error";
|
|
61
|
-
import type { TelemetryBatchBuffer } from "../../observability/telemetry-buffer";
|
|
62
|
-
import { computeTokensPerSec } from "../../observability/token-speed";
|
|
63
|
-
import { TelemetryPayloadCapture } from "../../observability/payload-capture";
|
|
64
|
-
import { providerBaseUrl } from "../provider-metadata";
|
|
65
|
-
import { isBundledProviderId, parseProviderId, type ProviderDispatchTarget as AdapterDispatchTarget, type ValidatedOutboundFetch } from "../provider-contracts";
|
|
66
|
-
import type { ValidatedOutboundHttp2Bidi } from "../provider-contracts";
|
|
67
|
-
import type { ModelDefinition } from "../provider-model-definition";
|
|
68
|
-
import type { ProviderRegistry } from "../provider-registry";
|
|
69
|
-
|
|
70
|
-
/** Max models probed concurrently per batch (outside the sequential warm-up). */
|
|
71
|
-
const PROBE_CONCURRENCY = 5;
|
|
72
|
-
/** Max accounts probed concurrently; batches wait before starting the next ten. */
|
|
73
|
-
const PROBE_ACCOUNT_CONCURRENCY = 10;
|
|
74
|
-
|
|
75
|
-
export interface ProbeOutboundBinding {
|
|
76
|
-
readonly fetch: ValidatedOutboundFetch;
|
|
77
|
-
/**
|
|
78
|
-
* Validated bidirectional HTTP/2 egress for the same pool/tenant. A provider
|
|
79
|
-
* whose only transport is a bidi stream (Cursor's Connect-RPC Run) has no
|
|
80
|
-
* `fetch` path to fall back to, so a probe without this binding reports a
|
|
81
|
-
* healthy account as broken.
|
|
82
|
-
*/
|
|
83
|
-
readonly http2Bidi?: ValidatedOutboundHttp2Bidi;
|
|
84
|
-
readonly networkPoolId?: string;
|
|
85
|
-
readonly release?: () => void;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export type ProbeOutboundResolver = (
|
|
89
|
-
tenantId: string,
|
|
90
|
-
providerId?: string,
|
|
91
|
-
) => Promise<ValidatedOutboundFetch | ProbeOutboundBinding> | ValidatedOutboundFetch | ProbeOutboundBinding;
|
|
92
|
-
|
|
93
|
-
interface ProviderProbingDeps {
|
|
94
|
-
readonly db: CartethyiaDatabase;
|
|
95
|
-
readonly telemetryBuffer: TelemetryBatchBuffer | undefined;
|
|
96
|
-
readonly defaultEndpoints: Record<WireFamily, string>;
|
|
97
|
-
readonly bundledModelCatalog: ReadonlyMap<string, readonly ModelDefinition[]>;
|
|
98
|
-
readonly outboundFetchFor: ProbeOutboundResolver;
|
|
99
|
-
readonly snapshotInvalidator: { invalidate(): unknown };
|
|
100
|
-
readonly providerRegistry: ProviderRegistry;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Adapts a validated outbound fetch to the `FetchLike` shape discovery
|
|
105
|
-
* helpers expect. Only the call signature is consumed downstream.
|
|
106
|
-
*/
|
|
107
|
-
function asFetchLike(fetchFn: ValidatedOutboundFetch): FetchLike {
|
|
108
|
-
const wrapped = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
|
|
109
|
-
fetchFn(input, init);
|
|
110
|
-
return Object.assign(wrapped, { preconnect: () => undefined });
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function asOutboundBinding(
|
|
114
|
-
resolved: ValidatedOutboundFetch | ProbeOutboundBinding,
|
|
115
|
-
): ProbeOutboundBinding {
|
|
116
|
-
return typeof resolved === "function" ? { fetch: resolved } : resolved;
|
|
117
|
-
}
|
|
118
|
-
export class ProviderProbingService {
|
|
119
|
-
private readonly db: CartethyiaDatabase;
|
|
120
|
-
private readonly telemetryBuffer: TelemetryBatchBuffer | undefined;
|
|
121
|
-
private readonly payloadCapture: TelemetryPayloadCapture;
|
|
122
|
-
private readonly defaultEndpoints: Record<WireFamily, string>;
|
|
123
|
-
private readonly bundledModelCatalog: ReadonlyMap<string, readonly ModelDefinition[]>;
|
|
124
|
-
private readonly outboundFetchFor: ProbeOutboundResolver;
|
|
125
|
-
private readonly snapshotInvalidator: { invalidate(): unknown };
|
|
126
|
-
private readonly providerRegistry: ProviderRegistry;
|
|
127
|
-
|
|
128
|
-
constructor(deps: ProviderProbingDeps) {
|
|
129
|
-
this.db = deps.db;
|
|
130
|
-
this.telemetryBuffer = deps.telemetryBuffer;
|
|
131
|
-
this.payloadCapture = new TelemetryPayloadCapture(this.db);
|
|
132
|
-
this.defaultEndpoints = deps.defaultEndpoints;
|
|
133
|
-
this.bundledModelCatalog = deps.bundledModelCatalog;
|
|
134
|
-
this.outboundFetchFor = deps.outboundFetchFor;
|
|
135
|
-
this.snapshotInvalidator = deps.snapshotInvalidator;
|
|
136
|
-
this.providerRegistry = deps.providerRegistry;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* The provider row columns that decide wire-family/endpoint resolution.
|
|
141
|
-
* `probeModel` loads this once and reuses it for the contract gate, the
|
|
142
|
-
* custom-adapter fallback, and the wire context.
|
|
143
|
-
*/
|
|
144
|
-
private async loadProviderWireRow(providerId: string): Promise<ProbeProviderRow | undefined> {
|
|
145
|
-
const rows = await this.db
|
|
146
|
-
.select({
|
|
147
|
-
baseUrl: providers.baseUrl,
|
|
148
|
-
wireFamilyDefault: providers.wireFamilyDefault,
|
|
149
|
-
compatibilityProfile: providers.compatibilityProfile,
|
|
150
|
-
requiresAccount: providers.requiresAccount,
|
|
151
|
-
})
|
|
152
|
-
.from(providers)
|
|
153
|
-
.where(eq(providers.id, providerId))
|
|
154
|
-
.limit(1);
|
|
155
|
-
return rows[0];
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Static endpoint for one provider + wire family, if the bundled catalog
|
|
160
|
-
* ships a static definition for it. Used by manual registration (via the
|
|
161
|
-
* console store) so it resolves the same provider-truth endpoints as probes.
|
|
162
|
-
*/
|
|
163
|
-
async resolveStaticEndpoint(
|
|
164
|
-
providerId: string,
|
|
165
|
-
wireFamily: WireFamily,
|
|
166
|
-
): Promise<string | undefined> {
|
|
167
|
-
return staticEndpointForWire(this.bundledModelCatalog, providerId, wireFamily);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** One-shot connectivity test: resolves a usable account + the real per-provider
|
|
171
|
-
* adapter, dispatches a minimal canonical request through the model's own wire
|
|
172
|
-
* family/endpoint (never a hardcoded `/v1/chat/completions`), and records the
|
|
173
|
-
* outcome into `telemetry_events` via the same writer live traffic uses. */
|
|
174
|
-
async probeModel(
|
|
175
|
-
tenantId: string,
|
|
176
|
-
providerId: string,
|
|
177
|
-
request: ProbeModelRequest,
|
|
178
|
-
): Promise<ProbeModelResult> {
|
|
179
|
-
const startedAt = Date.now();
|
|
180
|
-
const modelId = request.modelId;
|
|
181
|
-
// One provider snapshot per probe: the contract gate, the account gate, the
|
|
182
|
-
// custom-adapter fallback, and the wire context all read this same row, so a
|
|
183
|
-
// concurrent edit cannot make two reads disagree.
|
|
184
|
-
const providerWireRow = await this.loadProviderWireRow(providerId);
|
|
185
|
-
const requiresAccount = providerWireRow?.requiresAccount ?? true;
|
|
186
|
-
|
|
187
|
-
const { wireFamily, serviceKind, endpointPath, sourceSurface, capabilityProfile } = await resolveProbeTarget({
|
|
188
|
-
db: this.db,
|
|
189
|
-
bundledModelCatalog: this.bundledModelCatalog,
|
|
190
|
-
defaultEndpoints: this.defaultEndpoints,
|
|
191
|
-
providerId,
|
|
192
|
-
modelId,
|
|
193
|
-
request,
|
|
194
|
-
providerWireRow,
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
const account = await selectProbeAccount({
|
|
198
|
-
db: this.db,
|
|
199
|
-
tenantId,
|
|
200
|
-
providerId,
|
|
201
|
-
requestedAccountId: request.accountId,
|
|
202
|
-
requiresAccount,
|
|
203
|
-
allowCoolingAccount:
|
|
204
|
-
providerId === "grok" && request.prompt === GROK_407_PROBE_PROMPT,
|
|
205
|
-
});
|
|
206
|
-
if (!account.ok) {
|
|
207
|
-
return { ok: false, latencyMs: Date.now() - startedAt, error: account.error };
|
|
208
|
-
}
|
|
209
|
-
const accountId = account.accountId;
|
|
210
|
-
|
|
211
|
-
const adapter = await resolveProbeAdapter({
|
|
212
|
-
providerRegistry: this.providerRegistry,
|
|
213
|
-
providerId,
|
|
214
|
-
providerWireRow,
|
|
215
|
-
});
|
|
216
|
-
if (!adapter) {
|
|
217
|
-
return {
|
|
218
|
-
ok: false,
|
|
219
|
-
latencyMs: Date.now() - startedAt,
|
|
220
|
-
error: `No adapter registered for provider ${providerId}`,
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const { probeReasoning, payloadCaptureEnabled } = await loadProbePreferences({
|
|
225
|
-
db: this.db,
|
|
226
|
-
tenantId,
|
|
227
|
-
wireFamily,
|
|
228
|
-
request,
|
|
229
|
-
});
|
|
230
|
-
const canonicalRequest = buildProbeCanonicalRequest({
|
|
231
|
-
modelId,
|
|
232
|
-
request,
|
|
233
|
-
probeReasoning,
|
|
234
|
-
sourceSurface,
|
|
235
|
-
});
|
|
236
|
-
let capturedRequest = canonicalRequest;
|
|
237
|
-
const parsedProviderId = parseProviderId(providerId);
|
|
238
|
-
const candidate: AdapterDispatchTarget = {
|
|
239
|
-
provider_id: parsedProviderId,
|
|
240
|
-
model_id: modelId,
|
|
241
|
-
wire_family: wireFamily,
|
|
242
|
-
endpoint_path: endpointPath,
|
|
243
|
-
// The route's real profile, not `{}`. A probe runs the production adapter,
|
|
244
|
-
// which gates on capabilities — an empty profile made it reject the probe
|
|
245
|
-
// over capabilities the route actually has (`prompt_caching`, `tools`,
|
|
246
|
-
// `reasoning`), reporting a healthy provider as broken.
|
|
247
|
-
capabilities: capabilityProfile,
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
const probeSignal = AbortSignal.timeout(30_000);
|
|
251
|
-
const events: CanonicalEvent[] = [];
|
|
252
|
-
/**
|
|
253
|
-
* Populated when the probe dials. Declared here, not inside the try, so the
|
|
254
|
-
* model the upstream named is still readable after the block unwinds — the
|
|
255
|
-
* result is assembled outside it.
|
|
256
|
-
*/
|
|
257
|
-
let probeCapture: ProviderExchangeCapture | undefined;
|
|
258
|
-
let ttfbMs: number | undefined;
|
|
259
|
-
let dispatchError: unknown;
|
|
260
|
-
/** Hits a `websearch` probe returned; surfaced to the console verbatim. */
|
|
261
|
-
let searchResults: ProbeModelResult["searchResults"];
|
|
262
|
-
let networkPoolId: string | undefined;
|
|
263
|
-
try {
|
|
264
|
-
// Public providers (`requires_account: false`) probe credential-free;
|
|
265
|
-
const credential = accountId
|
|
266
|
-
? await resolveCredentialForAccount(this.db, providerId, accountId)
|
|
267
|
-
: { provider_id: parsedProviderId, credential_kind: "none" as const };
|
|
268
|
-
const outbound = asOutboundBinding(await this.outboundFetchFor(tenantId, providerId));
|
|
269
|
-
networkPoolId = outbound.networkPoolId;
|
|
270
|
-
probeCapture = {
|
|
271
|
-
request: null,
|
|
272
|
-
response: Promise.resolve(null),
|
|
273
|
-
};
|
|
274
|
-
// The same wrapper a real dispatch uses, so the probe reads the model the
|
|
275
|
-
// upstream named from the raw bytes rather than from a synthesized event.
|
|
276
|
-
const probeFetch = createProbeFetch(
|
|
277
|
-
// Body capture on, unlike a live dispatch: a probe is one-shot and
|
|
278
|
-
// operator-initiated, so it exists to be inspected. With it off, the
|
|
279
|
-
// provider-response tee resolved to null and every probe row reported
|
|
280
|
-
// the middle two stages as absent — a constant, not a measurement,
|
|
281
|
-
// which made the stage markers useless for the diagnosis they exist
|
|
282
|
-
// for. The 256 KiB cap in the tee bounds the cost.
|
|
283
|
-
captureProviderExchange(outbound.fetch, probeCapture, true),
|
|
284
|
-
);
|
|
285
|
-
try {
|
|
286
|
-
if (serviceKind === "websearch") {
|
|
287
|
-
if (typeof adapter.websearch !== "function") {
|
|
288
|
-
throw new GatewayError(
|
|
289
|
-
"capability_unsupported",
|
|
290
|
-
400,
|
|
291
|
-
`Provider ${providerId} has no web search transport`,
|
|
292
|
-
);
|
|
293
|
-
}
|
|
294
|
-
const searchProbeQuery = request.prompt?.trim() || "Cartethyia gateway probe";
|
|
295
|
-
const response = await adapter.websearch(
|
|
296
|
-
{ model: modelId, query: searchProbeQuery, max_results: 3 },
|
|
297
|
-
candidate,
|
|
298
|
-
{
|
|
299
|
-
credential,
|
|
300
|
-
deadline: startedAt + 30_000,
|
|
301
|
-
abort_signal: probeSignal,
|
|
302
|
-
outbound_fetch: probeFetch,
|
|
303
|
-
},
|
|
304
|
-
);
|
|
305
|
-
if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
|
|
306
|
-
searchResults = response.results.map((hit) => ({
|
|
307
|
-
title: hit.title,
|
|
308
|
-
url: hit.url,
|
|
309
|
-
snippet: hit.snippet,
|
|
310
|
-
}));
|
|
311
|
-
if (!response.results || response.results.length === 0) {
|
|
312
|
-
dispatchError = new GatewayError("platform_unavailable", 502, "web search returned no results");
|
|
313
|
-
}
|
|
314
|
-
} else if (serviceKind === "systemone") {
|
|
315
|
-
if (typeof adapter.systemone !== "function") {
|
|
316
|
-
throw new GatewayError(
|
|
317
|
-
"capability_unsupported",
|
|
318
|
-
400,
|
|
319
|
-
`Provider ${providerId} has no System One transport`,
|
|
320
|
-
);
|
|
321
|
-
}
|
|
322
|
-
const response = await adapter.systemone(
|
|
323
|
-
{
|
|
324
|
-
model: modelId,
|
|
325
|
-
state: "Customer: I was charged twice for my order this morning.",
|
|
326
|
-
questions: {
|
|
327
|
-
probe: {
|
|
328
|
-
type: "noul",
|
|
329
|
-
instructions: "Is the customer reporting a billing problem?",
|
|
330
|
-
},
|
|
331
|
-
},
|
|
332
|
-
},
|
|
333
|
-
candidate,
|
|
334
|
-
{
|
|
335
|
-
credential,
|
|
336
|
-
deadline: startedAt + 30_000,
|
|
337
|
-
abort_signal: probeSignal,
|
|
338
|
-
outbound_fetch: probeFetch,
|
|
339
|
-
},
|
|
340
|
-
);
|
|
341
|
-
if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
|
|
342
|
-
const raw = await response.text().catch(() => "");
|
|
343
|
-
if (!response.ok) {
|
|
344
|
-
dispatchError = new GatewayError(
|
|
345
|
-
response.status >= 500 ? "platform_unavailable" : "invalid_request",
|
|
346
|
-
response.status,
|
|
347
|
-
`System One upstream error: ${raw.slice(0, 240)}`,
|
|
348
|
-
);
|
|
349
|
-
} else {
|
|
350
|
-
let parsed: unknown;
|
|
351
|
-
try {
|
|
352
|
-
parsed = raw ? JSON.parse(raw) : null;
|
|
353
|
-
} catch {
|
|
354
|
-
dispatchError = new GatewayError("platform_unavailable", 502, "System One returned invalid JSON");
|
|
355
|
-
}
|
|
356
|
-
if (parsed !== null && parsed !== undefined) {
|
|
357
|
-
const answers =
|
|
358
|
-
typeof parsed === "object" ? (parsed as Record<string, unknown>).answers : undefined;
|
|
359
|
-
const hasAnswers =
|
|
360
|
-
answers !== undefined &&
|
|
361
|
-
answers !== null &&
|
|
362
|
-
typeof answers === "object" &&
|
|
363
|
-
Object.keys(answers as Record<string, unknown>).length > 0;
|
|
364
|
-
if (!hasAnswers) {
|
|
365
|
-
dispatchError = new GatewayError(
|
|
366
|
-
"platform_unavailable",
|
|
367
|
-
502,
|
|
368
|
-
"System One returned no answers",
|
|
369
|
-
);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
} else {
|
|
374
|
-
const dispatchProbe = async (request: CanonicalRequest): Promise<void> => {
|
|
375
|
-
capturedRequest = request;
|
|
376
|
-
for await (const event of adapter.dispatch(request, candidate, {
|
|
377
|
-
credential,
|
|
378
|
-
deadline: startedAt + 30_000,
|
|
379
|
-
abort_signal: probeSignal,
|
|
380
|
-
outbound_fetch: probeFetch,
|
|
381
|
-
// A bidi-only provider (Cursor's Connect-RPC Run) has no `fetch`
|
|
382
|
-
// transport at all: without the HTTP/2 binding the probe fails with
|
|
383
|
-
// "requires validated HTTP/2 egress" while live dispatch — which
|
|
384
|
-
// always supplies it — succeeds.
|
|
385
|
-
...(outbound.http2Bidi === undefined ? {} : { outbound_http2: outbound.http2Bidi }),
|
|
386
|
-
})) {
|
|
387
|
-
if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
|
|
388
|
-
events.push(event);
|
|
389
|
-
}
|
|
390
|
-
};
|
|
391
|
-
try {
|
|
392
|
-
await dispatchProbe(canonicalRequest);
|
|
393
|
-
if (!hasMeaningfulOutput(events)) throw new Error("probe response content empty");
|
|
394
|
-
} catch (error) {
|
|
395
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
396
|
-
if (!/empty\s+(?:response\s+)?content|response\s+content\s+empty/i.test(message)) {
|
|
397
|
-
throw error;
|
|
398
|
-
}
|
|
399
|
-
// Some reasoning models spend the short probe budget before producing
|
|
400
|
-
// visible text. Retry with a larger output allowance, and stream when
|
|
401
|
-
// the first attempt did not — a streamed probe keeps the connection
|
|
402
|
-
// open while the model reasons instead of waiting for a complete body.
|
|
403
|
-
// Generic: no provider/model id special case is needed.
|
|
404
|
-
events.length = 0;
|
|
405
|
-
ttfbMs = undefined;
|
|
406
|
-
await dispatchProbe({
|
|
407
|
-
...canonicalRequest,
|
|
408
|
-
stream: true,
|
|
409
|
-
generation_controls: {
|
|
410
|
-
...canonicalRequest.generation_controls,
|
|
411
|
-
max_tokens: 1024,
|
|
412
|
-
max_output_tokens: 1024,
|
|
413
|
-
},
|
|
414
|
-
});
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
} finally {
|
|
418
|
-
outbound.release?.();
|
|
419
|
-
}
|
|
420
|
-
} catch (error) {
|
|
421
|
-
dispatchError = error;
|
|
422
|
-
}
|
|
423
|
-
const featureFailure = grok407ProbeFailure({
|
|
424
|
-
providerId,
|
|
425
|
-
prompt: request.prompt,
|
|
426
|
-
events,
|
|
427
|
-
dispatchError,
|
|
428
|
-
});
|
|
429
|
-
if (featureFailure !== undefined) dispatchError = featureFailure;
|
|
430
|
-
|
|
431
|
-
const upstreamStatusCode =
|
|
432
|
-
dispatchError instanceof GatewayError
|
|
433
|
-
? typeof dispatchError.details.upstreamStatus === "number"
|
|
434
|
-
? dispatchError.details.upstreamStatus
|
|
435
|
-
: typeof dispatchError.details.providerStatus === "number"
|
|
436
|
-
? dispatchError.details.providerStatus
|
|
437
|
-
: undefined
|
|
438
|
-
: undefined;
|
|
439
|
-
await recordProbeHealth({
|
|
440
|
-
db: this.db,
|
|
441
|
-
invalidator: this.snapshotInvalidator,
|
|
442
|
-
accountId,
|
|
443
|
-
dispatchError,
|
|
444
|
-
// Passed so a refusal delivered as a terminal event — not as a thrown
|
|
445
|
-
// error — still reaches the health machine. Without it, Cursor's
|
|
446
|
-
// `resource_exhausted` left `dispatchError` undefined and the probe
|
|
447
|
-
// recovered an exhausted account.
|
|
448
|
-
...(events === undefined ? {} : { events }),
|
|
449
|
-
modelId,
|
|
450
|
-
upstreamStatusCode,
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
const latencyMs = Date.now() - startedAt;
|
|
454
|
-
// A native-service probe carries no canonical events: its verdict is the
|
|
455
|
-
// decision response itself (no dispatch error + answers present), which the
|
|
456
|
-
// branch above already encoded into `dispatchError`. Web search is a
|
|
457
|
-
// native service too — routing it through the LLM verdict demanded a
|
|
458
|
-
// `terminal` event the search transport never emits, so a search that
|
|
459
|
-
// returned hits was reported as a failure with an empty message.
|
|
460
|
-
const { ok, errorMessage, usage } =
|
|
461
|
-
serviceKind === "systemone" || serviceKind === "websearch"
|
|
462
|
-
? {
|
|
463
|
-
ok: dispatchError === undefined,
|
|
464
|
-
errorMessage:
|
|
465
|
-
dispatchError === undefined
|
|
466
|
-
? undefined
|
|
467
|
-
: dispatchError instanceof GatewayError
|
|
468
|
-
? `Search provider ${providerId}/${modelId} — ${dispatchError.message} (HTTP ${dispatchError.status})`
|
|
469
|
-
: dispatchError instanceof Error
|
|
470
|
-
? `${providerId}/${modelId} — ${dispatchError.message}`
|
|
471
|
-
: String(dispatchError),
|
|
472
|
-
usage: undefined,
|
|
473
|
-
}
|
|
474
|
-
: computeProbeVerdict({
|
|
475
|
-
events,
|
|
476
|
-
dispatchError,
|
|
477
|
-
wireFamily,
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
const requestId = crypto.randomUUID();
|
|
481
|
-
|
|
482
|
-
// Token speed shared with live traffic (`computeTokensPerSec`): probes
|
|
483
|
-
// record TTFT+latency only, so they report end-to-end effective speed.
|
|
484
|
-
const tokensPerSec = computeTokensPerSec({
|
|
485
|
-
outputTokens: usage?.output_tokens,
|
|
486
|
-
latencyMs,
|
|
487
|
-
});
|
|
488
|
-
|
|
489
|
-
// Probe telemetry rides the same batch buffer as live traffic. Keep the
|
|
490
|
-
// probe bodies linked by request_id so the console can inspect the exact
|
|
491
|
-
// canonical request and provider event stream that produced the result.
|
|
492
|
-
this.telemetryBuffer?.enqueue({
|
|
493
|
-
tenantId,
|
|
494
|
-
requestId,
|
|
495
|
-
sourceSurface,
|
|
496
|
-
requestedModel: modelId,
|
|
497
|
-
endpoint: endpointPath,
|
|
498
|
-
providerId,
|
|
499
|
-
...(accountId ? { accountId } : {}),
|
|
500
|
-
...(networkPoolId ? { networkPoolId } : {}),
|
|
501
|
-
latencyMs,
|
|
502
|
-
...(ttfbMs !== undefined ? { ttfbMs } : {}),
|
|
503
|
-
stream: capturedRequest.stream,
|
|
504
|
-
status: ok ? "completed" : "failed",
|
|
505
|
-
...(ok ? {} : { errorCategory: errorMessage ?? "probe_failed" }),
|
|
506
|
-
...(usage ? { usage } : {}),
|
|
507
|
-
...(tokensPerSec !== undefined ? { tokensPerSec } : {}),
|
|
508
|
-
});
|
|
509
|
-
if (this.telemetryBuffer && payloadCaptureEnabled) {
|
|
510
|
-
// All four surfaces, so the row's stage markers mean the same thing here
|
|
511
|
-
// as they do on a live request: which hop actually produced bytes.
|
|
512
|
-
const providerRequest = probeCapture?.request ?? null;
|
|
513
|
-
const providerResponse = await probeCapture?.response.catch(() => null) ?? null;
|
|
514
|
-
await this.payloadCapture
|
|
515
|
-
.capture({
|
|
516
|
-
tenantId,
|
|
517
|
-
requestId,
|
|
518
|
-
requestBody: capturedRequest,
|
|
519
|
-
// `events` is what the *client* receives, so it belongs in the client
|
|
520
|
-
// surface. Passing it as `responseBody` lit the client bit on probes
|
|
521
|
-
// whose event list was empty, over a body that was never captured.
|
|
522
|
-
// The legacy alias is explicitly absent here; `null` leaves bit 8 to
|
|
523
|
-
// be decided by the real client body alone.
|
|
524
|
-
responseBody: null,
|
|
525
|
-
...(events === undefined ? {} : { clientResponseBody: events }),
|
|
526
|
-
...(providerRequest === null ? {} : { providerRequestBody: providerRequest }),
|
|
527
|
-
...(providerResponse === null ? {} : { providerResponseBody: providerResponse }),
|
|
528
|
-
scope: "tenant",
|
|
529
|
-
tenantOptIn: true,
|
|
530
|
-
})
|
|
531
|
-
.catch(() => undefined);
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
const sample = extractSample(events);
|
|
535
|
-
const reportedModel = await probeCapture?.reportedModel?.catch(() => undefined);
|
|
536
|
-
return {
|
|
537
|
-
...(accountId ? { accountId } : {}),
|
|
538
|
-
ok,
|
|
539
|
-
latencyMs,
|
|
540
|
-
...(ttfbMs !== undefined ? { ttfbMs } : {}),
|
|
541
|
-
...(upstreamStatusCode === undefined ? {} : { statusCode: upstreamStatusCode }),
|
|
542
|
-
...(sample ? { sample } : {}),
|
|
543
|
-
...(reportedModel === undefined ? {} : { reportedModel }),
|
|
544
|
-
...(searchResults !== undefined && searchResults.length > 0 ? { searchResults } : {}),
|
|
545
|
-
...(errorMessage ? { error: errorMessage } : {}),
|
|
546
|
-
};
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/**
|
|
550
|
-
* Ad-hoc connectivity test for a not-yet-persisted BYOK provider.
|
|
551
|
-
*
|
|
552
|
-
* Dispatches a real `GET <base>/models` through the same SSRF-validated
|
|
553
|
-
* outbound fetch live traffic uses, stamping the credential with the auth
|
|
554
|
-
* header the chosen wire family actually reads (`x-api-key` for Messages,
|
|
555
|
-
* `Authorization: Bearer` otherwise) plus that wire's CLI identity headers.
|
|
556
|
-
* Reachability is the contract: any HTTP response proves the operator's base
|
|
557
|
-
* URL, credential, and egress path work — a 4xx is reported with its status
|
|
558
|
-
* rather than disguised as a transport failure.
|
|
559
|
-
*/
|
|
560
|
-
async testByokConnection(
|
|
561
|
-
tenantId: string,
|
|
562
|
-
request: ByokConnectionTestRequest,
|
|
563
|
-
): Promise<ByokConnectionTestResult> {
|
|
564
|
-
const startedAt = Date.now();
|
|
565
|
-
const requestedWire = request.wireFamily;
|
|
566
|
-
const wireFamily: WireFamily = (WIRE_FAMILIES as readonly string[]).includes(requestedWire)
|
|
567
|
-
? (requestedWire as WireFamily)
|
|
568
|
-
: "chat";
|
|
569
|
-
// An operator who pasted a full endpoint (`…/anthropic/v1/messages`) gets it
|
|
570
|
-
// stripped before the model list is derived, and told so. Probing the
|
|
571
|
-
// corrected root is what makes the result actionable: the alternative is a
|
|
572
|
-
// bare 404 against a path the gateway built from their own typo.
|
|
573
|
-
const submitted = splitEndpointBaseUrl(request.baseUrl);
|
|
574
|
-
const warning =
|
|
575
|
-
submitted.stripped === ""
|
|
576
|
-
? undefined
|
|
577
|
-
: `Base URL pointed at the endpoint "${submitted.stripped}". Cartethyia derives the model list from the root, so it tested "${submitted.baseUrl}" instead. Save that root as the Base URL.`;
|
|
578
|
-
const url = modelListUrl(request.baseUrl);
|
|
579
|
-
const outbound = asOutboundBinding(await this.outboundFetchFor(tenantId));
|
|
580
|
-
const probeFetch = createProbeFetch(outbound.fetch);
|
|
581
|
-
try {
|
|
582
|
-
const headers: Record<string, string> = {
|
|
583
|
-
accept: "application/json",
|
|
584
|
-
...authHeaders(
|
|
585
|
-
byokAuthHeaderShape([wireFamily]),
|
|
586
|
-
new TextEncoder().encode(request.apiKey),
|
|
587
|
-
request.apiKey.trim().length > 0 ? "api_key" : "none",
|
|
588
|
-
),
|
|
589
|
-
...(request.cliIdentity === false ? {} : resolveCustomCliHeaders(wireFamily)),
|
|
590
|
-
...(request.extraHeaders ?? {}),
|
|
591
|
-
};
|
|
592
|
-
const response = await probeFetch(url, {
|
|
593
|
-
method: "GET",
|
|
594
|
-
headers,
|
|
595
|
-
signal: AbortSignal.timeout(15_000),
|
|
596
|
-
});
|
|
597
|
-
const latencyMs = Date.now() - startedAt;
|
|
598
|
-
if (!response.ok) {
|
|
599
|
-
return {
|
|
600
|
-
ok: false,
|
|
601
|
-
latencyMs,
|
|
602
|
-
statusCode: response.status,
|
|
603
|
-
error: `Upstream rejected the connection test with HTTP ${response.status}`,
|
|
604
|
-
...(warning === undefined ? {} : { warning }),
|
|
605
|
-
};
|
|
606
|
-
}
|
|
607
|
-
// A body is nice to have but not required: some gateways answer `/models`
|
|
608
|
-
// with an empty envelope. Read it defensively so a non-JSON body cannot
|
|
609
|
-
// turn a successful reachability check into a failure.
|
|
610
|
-
let modelCount: number | undefined;
|
|
611
|
-
try {
|
|
612
|
-
const payload = (await response.json()) as unknown;
|
|
613
|
-
const entries = Array.isArray(payload)
|
|
614
|
-
? payload
|
|
615
|
-
: isRecord(payload) && Array.isArray(payload["data"])
|
|
616
|
-
? payload["data"]
|
|
617
|
-
: isRecord(payload) && Array.isArray(payload["models"])
|
|
618
|
-
? payload["models"]
|
|
619
|
-
: undefined;
|
|
620
|
-
if (entries) modelCount = entries.length;
|
|
621
|
-
} catch {
|
|
622
|
-
// Non-JSON success body: reachability already proven by the 2xx.
|
|
623
|
-
}
|
|
624
|
-
return {
|
|
625
|
-
ok: true,
|
|
626
|
-
latencyMs,
|
|
627
|
-
statusCode: response.status,
|
|
628
|
-
...(modelCount === undefined ? {} : { modelCount }),
|
|
629
|
-
...(warning === undefined ? {} : { warning }),
|
|
630
|
-
};
|
|
631
|
-
} catch (error) {
|
|
632
|
-
return {
|
|
633
|
-
ok: false,
|
|
634
|
-
latencyMs: Date.now() - startedAt,
|
|
635
|
-
error: error instanceof Error ? error.message : "Connection test failed",
|
|
636
|
-
...(warning === undefined ? {} : { warning }),
|
|
637
|
-
};
|
|
638
|
-
} finally {
|
|
639
|
-
outbound.release?.();
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
async syncModels(tenantId: string, providerId: string): Promise<{ synced: number }> {
|
|
644
|
-
const outbound = asOutboundBinding(await this.outboundFetchFor(tenantId, providerId));
|
|
645
|
-
try {
|
|
646
|
-
const discoveryFetch = asFetchLike(outbound.fetch);
|
|
647
|
-
const resolver = await this.providerRegistry.resolveModelDiscovery(providerId);
|
|
648
|
-
if (resolver) {
|
|
649
|
-
// An account is used when one is usable, and the fetch is attempted
|
|
650
|
-
// either way. Gating on a credential only made the common case
|
|
651
|
-
// unreachable: most providers list their models without auth, and the
|
|
652
|
-
// ones that do not answer 401 themselves, which names the actual
|
|
653
|
-
// problem where a local 409 named a missing account the operator may
|
|
654
|
-
// not even need.
|
|
655
|
-
const account = await this.usableAccount(tenantId, providerId);
|
|
656
|
-
const secret = account?.secret;
|
|
657
|
-
const providerRow = await this.loadProviderWireRow(providerId);
|
|
658
|
-
const effectiveBaseUrl = providerRow?.baseUrl ?? providerBaseUrl(providerId) ?? "";
|
|
659
|
-
let discovered: readonly ModelDefinition[] | null;
|
|
660
|
-
try {
|
|
661
|
-
discovered = await resolver({
|
|
662
|
-
baseUrl: effectiveBaseUrl,
|
|
663
|
-
credential: secret ?? "",
|
|
664
|
-
...(discoveryFetch ? { fetcher: discoveryFetch } : {}),
|
|
665
|
-
...(account?.authState === undefined ? {} : { authState: account.authState }),
|
|
666
|
-
});
|
|
667
|
-
} catch (error) {
|
|
668
|
-
throw new GatewayError(
|
|
669
|
-
"transport_unavailable",
|
|
670
|
-
502,
|
|
671
|
-
`Model discovery failed for ${providerId}: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
672
|
-
{},
|
|
673
|
-
"upstream",
|
|
674
|
-
);
|
|
675
|
-
}
|
|
676
|
-
if (!discovered) {
|
|
677
|
-
throw new GatewayError(
|
|
678
|
-
"transport_unavailable",
|
|
679
|
-
502,
|
|
680
|
-
`Model discovery failed for ${providerId}: endpoint returned no usable models`,
|
|
681
|
-
{},
|
|
682
|
-
"upstream",
|
|
683
|
-
);
|
|
684
|
-
}
|
|
685
|
-
const ctx = wireContextFrom(providerRow);
|
|
686
|
-
return this.persistDiscoveredModels(
|
|
687
|
-
providerId,
|
|
688
|
-
discovered,
|
|
689
|
-
(modelId) => resolveDiscoveredWire(modelId, ctx, undefined, this.defaultEndpoints),
|
|
690
|
-
ctx.endpointPathsByWireFamily,
|
|
691
|
-
effectiveBaseUrl,
|
|
692
|
-
);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
const providerRow = await this.loadProviderWireRow(providerId);
|
|
696
|
-
if (providerRow?.baseUrl) {
|
|
697
|
-
const secret = await this.usableAccountSecret(tenantId, providerId);
|
|
698
|
-
if (secret) {
|
|
699
|
-
// An Anthropic-compatible root reads `x-api-key`, not bearer auth,
|
|
700
|
-
// and a bare host needs its `/v1` segment before `/models` joins on.
|
|
701
|
-
// Both are derived from the same wire profile dispatch uses, so
|
|
702
|
-
// auto-fetch behaves identically for either flavor of custom provider.
|
|
703
|
-
const wireProfile = resolveByokWireProfile(
|
|
704
|
-
providerRow.wireFamilyDefault,
|
|
705
|
-
providerRow.compatibilityProfile as CompatibilityProfile | null,
|
|
706
|
-
);
|
|
707
|
-
const modelsBaseUrl = modelDiscoveryBaseUrl(providerRow.baseUrl);
|
|
708
|
-
let discovered: readonly ModelDefinition[] | null;
|
|
709
|
-
try {
|
|
710
|
-
discovered = await fetchOpenAICompatibleModels({
|
|
711
|
-
baseUrl: modelsBaseUrl,
|
|
712
|
-
providerId,
|
|
713
|
-
headers: {
|
|
714
|
-
...authHeaders(
|
|
715
|
-
wireProfile.authHeaderShape,
|
|
716
|
-
new TextEncoder().encode(secret),
|
|
717
|
-
"api_key",
|
|
718
|
-
),
|
|
719
|
-
},
|
|
720
|
-
...(discoveryFetch ? { fetcher: discoveryFetch as typeof fetch } : {}),
|
|
721
|
-
});
|
|
722
|
-
} catch (error) {
|
|
723
|
-
throw new GatewayError(
|
|
724
|
-
"transport_unavailable",
|
|
725
|
-
502,
|
|
726
|
-
`Model discovery failed for ${providerId}: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
727
|
-
{},
|
|
728
|
-
"upstream",
|
|
729
|
-
);
|
|
730
|
-
}
|
|
731
|
-
if (discovered && discovered.length > 0) {
|
|
732
|
-
const ctx = wireContextFrom(providerRow);
|
|
733
|
-
return this.persistDiscoveredModels(
|
|
734
|
-
providerId,
|
|
735
|
-
discovered,
|
|
736
|
-
(modelId) => resolveDiscoveredWire(modelId, ctx, undefined, this.defaultEndpoints),
|
|
737
|
-
ctx.endpointPathsByWireFamily,
|
|
738
|
-
providerRow.baseUrl ?? undefined,
|
|
739
|
-
// The generic `/models` fetch guesses a wire from the model id;
|
|
740
|
-
// the row's own profile is what the adapter will enforce, so it
|
|
741
|
-
// decides which guess is admissible.
|
|
742
|
-
wireProfile.supportedWireFamilies,
|
|
743
|
-
);
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
// Two reasons a sync reaches here with nothing dialed, and the operator
|
|
749
|
-
// needs to be told which one. Returning `{synced: 0}` for both made an
|
|
750
|
-
// unsupported provider indistinguishable from a successful fetch that
|
|
751
|
-
// found nothing — the console showed a green "0 synced" for a provider
|
|
752
|
-
// it can never sync.
|
|
753
|
-
const bundled = isBundledProviderId(providerId);
|
|
754
|
-
if (!bundled) {
|
|
755
|
-
// A custom provider has a row and a base URL but no usable credential.
|
|
756
|
-
// The fix is an account, and saying "no listing endpoint" would send the
|
|
757
|
-
// operator looking for a capability the provider may well have.
|
|
758
|
-
throw new GatewayError(
|
|
759
|
-
"invalid_request",
|
|
760
|
-
400,
|
|
761
|
-
`Add an account for ${providerId} first — model listing needs a credential.`,
|
|
762
|
-
{},
|
|
763
|
-
"cartethyia",
|
|
764
|
-
);
|
|
765
|
-
}
|
|
766
|
-
if (!(await this.usableAccountSecret(tenantId, providerId))) {
|
|
767
|
-
// A bundled provider with an account would have gone through the
|
|
768
|
-
// resolver branch above, so reaching here means none is registered.
|
|
769
|
-
// Add one first, then fetch.
|
|
770
|
-
throw new GatewayError(
|
|
771
|
-
"invalid_request",
|
|
772
|
-
400,
|
|
773
|
-
`Add an account for ${providerId} before fetching its models.`,
|
|
774
|
-
{},
|
|
775
|
-
"cartethyia",
|
|
776
|
-
);
|
|
777
|
-
}
|
|
778
|
-
// A credential exists and the provider still has no listing path: the
|
|
779
|
-
// catalog is the compiled one, and new models arrive with an update.
|
|
780
|
-
throw new GatewayError(
|
|
781
|
-
"capability_unsupported",
|
|
782
|
-
400,
|
|
783
|
-
`${providerId} publishes no model-listing endpoint. Its catalog ships with Cartethyia; new models appear with an update.`,
|
|
784
|
-
{},
|
|
785
|
-
"cartethyia",
|
|
786
|
-
);
|
|
787
|
-
} finally {
|
|
788
|
-
outbound.release?.();
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
private async persistDiscoveredModels(
|
|
793
|
-
providerId: string,
|
|
794
|
-
discoveredModels: readonly (string | ModelDefinition)[],
|
|
795
|
-
resolve: (modelId: string) => DiscoveredModelWire,
|
|
796
|
-
endpointOverrides?: Partial<Record<WireFamily, string>>,
|
|
797
|
-
baseUrl?: string,
|
|
798
|
-
supportedWireFamilies?: readonly WireFamily[],
|
|
799
|
-
): Promise<{ synced: number }> {
|
|
800
|
-
// Static definitions are the injected bundled catalog; the probing service
|
|
801
|
-
// and the console store are both handed the same startup-built map.
|
|
802
|
-
const staticDefinitions: readonly ModelDefinition[] =
|
|
803
|
-
this.bundledModelCatalog.get(providerId) ?? [];
|
|
804
|
-
const staticEndpoints: Partial<Record<WireFamily, string>> = {};
|
|
805
|
-
for (const definition of staticDefinitions) {
|
|
806
|
-
staticEndpoints[definition.wireFamily] ??= definition.endpointPath;
|
|
807
|
-
}
|
|
808
|
-
// Fetch only adds genuinely new models: an id the builtin catalog
|
|
809
|
-
// already owns stays exactly one (builtin) row instead of gaining a
|
|
810
|
-
// discovered shadow that confuses pickers and deletes.
|
|
811
|
-
const builtinIds = new Set(staticDefinitions.map((definition) => definition.modelId));
|
|
812
|
-
// `(modelId, endpointPath)` is the row identity, so a corrected wire family
|
|
813
|
-
// lands on a *new* row and would leave the old one behind as a dead route.
|
|
814
|
-
// Collect this run's pairs and drop the provider's superseded discovered
|
|
815
|
-
// rows for the same ids afterwards.
|
|
816
|
-
const resolvedPairs: Array<{ modelId: string; endpointPath: string }> = [];
|
|
817
|
-
const rows: Array<typeof models.$inferInsert> = [];
|
|
818
|
-
// `(modelId, endpointPath)` is the row identity and the upsert's conflict
|
|
819
|
-
// target, so a listing that repeats a pair (the bespoke discoveries return
|
|
820
|
-
// raw upstream ids) would make a multi-row `ON CONFLICT DO UPDATE` fail with
|
|
821
|
-
// "cannot affect row a second time". Keep the first row per pair, as the
|
|
822
|
-
// builtin seeder does, so one statement can carry a whole chunk.
|
|
823
|
-
const seenPairs = new Set<string>();
|
|
824
|
-
for (const item of discoveredModels) {
|
|
825
|
-
const modelId = typeof item === "string" ? item : item.modelId;
|
|
826
|
-
if (builtinIds.has(modelId)) continue;
|
|
827
|
-
const resolved = resolve(modelId);
|
|
828
|
-
const discDef = typeof item === "string" ? undefined : item;
|
|
829
|
-
const { wireFamily, endpointPath } = applyDiscoveredWire({
|
|
830
|
-
resolvedWireFamily: resolved.wireFamily,
|
|
831
|
-
resolvedEndpointPath: resolved.endpointPath,
|
|
832
|
-
...(discDef !== undefined
|
|
833
|
-
? { discoveredWireFamily: discDef.wireFamily, discoveredEndpointPath: discDef.endpointPath }
|
|
834
|
-
: {}),
|
|
835
|
-
staticEndpoints,
|
|
836
|
-
...(endpointOverrides !== undefined ? { endpointOverrides } : {}),
|
|
837
|
-
...(supportedWireFamilies !== undefined ? { supportedWireFamilies } : {}),
|
|
838
|
-
...(baseUrl !== undefined ? { baseUrl } : {}),
|
|
839
|
-
});
|
|
840
|
-
resolvedPairs.push({ modelId, endpointPath });
|
|
841
|
-
const pairKey = `${modelId}\u0000${endpointPath}`;
|
|
842
|
-
if (seenPairs.has(pairKey)) continue;
|
|
843
|
-
seenPairs.add(pairKey);
|
|
844
|
-
const knownDefinition = staticDefinitions.find((definition) => definition.modelId === modelId);
|
|
845
|
-
const sourceUpdatedAt = new Date();
|
|
846
|
-
const metadata = {
|
|
847
|
-
contextLimit: knownDefinition?.contextLimit ?? discDef?.contextLimit ?? null,
|
|
848
|
-
outputLimit: knownDefinition?.outputLimit ?? discDef?.outputLimit ?? null,
|
|
849
|
-
modalities: knownDefinition?.modalities ?? discDef?.modalities ?? null,
|
|
850
|
-
reasoning: knownDefinition?.reasoning ?? discDef?.reasoning ?? false,
|
|
851
|
-
toolCall: knownDefinition?.toolCall ?? discDef?.toolCall ?? false,
|
|
852
|
-
cost: modelsDevCatalog.costFor(providerId, modelId),
|
|
853
|
-
// Discovery builds its rows from the same static catalog, so a fetched
|
|
854
|
-
// row carries the same answer a seeded one does. Null when neither
|
|
855
|
-
// source marked it, which reads as "unknown" rather than "free".
|
|
856
|
-
proOnly: (discDef?.proOnly ?? knownDefinition?.proOnly ?? null),
|
|
857
|
-
// Same shape for the tier the provider states itself (Qoder `is_free`).
|
|
858
|
-
freeTier: (discDef?.freeTier ?? knownDefinition?.freeTier ?? null),
|
|
859
|
-
};
|
|
860
|
-
rows.push({
|
|
861
|
-
providerId,
|
|
862
|
-
modelId,
|
|
863
|
-
wireFamily,
|
|
864
|
-
// A discovery module that classifies an id as a non-chat service (the
|
|
865
|
-
// System One decision API) states it on its own definition; otherwise the
|
|
866
|
-
// static catalog's classification wins, and an unclassified id is `llm`.
|
|
867
|
-
serviceKind: discDef?.serviceKind ?? knownDefinition?.serviceKind ?? "llm",
|
|
868
|
-
endpointPath,
|
|
869
|
-
...metadata,
|
|
870
|
-
// A discovery module that marks its rows as a free tier gets its own
|
|
871
|
-
// source, because the model list groups on it: a free-tier row is not
|
|
872
|
-
// an ordinary fetched one, and collapsing both into `discovered` lost
|
|
873
|
-
// that distinction at the only point where it is still known.
|
|
874
|
-
source: discDef?.freeTier === true ? "auto_free" : "discovered",
|
|
875
|
-
sourceUpdatedAt,
|
|
876
|
-
enabled: true,
|
|
877
|
-
});
|
|
878
|
-
}
|
|
879
|
-
// One statement per chunk (500, the account-health batch step) instead of
|
|
880
|
-
// one round-trip per row. `excluded.*` reads the proposed row, so the update
|
|
881
|
-
// set is the same columns the per-row upsert wrote.
|
|
882
|
-
for (let offset = 0; offset < rows.length; offset += 500) {
|
|
883
|
-
await this.db
|
|
884
|
-
.insert(models)
|
|
885
|
-
.values(rows.slice(offset, offset + 500))
|
|
886
|
-
.onConflictDoUpdate({
|
|
887
|
-
target: [models.providerId, models.modelId, models.endpointPath],
|
|
888
|
-
set: {
|
|
889
|
-
serviceKind: sql`excluded.service_kind`,
|
|
890
|
-
contextLimit: sql`excluded.context_limit`,
|
|
891
|
-
outputLimit: sql`excluded.output_limit`,
|
|
892
|
-
modalities: sql`excluded.modalities`,
|
|
893
|
-
reasoning: sql`excluded.reasoning`,
|
|
894
|
-
toolCall: sql`excluded.tool_call`,
|
|
895
|
-
cost: sql`excluded.cost`,
|
|
896
|
-
proOnly: sql`excluded.pro_only`,
|
|
897
|
-
freeTier: sql`excluded.free_tier`,
|
|
898
|
-
// The proposed row's own source, not a constant: a free-tier
|
|
899
|
-
// discovery and an ordinary one write the same upsert, and pinning
|
|
900
|
-
// this to `discovered` would relabel every free-tier row on the
|
|
901
|
-
// next sync.
|
|
902
|
-
source: sql`excluded.source`,
|
|
903
|
-
sourceUpdatedAt: sql`excluded.source_updated_at`,
|
|
904
|
-
},
|
|
905
|
-
});
|
|
906
|
-
}
|
|
907
|
-
const synced = rows.length;
|
|
908
|
-
// Drop this provider's discovered rows for the synced ids that are no
|
|
909
|
-
// longer part of the resolved set — the stale `(model, endpoint)` pair left
|
|
910
|
-
// behind when a corrected wire family moved a model to a new path. Rows the
|
|
911
|
-
// operator added by hand (`manual`) and ids outside this run are untouched.
|
|
912
|
-
// `auto_free` is included: it is written by this same sync, so leaving it
|
|
913
|
-
// out would make a free-tier row the one kind of synced row that never gets
|
|
914
|
-
// pruned when its id moves.
|
|
915
|
-
if (resolvedPairs.length > 0) {
|
|
916
|
-
const pairs = resolvedPairs.map(
|
|
917
|
-
(pair) => sql`(${pair.modelId}, ${pair.endpointPath})`,
|
|
918
|
-
);
|
|
919
|
-
const ids = resolvedPairs.map((pair) => pair.modelId);
|
|
920
|
-
await this.db
|
|
921
|
-
.delete(models)
|
|
922
|
-
.where(
|
|
923
|
-
and(
|
|
924
|
-
eq(models.providerId, providerId),
|
|
925
|
-
inArray(models.source, ["discovered", "auto_free"]),
|
|
926
|
-
sql`${models.modelId} IN (${sql.join(
|
|
927
|
-
ids.map((id) => sql`${id}`),
|
|
928
|
-
sql`, `,
|
|
929
|
-
)})`,
|
|
930
|
-
sql`(${models.modelId}, ${models.endpointPath}) NOT IN (${sql.join(pairs, sql`, `)})`,
|
|
931
|
-
),
|
|
932
|
-
);
|
|
933
|
-
}
|
|
934
|
-
return { synced };
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
private async usableAccountSecret(
|
|
938
|
-
tenantId: string,
|
|
939
|
-
providerId: string,
|
|
940
|
-
): Promise<string | undefined> {
|
|
941
|
-
return (await this.usableAccount(tenantId, providerId))?.secret;
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
/**
|
|
945
|
-
* The account a credential-scoped discovery will use, with its non-secret
|
|
946
|
-
* auth configuration. Selection is unchanged — first usable row, preferring a
|
|
947
|
-
* tenant-owned one — so discovery and dispatch agree on which account answered.
|
|
948
|
-
*/
|
|
949
|
-
private async usableAccount(
|
|
950
|
-
tenantId: string,
|
|
951
|
-
providerId: string,
|
|
952
|
-
): Promise<{ readonly secret: string; readonly authState?: Readonly<Record<string, unknown>> } | undefined> {
|
|
953
|
-
const rows = await this.db
|
|
954
|
-
.select()
|
|
955
|
-
.from(providerAccounts)
|
|
956
|
-
.where(
|
|
957
|
-
and(
|
|
958
|
-
eq(providerAccounts.providerId, providerId),
|
|
959
|
-
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
960
|
-
),
|
|
961
|
-
);
|
|
962
|
-
const now = Date.now();
|
|
963
|
-
const usable = rows
|
|
964
|
-
.filter(
|
|
965
|
-
(row) =>
|
|
966
|
-
row.status !== "disabled" &&
|
|
967
|
-
(row.cooldownUntil === null || row.cooldownUntil.getTime() <= now),
|
|
968
|
-
)
|
|
969
|
-
.sort((left, right) => Number(right.tenantId !== null) - Number(left.tenantId !== null))[0];
|
|
970
|
-
if (!usable?.credentialCiphertext) return undefined;
|
|
971
|
-
const authState = isRecord(usable.authState) ? usable.authState as Readonly<Record<string, unknown>> : undefined;
|
|
972
|
-
return {
|
|
973
|
-
secret: decryptCredentialToString(usable.credentialCiphertext),
|
|
974
|
-
...(authState === undefined ? {} : { authState }),
|
|
975
|
-
};
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
/**
|
|
979
|
-
* Batched provider-wide probe: the first request runs sequentially (it warms
|
|
980
|
-
* OAuth token caches and connection pools for the provider), the rest run
|
|
981
|
-
*
|
|
982
|
-
* carries its model route and service kind so native services never fall
|
|
983
|
-
* through to an LLM probe.
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
)
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1
|
+
import { captureProviderExchange, type ProviderExchangeCapture } from "../../transport/dispatch/attempt-finalize";
|
|
2
|
+
import { globalOrOwnedBy } from "../../persistence/tenant-scope";
|
|
3
|
+
/**
|
|
4
|
+
* Provider probing and model-discovery orchestration.
|
|
5
|
+
*
|
|
6
|
+
* Network probes and `/v1/models` discovery stay separate from the Drizzle
|
|
7
|
+
* catalog repository while sharing the provider catalog contract.
|
|
8
|
+
*/
|
|
9
|
+
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
10
|
+
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
11
|
+
import { models, providerAccounts, providers } from "../../persistence/schema";
|
|
12
|
+
import type { CanonicalEvent, CanonicalRequest, WireFamily } from "../../protocol/canonical-model";
|
|
13
|
+
import { WIRE_FAMILIES } from "../../protocol/canonical-model";
|
|
14
|
+
import { isRecord } from "../../protocol/codecs";
|
|
15
|
+
import type { FetchLike } from "../quota/quota-contracts";
|
|
16
|
+
import type { CompatibilityProfile } from "../provider-metadata";
|
|
17
|
+
import {
|
|
18
|
+
type ByokConnectionTestRequest,
|
|
19
|
+
type ByokConnectionTestResult,
|
|
20
|
+
type ProbeAllAccountsResult,
|
|
21
|
+
type ProbeAllModelsResult,
|
|
22
|
+
type ProbeModelRequest,
|
|
23
|
+
type ProbeModelResult,
|
|
24
|
+
} from "./discovery-types";
|
|
25
|
+
import { modelsDevCatalog } from "./models-dev-catalog";
|
|
26
|
+
import { fetchOpenAICompatibleModels } from "./openai-model-discovery";
|
|
27
|
+
import {
|
|
28
|
+
applyDiscoveredWire,
|
|
29
|
+
resolveDiscoveredWire,
|
|
30
|
+
staticEndpointForWire,
|
|
31
|
+
type DiscoveredModelWire,
|
|
32
|
+
} from "./probe-wire";
|
|
33
|
+
import {
|
|
34
|
+
buildProbeCanonicalRequest,
|
|
35
|
+
computeProbeVerdict,
|
|
36
|
+
extractSample,
|
|
37
|
+
GROK_407_PROBE_PROMPT,
|
|
38
|
+
grok407ProbeFailure,
|
|
39
|
+
hasMeaningfulOutput,
|
|
40
|
+
loadProbePreferences,
|
|
41
|
+
recordProbeHealth,
|
|
42
|
+
resolveProbeAdapter,
|
|
43
|
+
resolveProbeTarget,
|
|
44
|
+
selectProbeAccount,
|
|
45
|
+
wireContextFrom,
|
|
46
|
+
type ProbeProviderRow,
|
|
47
|
+
} from "./probe-phases";
|
|
48
|
+
import { createProbeFetch } from "../operations/probe-fetch";
|
|
49
|
+
import { authHeaders } from "../adapters/compatible-adapter";
|
|
50
|
+
import {
|
|
51
|
+
byokAuthHeaderShape,
|
|
52
|
+
modelDiscoveryBaseUrl,
|
|
53
|
+
modelListUrl,
|
|
54
|
+
resolveByokWireProfile,
|
|
55
|
+
splitEndpointBaseUrl,
|
|
56
|
+
} from "../operations/byok-wire-profile";
|
|
57
|
+
import { resolveCustomCliHeaders } from "../operations/custom-cli-headers";
|
|
58
|
+
import { resolveCredentialForAccount } from "../operations/provider-credential-service";
|
|
59
|
+
import { decryptCredentialToString } from "../../security/crypto";
|
|
60
|
+
import { GatewayError } from "../../protocol/gateway-error";
|
|
61
|
+
import type { TelemetryBatchBuffer } from "../../observability/telemetry-buffer";
|
|
62
|
+
import { computeTokensPerSec } from "../../observability/token-speed";
|
|
63
|
+
import { TelemetryPayloadCapture } from "../../observability/payload-capture";
|
|
64
|
+
import { providerBaseUrl } from "../provider-metadata";
|
|
65
|
+
import { isBundledProviderId, parseProviderId, type ProviderDispatchTarget as AdapterDispatchTarget, type ValidatedOutboundFetch } from "../provider-contracts";
|
|
66
|
+
import type { ValidatedOutboundHttp2Bidi } from "../provider-contracts";
|
|
67
|
+
import type { ModelDefinition } from "../provider-model-definition";
|
|
68
|
+
import type { ProviderRegistry } from "../provider-registry";
|
|
69
|
+
|
|
70
|
+
/** Max models probed concurrently per batch (outside the sequential warm-up). */
|
|
71
|
+
const PROBE_CONCURRENCY = 5;
|
|
72
|
+
/** Max accounts probed concurrently; batches wait before starting the next ten. */
|
|
73
|
+
const PROBE_ACCOUNT_CONCURRENCY = 10;
|
|
74
|
+
|
|
75
|
+
export interface ProbeOutboundBinding {
|
|
76
|
+
readonly fetch: ValidatedOutboundFetch;
|
|
77
|
+
/**
|
|
78
|
+
* Validated bidirectional HTTP/2 egress for the same pool/tenant. A provider
|
|
79
|
+
* whose only transport is a bidi stream (Cursor's Connect-RPC Run) has no
|
|
80
|
+
* `fetch` path to fall back to, so a probe without this binding reports a
|
|
81
|
+
* healthy account as broken.
|
|
82
|
+
*/
|
|
83
|
+
readonly http2Bidi?: ValidatedOutboundHttp2Bidi;
|
|
84
|
+
readonly networkPoolId?: string;
|
|
85
|
+
readonly release?: () => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type ProbeOutboundResolver = (
|
|
89
|
+
tenantId: string,
|
|
90
|
+
providerId?: string,
|
|
91
|
+
) => Promise<ValidatedOutboundFetch | ProbeOutboundBinding> | ValidatedOutboundFetch | ProbeOutboundBinding;
|
|
92
|
+
|
|
93
|
+
interface ProviderProbingDeps {
|
|
94
|
+
readonly db: CartethyiaDatabase;
|
|
95
|
+
readonly telemetryBuffer: TelemetryBatchBuffer | undefined;
|
|
96
|
+
readonly defaultEndpoints: Record<WireFamily, string>;
|
|
97
|
+
readonly bundledModelCatalog: ReadonlyMap<string, readonly ModelDefinition[]>;
|
|
98
|
+
readonly outboundFetchFor: ProbeOutboundResolver;
|
|
99
|
+
readonly snapshotInvalidator: { invalidate(): unknown };
|
|
100
|
+
readonly providerRegistry: ProviderRegistry;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Adapts a validated outbound fetch to the `FetchLike` shape discovery
|
|
105
|
+
* helpers expect. Only the call signature is consumed downstream.
|
|
106
|
+
*/
|
|
107
|
+
function asFetchLike(fetchFn: ValidatedOutboundFetch): FetchLike {
|
|
108
|
+
const wrapped = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
|
|
109
|
+
fetchFn(input, init);
|
|
110
|
+
return Object.assign(wrapped, { preconnect: () => undefined });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function asOutboundBinding(
|
|
114
|
+
resolved: ValidatedOutboundFetch | ProbeOutboundBinding,
|
|
115
|
+
): ProbeOutboundBinding {
|
|
116
|
+
return typeof resolved === "function" ? { fetch: resolved } : resolved;
|
|
117
|
+
}
|
|
118
|
+
export class ProviderProbingService {
|
|
119
|
+
private readonly db: CartethyiaDatabase;
|
|
120
|
+
private readonly telemetryBuffer: TelemetryBatchBuffer | undefined;
|
|
121
|
+
private readonly payloadCapture: TelemetryPayloadCapture;
|
|
122
|
+
private readonly defaultEndpoints: Record<WireFamily, string>;
|
|
123
|
+
private readonly bundledModelCatalog: ReadonlyMap<string, readonly ModelDefinition[]>;
|
|
124
|
+
private readonly outboundFetchFor: ProbeOutboundResolver;
|
|
125
|
+
private readonly snapshotInvalidator: { invalidate(): unknown };
|
|
126
|
+
private readonly providerRegistry: ProviderRegistry;
|
|
127
|
+
|
|
128
|
+
constructor(deps: ProviderProbingDeps) {
|
|
129
|
+
this.db = deps.db;
|
|
130
|
+
this.telemetryBuffer = deps.telemetryBuffer;
|
|
131
|
+
this.payloadCapture = new TelemetryPayloadCapture(this.db);
|
|
132
|
+
this.defaultEndpoints = deps.defaultEndpoints;
|
|
133
|
+
this.bundledModelCatalog = deps.bundledModelCatalog;
|
|
134
|
+
this.outboundFetchFor = deps.outboundFetchFor;
|
|
135
|
+
this.snapshotInvalidator = deps.snapshotInvalidator;
|
|
136
|
+
this.providerRegistry = deps.providerRegistry;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The provider row columns that decide wire-family/endpoint resolution.
|
|
141
|
+
* `probeModel` loads this once and reuses it for the contract gate, the
|
|
142
|
+
* custom-adapter fallback, and the wire context.
|
|
143
|
+
*/
|
|
144
|
+
private async loadProviderWireRow(providerId: string): Promise<ProbeProviderRow | undefined> {
|
|
145
|
+
const rows = await this.db
|
|
146
|
+
.select({
|
|
147
|
+
baseUrl: providers.baseUrl,
|
|
148
|
+
wireFamilyDefault: providers.wireFamilyDefault,
|
|
149
|
+
compatibilityProfile: providers.compatibilityProfile,
|
|
150
|
+
requiresAccount: providers.requiresAccount,
|
|
151
|
+
})
|
|
152
|
+
.from(providers)
|
|
153
|
+
.where(eq(providers.id, providerId))
|
|
154
|
+
.limit(1);
|
|
155
|
+
return rows[0];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Static endpoint for one provider + wire family, if the bundled catalog
|
|
160
|
+
* ships a static definition for it. Used by manual registration (via the
|
|
161
|
+
* console store) so it resolves the same provider-truth endpoints as probes.
|
|
162
|
+
*/
|
|
163
|
+
async resolveStaticEndpoint(
|
|
164
|
+
providerId: string,
|
|
165
|
+
wireFamily: WireFamily,
|
|
166
|
+
): Promise<string | undefined> {
|
|
167
|
+
return staticEndpointForWire(this.bundledModelCatalog, providerId, wireFamily);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** One-shot connectivity test: resolves a usable account + the real per-provider
|
|
171
|
+
* adapter, dispatches a minimal canonical request through the model's own wire
|
|
172
|
+
* family/endpoint (never a hardcoded `/v1/chat/completions`), and records the
|
|
173
|
+
* outcome into `telemetry_events` via the same writer live traffic uses. */
|
|
174
|
+
async probeModel(
|
|
175
|
+
tenantId: string,
|
|
176
|
+
providerId: string,
|
|
177
|
+
request: ProbeModelRequest,
|
|
178
|
+
): Promise<ProbeModelResult> {
|
|
179
|
+
const startedAt = Date.now();
|
|
180
|
+
const modelId = request.modelId;
|
|
181
|
+
// One provider snapshot per probe: the contract gate, the account gate, the
|
|
182
|
+
// custom-adapter fallback, and the wire context all read this same row, so a
|
|
183
|
+
// concurrent edit cannot make two reads disagree.
|
|
184
|
+
const providerWireRow = await this.loadProviderWireRow(providerId);
|
|
185
|
+
const requiresAccount = providerWireRow?.requiresAccount ?? true;
|
|
186
|
+
|
|
187
|
+
const { wireFamily, serviceKind, endpointPath, sourceSurface, capabilityProfile } = await resolveProbeTarget({
|
|
188
|
+
db: this.db,
|
|
189
|
+
bundledModelCatalog: this.bundledModelCatalog,
|
|
190
|
+
defaultEndpoints: this.defaultEndpoints,
|
|
191
|
+
providerId,
|
|
192
|
+
modelId,
|
|
193
|
+
request,
|
|
194
|
+
providerWireRow,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const account = await selectProbeAccount({
|
|
198
|
+
db: this.db,
|
|
199
|
+
tenantId,
|
|
200
|
+
providerId,
|
|
201
|
+
requestedAccountId: request.accountId,
|
|
202
|
+
requiresAccount,
|
|
203
|
+
allowCoolingAccount:
|
|
204
|
+
providerId === "grok" && request.prompt === GROK_407_PROBE_PROMPT,
|
|
205
|
+
});
|
|
206
|
+
if (!account.ok) {
|
|
207
|
+
return { ok: false, latencyMs: Date.now() - startedAt, error: account.error };
|
|
208
|
+
}
|
|
209
|
+
const accountId = account.accountId;
|
|
210
|
+
|
|
211
|
+
const adapter = await resolveProbeAdapter({
|
|
212
|
+
providerRegistry: this.providerRegistry,
|
|
213
|
+
providerId,
|
|
214
|
+
providerWireRow,
|
|
215
|
+
});
|
|
216
|
+
if (!adapter) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
latencyMs: Date.now() - startedAt,
|
|
220
|
+
error: `No adapter registered for provider ${providerId}`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const { probeReasoning, payloadCaptureEnabled } = await loadProbePreferences({
|
|
225
|
+
db: this.db,
|
|
226
|
+
tenantId,
|
|
227
|
+
wireFamily,
|
|
228
|
+
request,
|
|
229
|
+
});
|
|
230
|
+
const canonicalRequest = buildProbeCanonicalRequest({
|
|
231
|
+
modelId,
|
|
232
|
+
request,
|
|
233
|
+
probeReasoning,
|
|
234
|
+
sourceSurface,
|
|
235
|
+
});
|
|
236
|
+
let capturedRequest = canonicalRequest;
|
|
237
|
+
const parsedProviderId = parseProviderId(providerId);
|
|
238
|
+
const candidate: AdapterDispatchTarget = {
|
|
239
|
+
provider_id: parsedProviderId,
|
|
240
|
+
model_id: modelId,
|
|
241
|
+
wire_family: wireFamily,
|
|
242
|
+
endpoint_path: endpointPath,
|
|
243
|
+
// The route's real profile, not `{}`. A probe runs the production adapter,
|
|
244
|
+
// which gates on capabilities — an empty profile made it reject the probe
|
|
245
|
+
// over capabilities the route actually has (`prompt_caching`, `tools`,
|
|
246
|
+
// `reasoning`), reporting a healthy provider as broken.
|
|
247
|
+
capabilities: capabilityProfile,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const probeSignal = AbortSignal.timeout(30_000);
|
|
251
|
+
const events: CanonicalEvent[] = [];
|
|
252
|
+
/**
|
|
253
|
+
* Populated when the probe dials. Declared here, not inside the try, so the
|
|
254
|
+
* model the upstream named is still readable after the block unwinds — the
|
|
255
|
+
* result is assembled outside it.
|
|
256
|
+
*/
|
|
257
|
+
let probeCapture: ProviderExchangeCapture | undefined;
|
|
258
|
+
let ttfbMs: number | undefined;
|
|
259
|
+
let dispatchError: unknown;
|
|
260
|
+
/** Hits a `websearch` probe returned; surfaced to the console verbatim. */
|
|
261
|
+
let searchResults: ProbeModelResult["searchResults"];
|
|
262
|
+
let networkPoolId: string | undefined;
|
|
263
|
+
try {
|
|
264
|
+
// Public providers (`requires_account: false`) probe credential-free;
|
|
265
|
+
const credential = accountId
|
|
266
|
+
? await resolveCredentialForAccount(this.db, providerId, accountId)
|
|
267
|
+
: { provider_id: parsedProviderId, credential_kind: "none" as const };
|
|
268
|
+
const outbound = asOutboundBinding(await this.outboundFetchFor(tenantId, providerId));
|
|
269
|
+
networkPoolId = outbound.networkPoolId;
|
|
270
|
+
probeCapture = {
|
|
271
|
+
request: null,
|
|
272
|
+
response: Promise.resolve(null),
|
|
273
|
+
};
|
|
274
|
+
// The same wrapper a real dispatch uses, so the probe reads the model the
|
|
275
|
+
// upstream named from the raw bytes rather than from a synthesized event.
|
|
276
|
+
const probeFetch = createProbeFetch(
|
|
277
|
+
// Body capture on, unlike a live dispatch: a probe is one-shot and
|
|
278
|
+
// operator-initiated, so it exists to be inspected. With it off, the
|
|
279
|
+
// provider-response tee resolved to null and every probe row reported
|
|
280
|
+
// the middle two stages as absent — a constant, not a measurement,
|
|
281
|
+
// which made the stage markers useless for the diagnosis they exist
|
|
282
|
+
// for. The 256 KiB cap in the tee bounds the cost.
|
|
283
|
+
captureProviderExchange(outbound.fetch, probeCapture, true),
|
|
284
|
+
);
|
|
285
|
+
try {
|
|
286
|
+
if (serviceKind === "websearch") {
|
|
287
|
+
if (typeof adapter.websearch !== "function") {
|
|
288
|
+
throw new GatewayError(
|
|
289
|
+
"capability_unsupported",
|
|
290
|
+
400,
|
|
291
|
+
`Provider ${providerId} has no web search transport`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
const searchProbeQuery = request.prompt?.trim() || "Cartethyia gateway probe";
|
|
295
|
+
const response = await adapter.websearch(
|
|
296
|
+
{ model: modelId, query: searchProbeQuery, max_results: 3 },
|
|
297
|
+
candidate,
|
|
298
|
+
{
|
|
299
|
+
credential,
|
|
300
|
+
deadline: startedAt + 30_000,
|
|
301
|
+
abort_signal: probeSignal,
|
|
302
|
+
outbound_fetch: probeFetch,
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
|
|
306
|
+
searchResults = response.results.map((hit) => ({
|
|
307
|
+
title: hit.title,
|
|
308
|
+
url: hit.url,
|
|
309
|
+
snippet: hit.snippet,
|
|
310
|
+
}));
|
|
311
|
+
if (!response.results || response.results.length === 0) {
|
|
312
|
+
dispatchError = new GatewayError("platform_unavailable", 502, "web search returned no results");
|
|
313
|
+
}
|
|
314
|
+
} else if (serviceKind === "systemone") {
|
|
315
|
+
if (typeof adapter.systemone !== "function") {
|
|
316
|
+
throw new GatewayError(
|
|
317
|
+
"capability_unsupported",
|
|
318
|
+
400,
|
|
319
|
+
`Provider ${providerId} has no System One transport`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
const response = await adapter.systemone(
|
|
323
|
+
{
|
|
324
|
+
model: modelId,
|
|
325
|
+
state: "Customer: I was charged twice for my order this morning.",
|
|
326
|
+
questions: {
|
|
327
|
+
probe: {
|
|
328
|
+
type: "noul",
|
|
329
|
+
instructions: "Is the customer reporting a billing problem?",
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
candidate,
|
|
334
|
+
{
|
|
335
|
+
credential,
|
|
336
|
+
deadline: startedAt + 30_000,
|
|
337
|
+
abort_signal: probeSignal,
|
|
338
|
+
outbound_fetch: probeFetch,
|
|
339
|
+
},
|
|
340
|
+
);
|
|
341
|
+
if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
|
|
342
|
+
const raw = await response.text().catch(() => "");
|
|
343
|
+
if (!response.ok) {
|
|
344
|
+
dispatchError = new GatewayError(
|
|
345
|
+
response.status >= 500 ? "platform_unavailable" : "invalid_request",
|
|
346
|
+
response.status,
|
|
347
|
+
`System One upstream error: ${raw.slice(0, 240)}`,
|
|
348
|
+
);
|
|
349
|
+
} else {
|
|
350
|
+
let parsed: unknown;
|
|
351
|
+
try {
|
|
352
|
+
parsed = raw ? JSON.parse(raw) : null;
|
|
353
|
+
} catch {
|
|
354
|
+
dispatchError = new GatewayError("platform_unavailable", 502, "System One returned invalid JSON");
|
|
355
|
+
}
|
|
356
|
+
if (parsed !== null && parsed !== undefined) {
|
|
357
|
+
const answers =
|
|
358
|
+
typeof parsed === "object" ? (parsed as Record<string, unknown>).answers : undefined;
|
|
359
|
+
const hasAnswers =
|
|
360
|
+
answers !== undefined &&
|
|
361
|
+
answers !== null &&
|
|
362
|
+
typeof answers === "object" &&
|
|
363
|
+
Object.keys(answers as Record<string, unknown>).length > 0;
|
|
364
|
+
if (!hasAnswers) {
|
|
365
|
+
dispatchError = new GatewayError(
|
|
366
|
+
"platform_unavailable",
|
|
367
|
+
502,
|
|
368
|
+
"System One returned no answers",
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
} else {
|
|
374
|
+
const dispatchProbe = async (request: CanonicalRequest): Promise<void> => {
|
|
375
|
+
capturedRequest = request;
|
|
376
|
+
for await (const event of adapter.dispatch(request, candidate, {
|
|
377
|
+
credential,
|
|
378
|
+
deadline: startedAt + 30_000,
|
|
379
|
+
abort_signal: probeSignal,
|
|
380
|
+
outbound_fetch: probeFetch,
|
|
381
|
+
// A bidi-only provider (Cursor's Connect-RPC Run) has no `fetch`
|
|
382
|
+
// transport at all: without the HTTP/2 binding the probe fails with
|
|
383
|
+
// "requires validated HTTP/2 egress" while live dispatch — which
|
|
384
|
+
// always supplies it — succeeds.
|
|
385
|
+
...(outbound.http2Bidi === undefined ? {} : { outbound_http2: outbound.http2Bidi }),
|
|
386
|
+
})) {
|
|
387
|
+
if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
|
|
388
|
+
events.push(event);
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
try {
|
|
392
|
+
await dispatchProbe(canonicalRequest);
|
|
393
|
+
if (!hasMeaningfulOutput(events)) throw new Error("probe response content empty");
|
|
394
|
+
} catch (error) {
|
|
395
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
396
|
+
if (!/empty\s+(?:response\s+)?content|response\s+content\s+empty/i.test(message)) {
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
// Some reasoning models spend the short probe budget before producing
|
|
400
|
+
// visible text. Retry with a larger output allowance, and stream when
|
|
401
|
+
// the first attempt did not — a streamed probe keeps the connection
|
|
402
|
+
// open while the model reasons instead of waiting for a complete body.
|
|
403
|
+
// Generic: no provider/model id special case is needed.
|
|
404
|
+
events.length = 0;
|
|
405
|
+
ttfbMs = undefined;
|
|
406
|
+
await dispatchProbe({
|
|
407
|
+
...canonicalRequest,
|
|
408
|
+
stream: true,
|
|
409
|
+
generation_controls: {
|
|
410
|
+
...canonicalRequest.generation_controls,
|
|
411
|
+
max_tokens: 1024,
|
|
412
|
+
max_output_tokens: 1024,
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
} finally {
|
|
418
|
+
outbound.release?.();
|
|
419
|
+
}
|
|
420
|
+
} catch (error) {
|
|
421
|
+
dispatchError = error;
|
|
422
|
+
}
|
|
423
|
+
const featureFailure = grok407ProbeFailure({
|
|
424
|
+
providerId,
|
|
425
|
+
prompt: request.prompt,
|
|
426
|
+
events,
|
|
427
|
+
dispatchError,
|
|
428
|
+
});
|
|
429
|
+
if (featureFailure !== undefined) dispatchError = featureFailure;
|
|
430
|
+
|
|
431
|
+
const upstreamStatusCode =
|
|
432
|
+
dispatchError instanceof GatewayError
|
|
433
|
+
? typeof dispatchError.details.upstreamStatus === "number"
|
|
434
|
+
? dispatchError.details.upstreamStatus
|
|
435
|
+
: typeof dispatchError.details.providerStatus === "number"
|
|
436
|
+
? dispatchError.details.providerStatus
|
|
437
|
+
: undefined
|
|
438
|
+
: undefined;
|
|
439
|
+
await recordProbeHealth({
|
|
440
|
+
db: this.db,
|
|
441
|
+
invalidator: this.snapshotInvalidator,
|
|
442
|
+
accountId,
|
|
443
|
+
dispatchError,
|
|
444
|
+
// Passed so a refusal delivered as a terminal event — not as a thrown
|
|
445
|
+
// error — still reaches the health machine. Without it, Cursor's
|
|
446
|
+
// `resource_exhausted` left `dispatchError` undefined and the probe
|
|
447
|
+
// recovered an exhausted account.
|
|
448
|
+
...(events === undefined ? {} : { events }),
|
|
449
|
+
modelId,
|
|
450
|
+
upstreamStatusCode,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const latencyMs = Date.now() - startedAt;
|
|
454
|
+
// A native-service probe carries no canonical events: its verdict is the
|
|
455
|
+
// decision response itself (no dispatch error + answers present), which the
|
|
456
|
+
// branch above already encoded into `dispatchError`. Web search is a
|
|
457
|
+
// native service too — routing it through the LLM verdict demanded a
|
|
458
|
+
// `terminal` event the search transport never emits, so a search that
|
|
459
|
+
// returned hits was reported as a failure with an empty message.
|
|
460
|
+
const { ok, errorMessage, usage } =
|
|
461
|
+
serviceKind === "systemone" || serviceKind === "websearch"
|
|
462
|
+
? {
|
|
463
|
+
ok: dispatchError === undefined,
|
|
464
|
+
errorMessage:
|
|
465
|
+
dispatchError === undefined
|
|
466
|
+
? undefined
|
|
467
|
+
: dispatchError instanceof GatewayError
|
|
468
|
+
? `Search provider ${providerId}/${modelId} — ${dispatchError.message} (HTTP ${dispatchError.status})`
|
|
469
|
+
: dispatchError instanceof Error
|
|
470
|
+
? `${providerId}/${modelId} — ${dispatchError.message}`
|
|
471
|
+
: String(dispatchError),
|
|
472
|
+
usage: undefined,
|
|
473
|
+
}
|
|
474
|
+
: computeProbeVerdict({
|
|
475
|
+
events,
|
|
476
|
+
dispatchError,
|
|
477
|
+
wireFamily,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const requestId = crypto.randomUUID();
|
|
481
|
+
|
|
482
|
+
// Token speed shared with live traffic (`computeTokensPerSec`): probes
|
|
483
|
+
// record TTFT+latency only, so they report end-to-end effective speed.
|
|
484
|
+
const tokensPerSec = computeTokensPerSec({
|
|
485
|
+
outputTokens: usage?.output_tokens,
|
|
486
|
+
latencyMs,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// Probe telemetry rides the same batch buffer as live traffic. Keep the
|
|
490
|
+
// probe bodies linked by request_id so the console can inspect the exact
|
|
491
|
+
// canonical request and provider event stream that produced the result.
|
|
492
|
+
this.telemetryBuffer?.enqueue({
|
|
493
|
+
tenantId,
|
|
494
|
+
requestId,
|
|
495
|
+
sourceSurface,
|
|
496
|
+
requestedModel: modelId,
|
|
497
|
+
endpoint: endpointPath,
|
|
498
|
+
providerId,
|
|
499
|
+
...(accountId ? { accountId } : {}),
|
|
500
|
+
...(networkPoolId ? { networkPoolId } : {}),
|
|
501
|
+
latencyMs,
|
|
502
|
+
...(ttfbMs !== undefined ? { ttfbMs } : {}),
|
|
503
|
+
stream: capturedRequest.stream,
|
|
504
|
+
status: ok ? "completed" : "failed",
|
|
505
|
+
...(ok ? {} : { errorCategory: errorMessage ?? "probe_failed" }),
|
|
506
|
+
...(usage ? { usage } : {}),
|
|
507
|
+
...(tokensPerSec !== undefined ? { tokensPerSec } : {}),
|
|
508
|
+
});
|
|
509
|
+
if (this.telemetryBuffer && payloadCaptureEnabled) {
|
|
510
|
+
// All four surfaces, so the row's stage markers mean the same thing here
|
|
511
|
+
// as they do on a live request: which hop actually produced bytes.
|
|
512
|
+
const providerRequest = probeCapture?.request ?? null;
|
|
513
|
+
const providerResponse = await probeCapture?.response.catch(() => null) ?? null;
|
|
514
|
+
await this.payloadCapture
|
|
515
|
+
.capture({
|
|
516
|
+
tenantId,
|
|
517
|
+
requestId,
|
|
518
|
+
requestBody: capturedRequest,
|
|
519
|
+
// `events` is what the *client* receives, so it belongs in the client
|
|
520
|
+
// surface. Passing it as `responseBody` lit the client bit on probes
|
|
521
|
+
// whose event list was empty, over a body that was never captured.
|
|
522
|
+
// The legacy alias is explicitly absent here; `null` leaves bit 8 to
|
|
523
|
+
// be decided by the real client body alone.
|
|
524
|
+
responseBody: null,
|
|
525
|
+
...(events === undefined ? {} : { clientResponseBody: events }),
|
|
526
|
+
...(providerRequest === null ? {} : { providerRequestBody: providerRequest }),
|
|
527
|
+
...(providerResponse === null ? {} : { providerResponseBody: providerResponse }),
|
|
528
|
+
scope: "tenant",
|
|
529
|
+
tenantOptIn: true,
|
|
530
|
+
})
|
|
531
|
+
.catch(() => undefined);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const sample = extractSample(events);
|
|
535
|
+
const reportedModel = await probeCapture?.reportedModel?.catch(() => undefined);
|
|
536
|
+
return {
|
|
537
|
+
...(accountId ? { accountId } : {}),
|
|
538
|
+
ok,
|
|
539
|
+
latencyMs,
|
|
540
|
+
...(ttfbMs !== undefined ? { ttfbMs } : {}),
|
|
541
|
+
...(upstreamStatusCode === undefined ? {} : { statusCode: upstreamStatusCode }),
|
|
542
|
+
...(sample ? { sample } : {}),
|
|
543
|
+
...(reportedModel === undefined ? {} : { reportedModel }),
|
|
544
|
+
...(searchResults !== undefined && searchResults.length > 0 ? { searchResults } : {}),
|
|
545
|
+
...(errorMessage ? { error: errorMessage } : {}),
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Ad-hoc connectivity test for a not-yet-persisted BYOK provider.
|
|
551
|
+
*
|
|
552
|
+
* Dispatches a real `GET <base>/models` through the same SSRF-validated
|
|
553
|
+
* outbound fetch live traffic uses, stamping the credential with the auth
|
|
554
|
+
* header the chosen wire family actually reads (`x-api-key` for Messages,
|
|
555
|
+
* `Authorization: Bearer` otherwise) plus that wire's CLI identity headers.
|
|
556
|
+
* Reachability is the contract: any HTTP response proves the operator's base
|
|
557
|
+
* URL, credential, and egress path work — a 4xx is reported with its status
|
|
558
|
+
* rather than disguised as a transport failure.
|
|
559
|
+
*/
|
|
560
|
+
async testByokConnection(
|
|
561
|
+
tenantId: string,
|
|
562
|
+
request: ByokConnectionTestRequest,
|
|
563
|
+
): Promise<ByokConnectionTestResult> {
|
|
564
|
+
const startedAt = Date.now();
|
|
565
|
+
const requestedWire = request.wireFamily;
|
|
566
|
+
const wireFamily: WireFamily = (WIRE_FAMILIES as readonly string[]).includes(requestedWire)
|
|
567
|
+
? (requestedWire as WireFamily)
|
|
568
|
+
: "chat";
|
|
569
|
+
// An operator who pasted a full endpoint (`…/anthropic/v1/messages`) gets it
|
|
570
|
+
// stripped before the model list is derived, and told so. Probing the
|
|
571
|
+
// corrected root is what makes the result actionable: the alternative is a
|
|
572
|
+
// bare 404 against a path the gateway built from their own typo.
|
|
573
|
+
const submitted = splitEndpointBaseUrl(request.baseUrl);
|
|
574
|
+
const warning =
|
|
575
|
+
submitted.stripped === ""
|
|
576
|
+
? undefined
|
|
577
|
+
: `Base URL pointed at the endpoint "${submitted.stripped}". Cartethyia derives the model list from the root, so it tested "${submitted.baseUrl}" instead. Save that root as the Base URL.`;
|
|
578
|
+
const url = modelListUrl(request.baseUrl);
|
|
579
|
+
const outbound = asOutboundBinding(await this.outboundFetchFor(tenantId));
|
|
580
|
+
const probeFetch = createProbeFetch(outbound.fetch);
|
|
581
|
+
try {
|
|
582
|
+
const headers: Record<string, string> = {
|
|
583
|
+
accept: "application/json",
|
|
584
|
+
...authHeaders(
|
|
585
|
+
byokAuthHeaderShape([wireFamily]),
|
|
586
|
+
new TextEncoder().encode(request.apiKey),
|
|
587
|
+
request.apiKey.trim().length > 0 ? "api_key" : "none",
|
|
588
|
+
),
|
|
589
|
+
...(request.cliIdentity === false ? {} : resolveCustomCliHeaders(wireFamily)),
|
|
590
|
+
...(request.extraHeaders ?? {}),
|
|
591
|
+
};
|
|
592
|
+
const response = await probeFetch(url, {
|
|
593
|
+
method: "GET",
|
|
594
|
+
headers,
|
|
595
|
+
signal: AbortSignal.timeout(15_000),
|
|
596
|
+
});
|
|
597
|
+
const latencyMs = Date.now() - startedAt;
|
|
598
|
+
if (!response.ok) {
|
|
599
|
+
return {
|
|
600
|
+
ok: false,
|
|
601
|
+
latencyMs,
|
|
602
|
+
statusCode: response.status,
|
|
603
|
+
error: `Upstream rejected the connection test with HTTP ${response.status}`,
|
|
604
|
+
...(warning === undefined ? {} : { warning }),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
// A body is nice to have but not required: some gateways answer `/models`
|
|
608
|
+
// with an empty envelope. Read it defensively so a non-JSON body cannot
|
|
609
|
+
// turn a successful reachability check into a failure.
|
|
610
|
+
let modelCount: number | undefined;
|
|
611
|
+
try {
|
|
612
|
+
const payload = (await response.json()) as unknown;
|
|
613
|
+
const entries = Array.isArray(payload)
|
|
614
|
+
? payload
|
|
615
|
+
: isRecord(payload) && Array.isArray(payload["data"])
|
|
616
|
+
? payload["data"]
|
|
617
|
+
: isRecord(payload) && Array.isArray(payload["models"])
|
|
618
|
+
? payload["models"]
|
|
619
|
+
: undefined;
|
|
620
|
+
if (entries) modelCount = entries.length;
|
|
621
|
+
} catch {
|
|
622
|
+
// Non-JSON success body: reachability already proven by the 2xx.
|
|
623
|
+
}
|
|
624
|
+
return {
|
|
625
|
+
ok: true,
|
|
626
|
+
latencyMs,
|
|
627
|
+
statusCode: response.status,
|
|
628
|
+
...(modelCount === undefined ? {} : { modelCount }),
|
|
629
|
+
...(warning === undefined ? {} : { warning }),
|
|
630
|
+
};
|
|
631
|
+
} catch (error) {
|
|
632
|
+
return {
|
|
633
|
+
ok: false,
|
|
634
|
+
latencyMs: Date.now() - startedAt,
|
|
635
|
+
error: error instanceof Error ? error.message : "Connection test failed",
|
|
636
|
+
...(warning === undefined ? {} : { warning }),
|
|
637
|
+
};
|
|
638
|
+
} finally {
|
|
639
|
+
outbound.release?.();
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async syncModels(tenantId: string, providerId: string): Promise<{ synced: number }> {
|
|
644
|
+
const outbound = asOutboundBinding(await this.outboundFetchFor(tenantId, providerId));
|
|
645
|
+
try {
|
|
646
|
+
const discoveryFetch = asFetchLike(outbound.fetch);
|
|
647
|
+
const resolver = await this.providerRegistry.resolveModelDiscovery(providerId);
|
|
648
|
+
if (resolver) {
|
|
649
|
+
// An account is used when one is usable, and the fetch is attempted
|
|
650
|
+
// either way. Gating on a credential only made the common case
|
|
651
|
+
// unreachable: most providers list their models without auth, and the
|
|
652
|
+
// ones that do not answer 401 themselves, which names the actual
|
|
653
|
+
// problem where a local 409 named a missing account the operator may
|
|
654
|
+
// not even need.
|
|
655
|
+
const account = await this.usableAccount(tenantId, providerId);
|
|
656
|
+
const secret = account?.secret;
|
|
657
|
+
const providerRow = await this.loadProviderWireRow(providerId);
|
|
658
|
+
const effectiveBaseUrl = providerRow?.baseUrl ?? providerBaseUrl(providerId) ?? "";
|
|
659
|
+
let discovered: readonly ModelDefinition[] | null;
|
|
660
|
+
try {
|
|
661
|
+
discovered = await resolver({
|
|
662
|
+
baseUrl: effectiveBaseUrl,
|
|
663
|
+
credential: secret ?? "",
|
|
664
|
+
...(discoveryFetch ? { fetcher: discoveryFetch } : {}),
|
|
665
|
+
...(account?.authState === undefined ? {} : { authState: account.authState }),
|
|
666
|
+
});
|
|
667
|
+
} catch (error) {
|
|
668
|
+
throw new GatewayError(
|
|
669
|
+
"transport_unavailable",
|
|
670
|
+
502,
|
|
671
|
+
`Model discovery failed for ${providerId}: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
672
|
+
{},
|
|
673
|
+
"upstream",
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
if (!discovered) {
|
|
677
|
+
throw new GatewayError(
|
|
678
|
+
"transport_unavailable",
|
|
679
|
+
502,
|
|
680
|
+
`Model discovery failed for ${providerId}: endpoint returned no usable models`,
|
|
681
|
+
{},
|
|
682
|
+
"upstream",
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
const ctx = wireContextFrom(providerRow);
|
|
686
|
+
return this.persistDiscoveredModels(
|
|
687
|
+
providerId,
|
|
688
|
+
discovered,
|
|
689
|
+
(modelId) => resolveDiscoveredWire(modelId, ctx, undefined, this.defaultEndpoints),
|
|
690
|
+
ctx.endpointPathsByWireFamily,
|
|
691
|
+
effectiveBaseUrl,
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const providerRow = await this.loadProviderWireRow(providerId);
|
|
696
|
+
if (providerRow?.baseUrl) {
|
|
697
|
+
const secret = await this.usableAccountSecret(tenantId, providerId);
|
|
698
|
+
if (secret) {
|
|
699
|
+
// An Anthropic-compatible root reads `x-api-key`, not bearer auth,
|
|
700
|
+
// and a bare host needs its `/v1` segment before `/models` joins on.
|
|
701
|
+
// Both are derived from the same wire profile dispatch uses, so
|
|
702
|
+
// auto-fetch behaves identically for either flavor of custom provider.
|
|
703
|
+
const wireProfile = resolveByokWireProfile(
|
|
704
|
+
providerRow.wireFamilyDefault,
|
|
705
|
+
providerRow.compatibilityProfile as CompatibilityProfile | null,
|
|
706
|
+
);
|
|
707
|
+
const modelsBaseUrl = modelDiscoveryBaseUrl(providerRow.baseUrl);
|
|
708
|
+
let discovered: readonly ModelDefinition[] | null;
|
|
709
|
+
try {
|
|
710
|
+
discovered = await fetchOpenAICompatibleModels({
|
|
711
|
+
baseUrl: modelsBaseUrl,
|
|
712
|
+
providerId,
|
|
713
|
+
headers: {
|
|
714
|
+
...authHeaders(
|
|
715
|
+
wireProfile.authHeaderShape,
|
|
716
|
+
new TextEncoder().encode(secret),
|
|
717
|
+
"api_key",
|
|
718
|
+
),
|
|
719
|
+
},
|
|
720
|
+
...(discoveryFetch ? { fetcher: discoveryFetch as typeof fetch } : {}),
|
|
721
|
+
});
|
|
722
|
+
} catch (error) {
|
|
723
|
+
throw new GatewayError(
|
|
724
|
+
"transport_unavailable",
|
|
725
|
+
502,
|
|
726
|
+
`Model discovery failed for ${providerId}: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
727
|
+
{},
|
|
728
|
+
"upstream",
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
if (discovered && discovered.length > 0) {
|
|
732
|
+
const ctx = wireContextFrom(providerRow);
|
|
733
|
+
return this.persistDiscoveredModels(
|
|
734
|
+
providerId,
|
|
735
|
+
discovered,
|
|
736
|
+
(modelId) => resolveDiscoveredWire(modelId, ctx, undefined, this.defaultEndpoints),
|
|
737
|
+
ctx.endpointPathsByWireFamily,
|
|
738
|
+
providerRow.baseUrl ?? undefined,
|
|
739
|
+
// The generic `/models` fetch guesses a wire from the model id;
|
|
740
|
+
// the row's own profile is what the adapter will enforce, so it
|
|
741
|
+
// decides which guess is admissible.
|
|
742
|
+
wireProfile.supportedWireFamilies,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Two reasons a sync reaches here with nothing dialed, and the operator
|
|
749
|
+
// needs to be told which one. Returning `{synced: 0}` for both made an
|
|
750
|
+
// unsupported provider indistinguishable from a successful fetch that
|
|
751
|
+
// found nothing — the console showed a green "0 synced" for a provider
|
|
752
|
+
// it can never sync.
|
|
753
|
+
const bundled = isBundledProviderId(providerId);
|
|
754
|
+
if (!bundled) {
|
|
755
|
+
// A custom provider has a row and a base URL but no usable credential.
|
|
756
|
+
// The fix is an account, and saying "no listing endpoint" would send the
|
|
757
|
+
// operator looking for a capability the provider may well have.
|
|
758
|
+
throw new GatewayError(
|
|
759
|
+
"invalid_request",
|
|
760
|
+
400,
|
|
761
|
+
`Add an account for ${providerId} first — model listing needs a credential.`,
|
|
762
|
+
{},
|
|
763
|
+
"cartethyia",
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
if (!(await this.usableAccountSecret(tenantId, providerId))) {
|
|
767
|
+
// A bundled provider with an account would have gone through the
|
|
768
|
+
// resolver branch above, so reaching here means none is registered.
|
|
769
|
+
// Add one first, then fetch.
|
|
770
|
+
throw new GatewayError(
|
|
771
|
+
"invalid_request",
|
|
772
|
+
400,
|
|
773
|
+
`Add an account for ${providerId} before fetching its models.`,
|
|
774
|
+
{},
|
|
775
|
+
"cartethyia",
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
// A credential exists and the provider still has no listing path: the
|
|
779
|
+
// catalog is the compiled one, and new models arrive with an update.
|
|
780
|
+
throw new GatewayError(
|
|
781
|
+
"capability_unsupported",
|
|
782
|
+
400,
|
|
783
|
+
`${providerId} publishes no model-listing endpoint. Its catalog ships with Cartethyia; new models appear with an update.`,
|
|
784
|
+
{},
|
|
785
|
+
"cartethyia",
|
|
786
|
+
);
|
|
787
|
+
} finally {
|
|
788
|
+
outbound.release?.();
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
private async persistDiscoveredModels(
|
|
793
|
+
providerId: string,
|
|
794
|
+
discoveredModels: readonly (string | ModelDefinition)[],
|
|
795
|
+
resolve: (modelId: string) => DiscoveredModelWire,
|
|
796
|
+
endpointOverrides?: Partial<Record<WireFamily, string>>,
|
|
797
|
+
baseUrl?: string,
|
|
798
|
+
supportedWireFamilies?: readonly WireFamily[],
|
|
799
|
+
): Promise<{ synced: number }> {
|
|
800
|
+
// Static definitions are the injected bundled catalog; the probing service
|
|
801
|
+
// and the console store are both handed the same startup-built map.
|
|
802
|
+
const staticDefinitions: readonly ModelDefinition[] =
|
|
803
|
+
this.bundledModelCatalog.get(providerId) ?? [];
|
|
804
|
+
const staticEndpoints: Partial<Record<WireFamily, string>> = {};
|
|
805
|
+
for (const definition of staticDefinitions) {
|
|
806
|
+
staticEndpoints[definition.wireFamily] ??= definition.endpointPath;
|
|
807
|
+
}
|
|
808
|
+
// Fetch only adds genuinely new models: an id the builtin catalog
|
|
809
|
+
// already owns stays exactly one (builtin) row instead of gaining a
|
|
810
|
+
// discovered shadow that confuses pickers and deletes.
|
|
811
|
+
const builtinIds = new Set(staticDefinitions.map((definition) => definition.modelId));
|
|
812
|
+
// `(modelId, endpointPath)` is the row identity, so a corrected wire family
|
|
813
|
+
// lands on a *new* row and would leave the old one behind as a dead route.
|
|
814
|
+
// Collect this run's pairs and drop the provider's superseded discovered
|
|
815
|
+
// rows for the same ids afterwards.
|
|
816
|
+
const resolvedPairs: Array<{ modelId: string; endpointPath: string }> = [];
|
|
817
|
+
const rows: Array<typeof models.$inferInsert> = [];
|
|
818
|
+
// `(modelId, endpointPath)` is the row identity and the upsert's conflict
|
|
819
|
+
// target, so a listing that repeats a pair (the bespoke discoveries return
|
|
820
|
+
// raw upstream ids) would make a multi-row `ON CONFLICT DO UPDATE` fail with
|
|
821
|
+
// "cannot affect row a second time". Keep the first row per pair, as the
|
|
822
|
+
// builtin seeder does, so one statement can carry a whole chunk.
|
|
823
|
+
const seenPairs = new Set<string>();
|
|
824
|
+
for (const item of discoveredModels) {
|
|
825
|
+
const modelId = typeof item === "string" ? item : item.modelId;
|
|
826
|
+
if (builtinIds.has(modelId)) continue;
|
|
827
|
+
const resolved = resolve(modelId);
|
|
828
|
+
const discDef = typeof item === "string" ? undefined : item;
|
|
829
|
+
const { wireFamily, endpointPath } = applyDiscoveredWire({
|
|
830
|
+
resolvedWireFamily: resolved.wireFamily,
|
|
831
|
+
resolvedEndpointPath: resolved.endpointPath,
|
|
832
|
+
...(discDef !== undefined
|
|
833
|
+
? { discoveredWireFamily: discDef.wireFamily, discoveredEndpointPath: discDef.endpointPath }
|
|
834
|
+
: {}),
|
|
835
|
+
staticEndpoints,
|
|
836
|
+
...(endpointOverrides !== undefined ? { endpointOverrides } : {}),
|
|
837
|
+
...(supportedWireFamilies !== undefined ? { supportedWireFamilies } : {}),
|
|
838
|
+
...(baseUrl !== undefined ? { baseUrl } : {}),
|
|
839
|
+
});
|
|
840
|
+
resolvedPairs.push({ modelId, endpointPath });
|
|
841
|
+
const pairKey = `${modelId}\u0000${endpointPath}`;
|
|
842
|
+
if (seenPairs.has(pairKey)) continue;
|
|
843
|
+
seenPairs.add(pairKey);
|
|
844
|
+
const knownDefinition = staticDefinitions.find((definition) => definition.modelId === modelId);
|
|
845
|
+
const sourceUpdatedAt = new Date();
|
|
846
|
+
const metadata = {
|
|
847
|
+
contextLimit: knownDefinition?.contextLimit ?? discDef?.contextLimit ?? null,
|
|
848
|
+
outputLimit: knownDefinition?.outputLimit ?? discDef?.outputLimit ?? null,
|
|
849
|
+
modalities: knownDefinition?.modalities ?? discDef?.modalities ?? null,
|
|
850
|
+
reasoning: knownDefinition?.reasoning ?? discDef?.reasoning ?? false,
|
|
851
|
+
toolCall: knownDefinition?.toolCall ?? discDef?.toolCall ?? false,
|
|
852
|
+
cost: modelsDevCatalog.costFor(providerId, modelId),
|
|
853
|
+
// Discovery builds its rows from the same static catalog, so a fetched
|
|
854
|
+
// row carries the same answer a seeded one does. Null when neither
|
|
855
|
+
// source marked it, which reads as "unknown" rather than "free".
|
|
856
|
+
proOnly: (discDef?.proOnly ?? knownDefinition?.proOnly ?? null),
|
|
857
|
+
// Same shape for the tier the provider states itself (Qoder `is_free`).
|
|
858
|
+
freeTier: (discDef?.freeTier ?? knownDefinition?.freeTier ?? null),
|
|
859
|
+
};
|
|
860
|
+
rows.push({
|
|
861
|
+
providerId,
|
|
862
|
+
modelId,
|
|
863
|
+
wireFamily,
|
|
864
|
+
// A discovery module that classifies an id as a non-chat service (the
|
|
865
|
+
// System One decision API) states it on its own definition; otherwise the
|
|
866
|
+
// static catalog's classification wins, and an unclassified id is `llm`.
|
|
867
|
+
serviceKind: discDef?.serviceKind ?? knownDefinition?.serviceKind ?? "llm",
|
|
868
|
+
endpointPath,
|
|
869
|
+
...metadata,
|
|
870
|
+
// A discovery module that marks its rows as a free tier gets its own
|
|
871
|
+
// source, because the model list groups on it: a free-tier row is not
|
|
872
|
+
// an ordinary fetched one, and collapsing both into `discovered` lost
|
|
873
|
+
// that distinction at the only point where it is still known.
|
|
874
|
+
source: discDef?.freeTier === true ? "auto_free" : "discovered",
|
|
875
|
+
sourceUpdatedAt,
|
|
876
|
+
enabled: true,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
// One statement per chunk (500, the account-health batch step) instead of
|
|
880
|
+
// one round-trip per row. `excluded.*` reads the proposed row, so the update
|
|
881
|
+
// set is the same columns the per-row upsert wrote.
|
|
882
|
+
for (let offset = 0; offset < rows.length; offset += 500) {
|
|
883
|
+
await this.db
|
|
884
|
+
.insert(models)
|
|
885
|
+
.values(rows.slice(offset, offset + 500))
|
|
886
|
+
.onConflictDoUpdate({
|
|
887
|
+
target: [models.providerId, models.modelId, models.endpointPath],
|
|
888
|
+
set: {
|
|
889
|
+
serviceKind: sql`excluded.service_kind`,
|
|
890
|
+
contextLimit: sql`excluded.context_limit`,
|
|
891
|
+
outputLimit: sql`excluded.output_limit`,
|
|
892
|
+
modalities: sql`excluded.modalities`,
|
|
893
|
+
reasoning: sql`excluded.reasoning`,
|
|
894
|
+
toolCall: sql`excluded.tool_call`,
|
|
895
|
+
cost: sql`excluded.cost`,
|
|
896
|
+
proOnly: sql`excluded.pro_only`,
|
|
897
|
+
freeTier: sql`excluded.free_tier`,
|
|
898
|
+
// The proposed row's own source, not a constant: a free-tier
|
|
899
|
+
// discovery and an ordinary one write the same upsert, and pinning
|
|
900
|
+
// this to `discovered` would relabel every free-tier row on the
|
|
901
|
+
// next sync.
|
|
902
|
+
source: sql`excluded.source`,
|
|
903
|
+
sourceUpdatedAt: sql`excluded.source_updated_at`,
|
|
904
|
+
},
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
const synced = rows.length;
|
|
908
|
+
// Drop this provider's discovered rows for the synced ids that are no
|
|
909
|
+
// longer part of the resolved set — the stale `(model, endpoint)` pair left
|
|
910
|
+
// behind when a corrected wire family moved a model to a new path. Rows the
|
|
911
|
+
// operator added by hand (`manual`) and ids outside this run are untouched.
|
|
912
|
+
// `auto_free` is included: it is written by this same sync, so leaving it
|
|
913
|
+
// out would make a free-tier row the one kind of synced row that never gets
|
|
914
|
+
// pruned when its id moves.
|
|
915
|
+
if (resolvedPairs.length > 0) {
|
|
916
|
+
const pairs = resolvedPairs.map(
|
|
917
|
+
(pair) => sql`(${pair.modelId}, ${pair.endpointPath})`,
|
|
918
|
+
);
|
|
919
|
+
const ids = resolvedPairs.map((pair) => pair.modelId);
|
|
920
|
+
await this.db
|
|
921
|
+
.delete(models)
|
|
922
|
+
.where(
|
|
923
|
+
and(
|
|
924
|
+
eq(models.providerId, providerId),
|
|
925
|
+
inArray(models.source, ["discovered", "auto_free"]),
|
|
926
|
+
sql`${models.modelId} IN (${sql.join(
|
|
927
|
+
ids.map((id) => sql`${id}`),
|
|
928
|
+
sql`, `,
|
|
929
|
+
)})`,
|
|
930
|
+
sql`(${models.modelId}, ${models.endpointPath}) NOT IN (${sql.join(pairs, sql`, `)})`,
|
|
931
|
+
),
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
return { synced };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
private async usableAccountSecret(
|
|
938
|
+
tenantId: string,
|
|
939
|
+
providerId: string,
|
|
940
|
+
): Promise<string | undefined> {
|
|
941
|
+
return (await this.usableAccount(tenantId, providerId))?.secret;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* The account a credential-scoped discovery will use, with its non-secret
|
|
946
|
+
* auth configuration. Selection is unchanged — first usable row, preferring a
|
|
947
|
+
* tenant-owned one — so discovery and dispatch agree on which account answered.
|
|
948
|
+
*/
|
|
949
|
+
private async usableAccount(
|
|
950
|
+
tenantId: string,
|
|
951
|
+
providerId: string,
|
|
952
|
+
): Promise<{ readonly secret: string; readonly authState?: Readonly<Record<string, unknown>> } | undefined> {
|
|
953
|
+
const rows = await this.db
|
|
954
|
+
.select()
|
|
955
|
+
.from(providerAccounts)
|
|
956
|
+
.where(
|
|
957
|
+
and(
|
|
958
|
+
eq(providerAccounts.providerId, providerId),
|
|
959
|
+
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
960
|
+
),
|
|
961
|
+
);
|
|
962
|
+
const now = Date.now();
|
|
963
|
+
const usable = rows
|
|
964
|
+
.filter(
|
|
965
|
+
(row) =>
|
|
966
|
+
row.status !== "disabled" &&
|
|
967
|
+
(row.cooldownUntil === null || row.cooldownUntil.getTime() <= now),
|
|
968
|
+
)
|
|
969
|
+
.sort((left, right) => Number(right.tenantId !== null) - Number(left.tenantId !== null))[0];
|
|
970
|
+
if (!usable?.credentialCiphertext) return undefined;
|
|
971
|
+
const authState = isRecord(usable.authState) ? usable.authState as Readonly<Record<string, unknown>> : undefined;
|
|
972
|
+
return {
|
|
973
|
+
secret: decryptCredentialToString(usable.credentialCiphertext),
|
|
974
|
+
...(authState === undefined ? {} : { authState }),
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Batched provider-wide probe: the first request runs sequentially (it warms
|
|
980
|
+
* OAuth token caches and connection pools for the provider), the rest run
|
|
981
|
+
* through a sliding worker pool bounded to `PROBE_CONCURRENCY`. Each request
|
|
982
|
+
* carries its model route and service kind so native services never fall
|
|
983
|
+
* through to an LLM probe.
|
|
984
|
+
*
|
|
985
|
+
* Sliding window beats barrel-batching here: the historical pattern waited
|
|
986
|
+
* for the slowest probe in each group of `N` before starting the next group,
|
|
987
|
+
* so one slow route stalled nine others. With the worker loop, a worker
|
|
988
|
+
* picks up the next request as soon as its current one settles, which keeps
|
|
989
|
+
* the full window of `PROBE_CONCURRENCY` in flight across the whole batch.
|
|
990
|
+
*/
|
|
991
|
+
async probeAllModels(
|
|
992
|
+
tenantId: string,
|
|
993
|
+
providerId: string,
|
|
994
|
+
requests: readonly ProbeModelRequest[],
|
|
995
|
+
): Promise<ProbeAllModelsResult> {
|
|
996
|
+
const [firstRequest, ...restRequests] = requests;
|
|
997
|
+
if (firstRequest === undefined) return { providerId, results: [] };
|
|
998
|
+
|
|
999
|
+
// Position the warm-up at index 0 and the rest at their original indices
|
|
1000
|
+
// so a caller inspecting `results` sees them in the same order they were
|
|
1001
|
+
// requested. Pre-sized `Array` means workers assign by index rather than
|
|
1002
|
+
// racing to `push`.
|
|
1003
|
+
const results: ProbeAllModelsResult["results"][number][] = new Array(requests.length);
|
|
1004
|
+
|
|
1005
|
+
const runOne = async (probeRequest: ProbeModelRequest, slot: number): Promise<void> => {
|
|
1006
|
+
const outcome = await this.probeModel(tenantId, providerId, probeRequest);
|
|
1007
|
+
results[slot] = {
|
|
1008
|
+
modelId: probeRequest.modelId,
|
|
1009
|
+
ok: outcome.ok,
|
|
1010
|
+
latencyMs: outcome.latencyMs,
|
|
1011
|
+
...(outcome.error ? { error: outcome.error } : {}),
|
|
1012
|
+
};
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
// Warm-up stays sequential: it primes OAuth token caches and connection
|
|
1016
|
+
// pools so the parallel workers do not each pay the first-call tax.
|
|
1017
|
+
await runOne(firstRequest, 0);
|
|
1018
|
+
|
|
1019
|
+
let cursor = 0;
|
|
1020
|
+
const worker = async (): Promise<void> => {
|
|
1021
|
+
while (true) {
|
|
1022
|
+
const index = cursor++;
|
|
1023
|
+
if (index >= restRequests.length) return;
|
|
1024
|
+
const request = restRequests[index]!;
|
|
1025
|
+
// Swallow per-probe errors to the array shape: `probeModel` is
|
|
1026
|
+
// supposed to return an `outcome` object even on failure, but a
|
|
1027
|
+
// throw here (unexpected shape, aborted socket) must not fail the
|
|
1028
|
+
// whole batch — the operator still needs to see which models passed.
|
|
1029
|
+
try {
|
|
1030
|
+
await runOne(request, index + 1);
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
results[index + 1] = {
|
|
1033
|
+
modelId: request.modelId,
|
|
1034
|
+
ok: false,
|
|
1035
|
+
latencyMs: 0,
|
|
1036
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
const workerCount = Math.min(PROBE_CONCURRENCY, restRequests.length);
|
|
1043
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
1044
|
+
|
|
1045
|
+
// Drop any gaps left by a `requests.length` of 1 (no rest) or an array
|
|
1046
|
+
// that was somehow sparser than planned — defensive, not expected.
|
|
1047
|
+
return { providerId, results: results.filter((entry) => entry !== undefined) };
|
|
1048
|
+
}
|
|
1049
|
+
async probeAllAccounts(
|
|
1050
|
+
tenantId: string,
|
|
1051
|
+
providerId: string,
|
|
1052
|
+
request: ProbeModelRequest,
|
|
1053
|
+
): Promise<ProbeAllAccountsResult> {
|
|
1054
|
+
const accounts = await this.db
|
|
1055
|
+
.select({ id: providerAccounts.id })
|
|
1056
|
+
.from(providerAccounts)
|
|
1057
|
+
.where(
|
|
1058
|
+
and(
|
|
1059
|
+
eq(providerAccounts.providerId, providerId),
|
|
1060
|
+
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
1061
|
+
),
|
|
1062
|
+
);
|
|
1063
|
+
// Sliding worker pool: workers claim the next account as soon as their
|
|
1064
|
+
// current probe settles, so one slow account cannot stall the rest of the
|
|
1065
|
+
// window the way `Promise.all` on fixed-size batches did. Order of
|
|
1066
|
+
// completion is irrelevant to the caller (each result carries its own
|
|
1067
|
+
// `accountId`), but we still assign by slot to keep the output stable
|
|
1068
|
+
// for golden-file tests and operator audit replay.
|
|
1069
|
+
//
|
|
1070
|
+
// Fault tolerance: the old `Promise.all` meant a single thrown probe
|
|
1071
|
+
// aborted the entire batch and the operator lost the status of every
|
|
1072
|
+
// other account they clicked "test all" on. Catching per-account turns a
|
|
1073
|
+
// thrown probe into a failed result for that account only — matching the
|
|
1074
|
+
// dashboard's existing per-account expectation.
|
|
1075
|
+
const results: ProbeAllAccountsResult["results"][number][] = new Array(accounts.length);
|
|
1076
|
+
if (accounts.length === 0) return { providerId, modelId: request.modelId, results: [] };
|
|
1077
|
+
|
|
1078
|
+
const runOne = async (account: { id: string }, slot: number): Promise<void> => {
|
|
1079
|
+
try {
|
|
1080
|
+
const result = await this.probeModel(tenantId, providerId, {
|
|
1081
|
+
...request,
|
|
1082
|
+
accountId: account.id,
|
|
1083
|
+
});
|
|
1084
|
+
// No health write here: a probe that genuinely fails already reports
|
|
1085
|
+
// through `recordAccountFailure`, which classifies the real status;
|
|
1086
|
+
// matching answer text is not a health signal.
|
|
1087
|
+
results[slot] = { ...result, accountId: account.id };
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
results[slot] = {
|
|
1090
|
+
ok: false,
|
|
1091
|
+
latencyMs: 0,
|
|
1092
|
+
accountId: account.id,
|
|
1093
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1094
|
+
} as ProbeAllAccountsResult["results"][number];
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
// As in probeAllModels, the first probe primes OAuth token caches and
|
|
1099
|
+
// connection pools before concurrent workers start. Ten simultaneous cold
|
|
1100
|
+
// refreshes could otherwise trip provider rate limits and mark healthy
|
|
1101
|
+
// accounts as broken during a "test all" run.
|
|
1102
|
+
await runOne(accounts[0]!, 0);
|
|
1103
|
+
const restAccounts = accounts.slice(1);
|
|
1104
|
+
let cursor = 0;
|
|
1105
|
+
const worker = async (): Promise<void> => {
|
|
1106
|
+
while (true) {
|
|
1107
|
+
const index = cursor++;
|
|
1108
|
+
if (index >= restAccounts.length) return;
|
|
1109
|
+
await runOne(restAccounts[index]!, index + 1);
|
|
1110
|
+
}
|
|
1111
|
+
};
|
|
1112
|
+
const workerCount = Math.min(PROBE_ACCOUNT_CONCURRENCY, restAccounts.length);
|
|
1113
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
1114
|
+
|
|
1115
|
+
return { providerId, modelId: request.modelId, results };
|
|
1116
|
+
}
|
|
1117
|
+
}
|