cartethyia 2.5.1 → 2.5.3
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 +5 -1
- package/dist/dashboard/assets/{AccountCooldown-ChpdzxRH.js → AccountCooldown-Bjo92Qat.js} +1 -1
- package/dist/dashboard/assets/AccountCooldown-Bjo92Qat.js.gz +0 -0
- package/dist/dashboard/assets/{BannedPage-ChqnTRpZ.js → BannedPage-hfCzi-Ux.js} +1 -1
- package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js +1 -0
- package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-BXUaRCQQ.js → CliToolsPage-DEDyKBlC.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js +1 -0
- package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js +1 -0
- package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js +1 -0
- package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DDVIfZGW.js +1 -0
- package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js +1 -0
- package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-B53YYMng.js +1 -0
- package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js +7 -0
- package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BpOQCnun.js +1 -0
- package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +1 -0
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-D6pmQ86L.js +1 -0
- package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DWvKiuSl.js +1 -0
- package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-DqK5E26W.js → StudioPage-CmkUrgdj.js} +3 -3
- package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-B47uOydr.js +56 -0
- package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-BmDbsior.js → api-keys-uNywYlfv.js} +1 -1
- package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
- package/dist/dashboard/assets/{app-C-Y4oZT0.js → app-CafWtVXA.js} +1 -1
- package/dist/dashboard/assets/{app-C-Y4oZT0.js.gz → app-CafWtVXA.js.gz} +0 -0
- package/dist/dashboard/assets/app-DxivpfYG.css +2 -0
- package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
- package/dist/dashboard/assets/{assert-BHfaei-X.js → assert-NHwYq95t.js} +1 -1
- package/dist/dashboard/assets/{assert-BHfaei-X.js.gz → assert-NHwYq95t.js.gz} +0 -0
- package/dist/dashboard/assets/badge-6uzDDvqs.js +1 -0
- package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
- package/dist/dashboard/assets/banner-4_WNg2WZ.js +1 -0
- package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
- package/dist/dashboard/assets/card--AtFedAq.js +1 -0
- package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-B0wBq-RL.js → cli-tools-B3e7dx3N.js} +1 -1
- package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-NreUx6c8.js +1 -0
- package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BtpsJU98.js +1 -0
- package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
- package/dist/dashboard/assets/drawer-WwwtzihF.js +1 -0
- package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
- package/dist/dashboard/assets/entry-CkryrIea.js +4 -0
- package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
- package/dist/dashboard/assets/entry-TJIye_jT.js +1 -0
- package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
- package/dist/dashboard/assets/{entry-D4O2229X.js → entry-jy9_4-Ke.js} +1 -1
- package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-D_eM3IgG.js +1 -0
- package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
- package/dist/dashboard/assets/{index-CcD1eSJ6.js → index-BydyKaDz.js} +2 -2
- package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
- package/dist/dashboard/assets/inline-BcNGF8Gr.js +1 -0
- package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
- package/dist/dashboard/assets/input-BG41et9G.js +1 -0
- package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
- package/dist/dashboard/assets/layout-C8mer_Ak.js +1 -0
- package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
- package/dist/dashboard/assets/list-row-Gh_W4OR_.js +1 -0
- package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
- package/dist/dashboard/assets/{live-yfEJFF9T.js → live-BJGYRBnR.js} +1 -1
- package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
- package/dist/dashboard/assets/{model-bans-CT-TxSNX.js → model-bans-DeVLDA7I.js} +1 -1
- package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
- package/dist/dashboard/assets/numbers-Bs4LOOuf.js +2 -0
- package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
- package/dist/dashboard/assets/{routing-Cs6jl49L.js → routing-CcusqXA7.js} +1 -1
- package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
- package/dist/dashboard/assets/select-BO6h8yNv.js +41 -0
- package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
- package/dist/dashboard/assets/{settings-CfcTcOyU.js → settings-mpmXTgUa.js} +1 -1
- package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
- package/dist/dashboard/assets/stack-DwLmjUFK.js +1 -0
- package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
- package/dist/dashboard/assets/state-DYdQmdAl.js +1 -0
- package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
- package/dist/dashboard/assets/switch-CidSfbG7.js +1 -0
- package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-BQKMcJ1H.js → useMutation-w4u4CVue.js} +1 -1
- package/dist/dashboard/assets/useMutation-w4u4CVue.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 +51 -54
- package/package.json +1 -1
- package/scripts/cli/command/console-menu.ts +248 -246
- package/scripts/cli/command/tray.ts +208 -204
- package/src/boot/app.ts +3 -2
- package/src/config/spec.ts +26 -2
- package/src/console/backup/routes.ts +52 -1
- package/src/console/backup/service.ts +60 -4
- package/src/console/observability/contracts.ts +11 -1
- package/src/console/observability/store.ts +9 -6
- package/src/console/share/share-router.ts +6 -9
- package/src/observability/payload-capture.ts +366 -344
- package/src/observability/payload-store.ts +12 -2
- package/src/observability/telemetry-status.ts +28 -16
- package/src/persistence/stores/api-key-store.ts +6 -0
- package/src/persistence/stores/share-store.ts +583 -556
- package/src/persistence/stores/telemetry-store.ts +1 -1
- package/src/protocol/response/gemini.ts +18 -4
- package/src/providers/discovery/openrouter-free-roster.ts +3 -11
- package/src/providers/discovery/probe-phases.ts +42 -5
- package/src/providers/discovery/probing-service.ts +25 -2
- package/src/providers/integrations/antigravity/antigravity-quota.ts +11 -3
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +4 -4
- package/src/providers/integrations/buddy/workbuddy/shared.ts +201 -200
- package/src/providers/integrations/claude/claude.ts +3 -1
- package/src/providers/integrations/cline/cline-quota.ts +8 -11
- package/src/providers/integrations/cloudflareai/cloudflareai-models.ts +3 -1
- package/src/providers/integrations/inferhub.ts +3 -1
- package/src/providers/integrations/kilo/kilo.ts +0 -1
- package/src/providers/integrations/kiro/kiro-catalog.ts +8 -2
- package/src/providers/integrations/opencode/provider.ts +532 -532
- package/src/providers/integrations/openrouter/provider.ts +5 -15
- package/src/providers/integrations/tokenharbor.ts +2 -2
- package/src/providers/integrations/trae/trae-catalog.ts +5 -3
- package/src/providers/operations/account-health-service.ts +23 -0
- package/src/providers/operations/client-versions.ts +9 -2
- package/src/providers/provider-metadata.ts +7 -1
- package/src/security/api-key-auth.ts +23 -0
- package/src/transport/dispatch/attempt-finalize.ts +46 -5
- package/src/transport/dispatch/proxy-request.ts +9 -0
- package/src/transport/dispatch/streaming/stream.ts +16 -2
- package/src/transport/guards/auth-guard.ts +2 -3
- package/src/transport/routing/route-catalog.ts +806 -799
- package/dist/dashboard/assets/AccountCooldown-ChpdzxRH.js.gz +0 -0
- package/dist/dashboard/assets/BannedPage-ChqnTRpZ.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-Bb9CNuoh.js +0 -1
- package/dist/dashboard/assets/CliToolDetailPage-Bb9CNuoh.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-BXUaRCQQ.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-CisNZOUt.js +0 -1
- package/dist/dashboard/assets/CombosPage-CisNZOUt.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-BOpI_LOH.js +0 -1
- package/dist/dashboard/assets/ConsoleLogPage-BOpI_LOH.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-DAkdvJ4k.js +0 -1
- package/dist/dashboard/assets/CustomizationPage-DAkdvJ4k.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-DeT-VXHd.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-DeT-VXHd.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-CytxSeGP.js +0 -1
- package/dist/dashboard/assets/LoginPage-CytxSeGP.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js +0 -1
- package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-CU7S1wJs.js +0 -1
- package/dist/dashboard/assets/OverviewPage-CU7S1wJs.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-DLm2baot.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-DLm2baot.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-DOXfuzfL.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-DOXfuzfL.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-wvk0ciQN.js +0 -7
- package/dist/dashboard/assets/ProxyPage-wvk0ciQN.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-Cn_fcUHE.js +0 -1
- package/dist/dashboard/assets/QuotaPage-Cn_fcUHE.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-8-eVadhc.js +0 -1
- package/dist/dashboard/assets/SettingsPage-8-eVadhc.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-uEIXPyGO.js +0 -1
- package/dist/dashboard/assets/SetupPage-uEIXPyGO.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DCY0TPTZ.js +0 -1
- package/dist/dashboard/assets/SortableList-DCY0TPTZ.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-DqK5E26W.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-38JfiZ0H.js +0 -56
- package/dist/dashboard/assets/UsagePage-38JfiZ0H.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
- package/dist/dashboard/assets/app-BbpSarVV.css +0 -2
- package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
- package/dist/dashboard/assets/badge-DpXT4Ixt.js +0 -1
- package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
- package/dist/dashboard/assets/banner-CSAAWLJv.js +0 -1
- package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
- package/dist/dashboard/assets/card-DEhRXXw4.js +0 -1
- package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
- package/dist/dashboard/assets/check-D69FzdvC.js +0 -1
- package/dist/dashboard/assets/check-D69FzdvC.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js +0 -1
- package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
- package/dist/dashboard/assets/copy-ngmQ9zcZ.js +0 -1
- package/dist/dashboard/assets/copy-ngmQ9zcZ.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BvQeBMfP.js +0 -1
- package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CMwPXwQB.js +0 -1
- package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
- package/dist/dashboard/assets/entry--yj84zk3.js +0 -4
- package/dist/dashboard/assets/entry--yj84zk3.js.gz +0 -0
- package/dist/dashboard/assets/entry-D4O2229X.js.gz +0 -0
- package/dist/dashboard/assets/entry-DKvr5Q9K.js +0 -1
- package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-zliLvBwr.js +0 -1
- package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
- package/dist/dashboard/assets/index-CcD1eSJ6.js.gz +0 -0
- package/dist/dashboard/assets/inline-D1IlZZ8H.js +0 -1
- package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
- package/dist/dashboard/assets/input-BpVZdrbc.js +0 -1
- package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
- package/dist/dashboard/assets/layout-BioLW7gN.js +0 -1
- package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
- package/dist/dashboard/assets/list-row-CwVcGAIc.js +0 -1
- package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
- package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
- package/dist/dashboard/assets/numbers-CFL4_Dqc.js +0 -1
- package/dist/dashboard/assets/numbers-CFL4_Dqc.js.gz +0 -0
- package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
- package/dist/dashboard/assets/select-XPJ-dIw3.js +0 -41
- package/dist/dashboard/assets/select-XPJ-dIw3.js.gz +0 -0
- package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
- package/dist/dashboard/assets/stack-C9_uUjaA.js +0 -1
- package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
- package/dist/dashboard/assets/state-DA0B95UQ.js +0 -1
- package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
- package/dist/dashboard/assets/switch-Cittxy-x.js +0 -1
- package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
- package/dist/dashboard/assets/toast-CNt_jzO4.js +0 -2
- package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
|
@@ -1,799 +1,806 @@
|
|
|
1
|
-
// Routing snapshot: builds the RouteSnapshot from the persisted model catalog.
|
|
2
|
-
|
|
3
|
-
import { asc, eq, ne } from "drizzle-orm";
|
|
4
|
-
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
5
|
-
import {
|
|
6
|
-
models,
|
|
7
|
-
modelAliases,
|
|
8
|
-
modelCombos,
|
|
9
|
-
networkPools,
|
|
10
|
-
poolRoutingSettings,
|
|
11
|
-
providerAccounts,
|
|
12
|
-
providerRoutingSettings,
|
|
13
|
-
providers,
|
|
14
|
-
tenantDisabledModels,
|
|
15
|
-
cliToolMappings,
|
|
16
|
-
cliToolSettings,
|
|
17
|
-
} from "../../persistence/schema";
|
|
18
|
-
import { DEFAULT_PROVIDER_MAX_INFLIGHT } from "../../persistence/schema";
|
|
19
|
-
import { resolveTenantOverride } from "../../persistence/tenant-scope";
|
|
20
|
-
import {
|
|
21
|
-
type RouteCandidate,
|
|
22
|
-
type ComboDefinition,
|
|
23
|
-
type PoolRoutingSetting,
|
|
24
|
-
type ProviderRoutingMap,
|
|
25
|
-
type RouteSnapshot,
|
|
26
|
-
type SnapshotBuilder,
|
|
27
|
-
} from "./route-model";
|
|
28
|
-
import { DEFAULT_PROXY_SKIP_PROVIDER_IDS } from "../../providers/provider-metadata";
|
|
29
|
-
import { buildClaudeCliUserAgent } from "../../providers/operations/custom-cli-headers";
|
|
30
|
-
import { isBundledProviderId } from "../../providers/provider-contracts";
|
|
31
|
-
import type { ServiceKind, WireFamily } from "../../protocol/canonical-model";
|
|
32
|
-
import { providerHasAdapterUserAgent, providerUsesBespokeWire } from "../../providers/provider-metadata";
|
|
33
|
-
|
|
34
|
-
const CLAUDE_MODEL_FAMILIES = new Set(["opus", "sonnet", "haiku", "fable", "mythos"]);
|
|
35
|
-
|
|
36
|
-
export function cliMappingSourceKeys(toolId: string, sourceModel: string): readonly string[] {
|
|
37
|
-
if (toolId !== "claude") return [sourceModel];
|
|
38
|
-
const normalized = sourceModel.trim().toLowerCase();
|
|
39
|
-
const family =
|
|
40
|
-
CLAUDE_MODEL_FAMILIES.has(normalized)
|
|
41
|
-
? normalized
|
|
42
|
-
: /^claude-(opus|sonnet|haiku|fable|mythos)(?:-|$)/.exec(normalized)?.[1];
|
|
43
|
-
if (normalized.includes("/") || family === undefined) return [sourceModel];
|
|
44
|
-
return [...new Set([
|
|
45
|
-
sourceModel,
|
|
46
|
-
family,
|
|
47
|
-
`claude-${family}-5`,
|
|
48
|
-
`claude-${family}-5-1`,
|
|
49
|
-
`claude-${family}-5-5`,
|
|
50
|
-
`claude-${family}-4-6`,
|
|
51
|
-
`claude-${family}-4-5`,
|
|
52
|
-
])];
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Builds the routing `RouteSnapshot` from the persisted catalog
|
|
56
|
-
* (`providers`, `models`, `provider_accounts`). Consumed by
|
|
57
|
-
* `InMemoryRouteSnapshotService` as its `SnapshotBuilder`.
|
|
58
|
-
*
|
|
59
|
-
* Built-in definitions are materialized before snapshots are read. The
|
|
60
|
-
* `models` DB table is authoritative for routing; compiled definitions are
|
|
61
|
-
* only startup seed data. Custom/BYOK and live-discovered models are stored
|
|
62
|
-
* in the same table.
|
|
63
|
-
*
|
|
64
|
-
* Tenant-aware: a provider with `providers.tenant_id = NULL` is global
|
|
65
|
-
* (every built-in, plus any pool-wide custom provider) and its candidates
|
|
66
|
-
* carry `tenant_id: null`, matching every request regardless of tenant. A
|
|
67
|
-
* provider with `providers.tenant_id` set (BYOK) only pairs with accounts
|
|
68
|
-
* owned by that exact same tenant, and its candidates carry that tenant id
|
|
69
|
-
* — `RoutingEngine.plan()` filters candidates by the requesting tenant.
|
|
70
|
-
*/
|
|
71
|
-
|
|
72
|
-
type RouteCandidateWithHealth = RouteCandidate & {
|
|
73
|
-
health_status?: "cooldown" | "model_cooldown" | "disabled";
|
|
74
|
-
cooldown_kind?: "hard" | "soft";
|
|
75
|
-
/**
|
|
76
|
-
* Epoch milliseconds at which this candidate's cooling window ends, when one
|
|
77
|
-
* is in force. Routing-internal: it is what lets `plan()` report a real reset
|
|
78
|
-
* on the all-accounts-cooling answer, and it never reaches a client verbatim —
|
|
79
|
-
* only the derived `retry-after` does.
|
|
80
|
-
*/
|
|
81
|
-
cooldown_until?: number;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const HARD_COOLDOWN_CATEGORIES: Readonly<Record<string, true>> = {
|
|
85
|
-
quota_exhausted: true,
|
|
86
|
-
policy_blocked: true,
|
|
87
|
-
auth_invalidated: true,
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
function hardCooldownCategory(category: string | null): boolean {
|
|
91
|
-
return category !== null && HARD_COOLDOWN_CATEGORIES[category] === true;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Projects one model row's metadata into the capability gates the router
|
|
96
|
-
* filters candidates by. Exported so the projection contract is unit-testable
|
|
97
|
-
* without a database.
|
|
98
|
-
*/
|
|
99
|
-
export function buildCapabilityProfile(row: {
|
|
100
|
-
modalities: unknown;
|
|
101
|
-
reasoning: boolean;
|
|
102
|
-
toolCall: boolean;
|
|
103
|
-
providerId: string;
|
|
104
|
-
}): Record<string, boolean> {
|
|
105
|
-
const mods =
|
|
106
|
-
row.modalities != null && typeof row.modalities === "object"
|
|
107
|
-
? (row.modalities as { input?: unknown[]; output?: unknown[] })
|
|
108
|
-
: {};
|
|
109
|
-
const inputMods = Array.isArray(mods.input) ? mods.input : [];
|
|
110
|
-
const outputMods = Array.isArray(mods.output) ? mods.output : [];
|
|
111
|
-
// The canonical chat and responses codecs encode `image`, `document`/`file`,
|
|
112
|
-
// and `audio` parts; the messages codec encodes the first two but has no
|
|
113
|
-
// audio block (see `AUDIO_CAPABLE_WIRE_FAMILIES`). A bespoke adapter (Cursor)
|
|
114
|
-
// frames its own protocol and has no generic rich-content path.
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
capabilityProfileCache.
|
|
178
|
-
return
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
} as const;
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
tenantId:
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
} as const;
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
tenantId:
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
tenantId:
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
this.db.select(
|
|
389
|
-
this.db.select(
|
|
390
|
-
this.db
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
this.db.select(
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
);
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
if (
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
...(
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
//
|
|
677
|
-
// account
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
candidate.
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
//
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
1
|
+
// Routing snapshot: builds the RouteSnapshot from the persisted model catalog.
|
|
2
|
+
|
|
3
|
+
import { asc, eq, ne } from "drizzle-orm";
|
|
4
|
+
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
5
|
+
import {
|
|
6
|
+
models,
|
|
7
|
+
modelAliases,
|
|
8
|
+
modelCombos,
|
|
9
|
+
networkPools,
|
|
10
|
+
poolRoutingSettings,
|
|
11
|
+
providerAccounts,
|
|
12
|
+
providerRoutingSettings,
|
|
13
|
+
providers,
|
|
14
|
+
tenantDisabledModels,
|
|
15
|
+
cliToolMappings,
|
|
16
|
+
cliToolSettings,
|
|
17
|
+
} from "../../persistence/schema";
|
|
18
|
+
import { DEFAULT_PROVIDER_MAX_INFLIGHT } from "../../persistence/schema";
|
|
19
|
+
import { resolveTenantOverride } from "../../persistence/tenant-scope";
|
|
20
|
+
import {
|
|
21
|
+
type RouteCandidate,
|
|
22
|
+
type ComboDefinition,
|
|
23
|
+
type PoolRoutingSetting,
|
|
24
|
+
type ProviderRoutingMap,
|
|
25
|
+
type RouteSnapshot,
|
|
26
|
+
type SnapshotBuilder,
|
|
27
|
+
} from "./route-model";
|
|
28
|
+
import { DEFAULT_PROXY_SKIP_PROVIDER_IDS } from "../../providers/provider-metadata";
|
|
29
|
+
import { buildClaudeCliUserAgent } from "../../providers/operations/custom-cli-headers";
|
|
30
|
+
import { isBundledProviderId } from "../../providers/provider-contracts";
|
|
31
|
+
import type { ServiceKind, WireFamily } from "../../protocol/canonical-model";
|
|
32
|
+
import { providerHasAdapterUserAgent, providerUsesBespokeWire } from "../../providers/provider-metadata";
|
|
33
|
+
|
|
34
|
+
const CLAUDE_MODEL_FAMILIES = new Set(["opus", "sonnet", "haiku", "fable", "mythos"]);
|
|
35
|
+
|
|
36
|
+
export function cliMappingSourceKeys(toolId: string, sourceModel: string): readonly string[] {
|
|
37
|
+
if (toolId !== "claude") return [sourceModel];
|
|
38
|
+
const normalized = sourceModel.trim().toLowerCase();
|
|
39
|
+
const family =
|
|
40
|
+
CLAUDE_MODEL_FAMILIES.has(normalized)
|
|
41
|
+
? normalized
|
|
42
|
+
: /^claude-(opus|sonnet|haiku|fable|mythos)(?:-|$)/.exec(normalized)?.[1];
|
|
43
|
+
if (normalized.includes("/") || family === undefined) return [sourceModel];
|
|
44
|
+
return [...new Set([
|
|
45
|
+
sourceModel,
|
|
46
|
+
family,
|
|
47
|
+
`claude-${family}-5`,
|
|
48
|
+
`claude-${family}-5-1`,
|
|
49
|
+
`claude-${family}-5-5`,
|
|
50
|
+
`claude-${family}-4-6`,
|
|
51
|
+
`claude-${family}-4-5`,
|
|
52
|
+
])];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Builds the routing `RouteSnapshot` from the persisted catalog
|
|
56
|
+
* (`providers`, `models`, `provider_accounts`). Consumed by
|
|
57
|
+
* `InMemoryRouteSnapshotService` as its `SnapshotBuilder`.
|
|
58
|
+
*
|
|
59
|
+
* Built-in definitions are materialized before snapshots are read. The
|
|
60
|
+
* `models` DB table is authoritative for routing; compiled definitions are
|
|
61
|
+
* only startup seed data. Custom/BYOK and live-discovered models are stored
|
|
62
|
+
* in the same table.
|
|
63
|
+
*
|
|
64
|
+
* Tenant-aware: a provider with `providers.tenant_id = NULL` is global
|
|
65
|
+
* (every built-in, plus any pool-wide custom provider) and its candidates
|
|
66
|
+
* carry `tenant_id: null`, matching every request regardless of tenant. A
|
|
67
|
+
* provider with `providers.tenant_id` set (BYOK) only pairs with accounts
|
|
68
|
+
* owned by that exact same tenant, and its candidates carry that tenant id
|
|
69
|
+
* — `RoutingEngine.plan()` filters candidates by the requesting tenant.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
type RouteCandidateWithHealth = RouteCandidate & {
|
|
73
|
+
health_status?: "cooldown" | "model_cooldown" | "disabled";
|
|
74
|
+
cooldown_kind?: "hard" | "soft";
|
|
75
|
+
/**
|
|
76
|
+
* Epoch milliseconds at which this candidate's cooling window ends, when one
|
|
77
|
+
* is in force. Routing-internal: it is what lets `plan()` report a real reset
|
|
78
|
+
* on the all-accounts-cooling answer, and it never reaches a client verbatim —
|
|
79
|
+
* only the derived `retry-after` does.
|
|
80
|
+
*/
|
|
81
|
+
cooldown_until?: number;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const HARD_COOLDOWN_CATEGORIES: Readonly<Record<string, true>> = {
|
|
85
|
+
quota_exhausted: true,
|
|
86
|
+
policy_blocked: true,
|
|
87
|
+
auth_invalidated: true,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function hardCooldownCategory(category: string | null): boolean {
|
|
91
|
+
return category !== null && HARD_COOLDOWN_CATEGORIES[category] === true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Projects one model row's metadata into the capability gates the router
|
|
96
|
+
* filters candidates by. Exported so the projection contract is unit-testable
|
|
97
|
+
* without a database.
|
|
98
|
+
*/
|
|
99
|
+
export function buildCapabilityProfile(row: {
|
|
100
|
+
modalities: unknown;
|
|
101
|
+
reasoning: boolean;
|
|
102
|
+
toolCall: boolean;
|
|
103
|
+
providerId: string;
|
|
104
|
+
}): Record<string, boolean> {
|
|
105
|
+
const mods =
|
|
106
|
+
row.modalities != null && typeof row.modalities === "object"
|
|
107
|
+
? (row.modalities as { input?: unknown[]; output?: unknown[] })
|
|
108
|
+
: {};
|
|
109
|
+
const inputMods = Array.isArray(mods.input) ? mods.input : [];
|
|
110
|
+
const outputMods = Array.isArray(mods.output) ? mods.output : [];
|
|
111
|
+
// The canonical chat and responses codecs encode `image`, `document`/`file`,
|
|
112
|
+
// and `audio` parts; the messages codec encodes the first two but has no
|
|
113
|
+
// audio block (see `AUDIO_CAPABLE_WIRE_FAMILIES`). A bespoke adapter (Cursor)
|
|
114
|
+
// frames its own protocol and has no generic rich-content path.
|
|
115
|
+
//
|
|
116
|
+
// The codec grant is a **fallback for rows with no modality metadata**, not
|
|
117
|
+
// an override: `defineModel` always seeds `"text"`, so a non-empty array is
|
|
118
|
+
// an explicit declaration and its contents are authoritative — a row that
|
|
119
|
+
// declares `["text"]` is a text-only row and the router must strip the image
|
|
120
|
+
// rather than forward it to an upstream that rejects it. An empty array is a
|
|
121
|
+
// discovered or legacy row that carried no metadata, and pass-through keeps
|
|
122
|
+
// a caller's attachment from being degraded on the strength of a guess.
|
|
123
|
+
// Whether a given upstream model accepts the part is still the upstream's
|
|
124
|
+
// call — a capability declared here only decides whether the router strips
|
|
125
|
+
// the part before it ever sees it, and the audio grant is narrowed per wire
|
|
126
|
+
// family at projection time.
|
|
127
|
+
const codecEncodesRichContent = !providerUsesBespokeWire(row.providerId);
|
|
128
|
+
const declaredInput = inputMods.length > 0;
|
|
129
|
+
const richContentByCodec = codecEncodesRichContent && !declaredInput;
|
|
130
|
+
// Reasoning and tools are never stripped here. A `false` flag — whether a
|
|
131
|
+
// discovered row recorded it for lack of metadata, or a catalog row set it
|
|
132
|
+
// explicitly — must not become a silent rewrite of a request the caller
|
|
133
|
+
// asked for. The upstream decides whether it can serve them and returns its
|
|
134
|
+
// own error if it cannot.
|
|
135
|
+
return {
|
|
136
|
+
// The route is not served by a canonical codec. Carried in the profile
|
|
137
|
+
// because the capability predicates downstream see only this snapshot, not
|
|
138
|
+
// the provider registry.
|
|
139
|
+
bespokeWire: !codecEncodesRichContent,
|
|
140
|
+
image: inputMods.includes("image") || richContentByCodec,
|
|
141
|
+
document: inputMods.includes("document") || richContentByCodec,
|
|
142
|
+
audio: inputMods.includes("audio") || richContentByCodec,
|
|
143
|
+
mediaGeneration: outputMods.includes("image"),
|
|
144
|
+
tools: true,
|
|
145
|
+
parallelToolCalls: true,
|
|
146
|
+
reasoning: true,
|
|
147
|
+
reasoningEncryptedContent: true,
|
|
148
|
+
// Search is its own route: a `websearch` row answers `POST /v1/search`.
|
|
149
|
+
// A chat row never claims it; a hosted declaration rides through `tools`.
|
|
150
|
+
webSearch: false,
|
|
151
|
+
responseJsonObject: true,
|
|
152
|
+
responseJsonSchema: true,
|
|
153
|
+
promptCaching: true,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The capability profile for one model row, memoized across rebuilds.
|
|
159
|
+
*
|
|
160
|
+
* Bounded LRU keyed by `providerId/modelId`: the value is a pure function of
|
|
161
|
+
* the row's metadata, so two rebuilds that see the same row get the same object
|
|
162
|
+
* instead of two identical ones. The bound keeps a long-lived process from
|
|
163
|
+
* pinning one profile per row it has ever seen — a model deleted from the
|
|
164
|
+
* catalog must not leave its profile behind forever.
|
|
165
|
+
*/
|
|
166
|
+
const CAPABILITY_PROFILE_CACHE_MAX_ENTRIES = 4096;
|
|
167
|
+
const capabilityProfileCache = new Map<string, Record<string, boolean>>();
|
|
168
|
+
|
|
169
|
+
function capabilityProfileFor(row: {
|
|
170
|
+
modalities: unknown;
|
|
171
|
+
reasoning: boolean;
|
|
172
|
+
toolCall: boolean;
|
|
173
|
+
providerId: string;
|
|
174
|
+
modelId: string;
|
|
175
|
+
}): Record<string, boolean> {
|
|
176
|
+
const key = `${row.providerId}/${row.modelId}`;
|
|
177
|
+
const cached = capabilityProfileCache.get(key);
|
|
178
|
+
if (cached !== undefined) return cached;
|
|
179
|
+
const profile = buildCapabilityProfile(row);
|
|
180
|
+
if (capabilityProfileCache.size >= CAPABILITY_PROFILE_CACHE_MAX_ENTRIES) {
|
|
181
|
+
const oldest = capabilityProfileCache.keys().next().value;
|
|
182
|
+
if (oldest !== undefined) capabilityProfileCache.delete(oldest);
|
|
183
|
+
}
|
|
184
|
+
capabilityProfileCache.set(key, profile);
|
|
185
|
+
return profile;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Live memo size, for tests and the metrics gauge. */
|
|
189
|
+
export function capabilityProfileCacheSize(): number {
|
|
190
|
+
return capabilityProfileCache.size;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Resolves route identity only for bundled adapters without a native User-Agent builder. */
|
|
194
|
+
export function resolveRouteUserAgent(
|
|
195
|
+
providerId: string,
|
|
196
|
+
tenantUserAgent: string | undefined,
|
|
197
|
+
globalUserAgent: string | undefined,
|
|
198
|
+
): string | undefined {
|
|
199
|
+
if (!isBundledProviderId(providerId) || providerHasAdapterUserAgent(providerId)) return undefined;
|
|
200
|
+
// A provider whose operator set no identity of their own gets the Claude Code
|
|
201
|
+
// CLI identity, resolved live so the stamped version tracks discovery. This
|
|
202
|
+
// used to be a frozen `codex_cli_rs` literal, which named a different vendor's
|
|
203
|
+
// client on requests that mostly reached neither.
|
|
204
|
+
const selected = resolveTenantOverride(
|
|
205
|
+
tenantUserAgent,
|
|
206
|
+
globalUserAgent,
|
|
207
|
+
buildClaudeCliUserAgent(),
|
|
208
|
+
);
|
|
209
|
+
return selected.trim() === "" ? undefined : selected;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** One persisted model row ready for candidate construction. */
|
|
213
|
+
interface MergedModelRow {
|
|
214
|
+
providerId: string;
|
|
215
|
+
modelId: string;
|
|
216
|
+
wireFamily: WireFamily;
|
|
217
|
+
serviceKind: ServiceKind;
|
|
218
|
+
endpointPath: string;
|
|
219
|
+
modalities: unknown;
|
|
220
|
+
outputLimit: number | null;
|
|
221
|
+
reasoning: boolean;
|
|
222
|
+
toolCall: boolean;
|
|
223
|
+
enabled: boolean;
|
|
224
|
+
proOnly: boolean | null;
|
|
225
|
+
/** Whether the provider publishes the row as free. Read for plan logging. */
|
|
226
|
+
freeTier: boolean | null;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Converts persisted model rows into candidate input. Seed materialization is
|
|
230
|
+
* performed before this function runs, so no static-vs-DB merge is needed.
|
|
231
|
+
*/
|
|
232
|
+
function mergeModelCatalog(
|
|
233
|
+
dbRows: readonly ModelRouteRow[],
|
|
234
|
+
): MergedModelRow[] {
|
|
235
|
+
return dbRows
|
|
236
|
+
.filter((row) => row.enabled)
|
|
237
|
+
.map((row) => ({
|
|
238
|
+
providerId: row.providerId,
|
|
239
|
+
modelId: row.modelId,
|
|
240
|
+
wireFamily: row.wireFamily as WireFamily,
|
|
241
|
+
serviceKind: row.serviceKind as ServiceKind,
|
|
242
|
+
endpointPath: row.endpointPath,
|
|
243
|
+
modalities: row.modalities,
|
|
244
|
+
outputLimit: row.outputLimit,
|
|
245
|
+
reasoning: row.reasoning,
|
|
246
|
+
toolCall: row.toolCall,
|
|
247
|
+
enabled: row.enabled,
|
|
248
|
+
freeTier: row.freeTier ?? null,
|
|
249
|
+
proOnly: row.proOnly ?? null,
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Column projections for the snapshot read.
|
|
255
|
+
*
|
|
256
|
+
* The builder reads a handful of fields from each table, but the read used to
|
|
257
|
+
* be a bare `select()` — every column of all ten tables, including
|
|
258
|
+
* `provider_accounts.credential_ciphertext` and `network_pools.
|
|
259
|
+
* endpoint_config`, which the snapshot never touches. That ciphertext is the
|
|
260
|
+
* largest thing the gateway stores per account, and the snapshot is rebuilt on
|
|
261
|
+
* every catalog change, so the bytes were transferred and then discarded on
|
|
262
|
+
* every rebuild. The projection keeps the query count at ten and drops the
|
|
263
|
+
* payload to what is actually read.
|
|
264
|
+
*
|
|
265
|
+
* Each object doubles as the row type (`ModelRouteRow` and friends are derived
|
|
266
|
+
* from it), so a column added here is automatically typed, and a column the
|
|
267
|
+
* builder starts reading but is missing here fails to compile rather than
|
|
268
|
+
* silently reading `undefined`.
|
|
269
|
+
*/
|
|
270
|
+
const PROVIDER_COLUMNS = {
|
|
271
|
+
id: providers.id,
|
|
272
|
+
tenantId: providers.tenantId,
|
|
273
|
+
requiresAccount: providers.requiresAccount,
|
|
274
|
+
} as const;
|
|
275
|
+
|
|
276
|
+
const MODEL_COLUMNS = {
|
|
277
|
+
providerId: models.providerId,
|
|
278
|
+
modelId: models.modelId,
|
|
279
|
+
wireFamily: models.wireFamily,
|
|
280
|
+
serviceKind: models.serviceKind,
|
|
281
|
+
endpointPath: models.endpointPath,
|
|
282
|
+
modalities: models.modalities,
|
|
283
|
+
outputLimit: models.outputLimit,
|
|
284
|
+
reasoning: models.reasoning,
|
|
285
|
+
toolCall: models.toolCall,
|
|
286
|
+
enabled: models.enabled,
|
|
287
|
+
// Carried for the plan gate: an upstream may refuse a model on a free plan,
|
|
288
|
+
// and only the row says which. See `planAllowsModel`.
|
|
289
|
+
proOnly: models.proOnly,
|
|
290
|
+
// Carried alongside it: the provider's own statement that the row is free,
|
|
291
|
+
// which is the other half of the same plan question.
|
|
292
|
+
freeTier: models.freeTier,
|
|
293
|
+
} as const;
|
|
294
|
+
const ACCOUNT_COLUMNS = {
|
|
295
|
+
id: providerAccounts.id,
|
|
296
|
+
providerId: providerAccounts.providerId,
|
|
297
|
+
tenantId: providerAccounts.tenantId,
|
|
298
|
+
label: providerAccounts.label,
|
|
299
|
+
status: providerAccounts.status,
|
|
300
|
+
cooldownUntil: providerAccounts.cooldownUntil,
|
|
301
|
+
modelCooldowns: providerAccounts.modelCooldowns,
|
|
302
|
+
lastErrorCategory: providerAccounts.lastErrorCategory,
|
|
303
|
+
// The account's last reported plan. Read here rather than from the quota
|
|
304
|
+
// cache, which is empty on a cold start — exactly when routing must not be
|
|
305
|
+
// guessing whether an account is free.
|
|
306
|
+
quotaPlan: providerAccounts.quotaPlan,
|
|
307
|
+
} as const;
|
|
308
|
+
|
|
309
|
+
const ALIAS_COLUMNS = {
|
|
310
|
+
tenantId: modelAliases.tenantId,
|
|
311
|
+
alias: modelAliases.alias,
|
|
312
|
+
targetModel: modelAliases.targetModel,
|
|
313
|
+
} as const;
|
|
314
|
+
|
|
315
|
+
const COMBO_COLUMNS = {
|
|
316
|
+
tenantId: modelCombos.tenantId,
|
|
317
|
+
name: modelCombos.name,
|
|
318
|
+
members: modelCombos.members,
|
|
319
|
+
strategy: modelCombos.strategy,
|
|
320
|
+
} as const;
|
|
321
|
+
|
|
322
|
+
const ROUTING_COLUMNS = {
|
|
323
|
+
providerId: providerRoutingSettings.providerId,
|
|
324
|
+
tenantId: providerRoutingSettings.tenantId,
|
|
325
|
+
strategy: providerRoutingSettings.strategy,
|
|
326
|
+
rotateCount: providerRoutingSettings.rotateCount,
|
|
327
|
+
maxInflight: providerRoutingSettings.maxInflight,
|
|
328
|
+
maxInflightPerProxy: providerRoutingSettings.maxInflightPerProxy,
|
|
329
|
+
usageFloor: providerRoutingSettings.usageFloor,
|
|
330
|
+
usageFloorUnit: providerRoutingSettings.usageFloorUnit,
|
|
331
|
+
enabled: providerRoutingSettings.enabled,
|
|
332
|
+
skipProxy: providerRoutingSettings.skipProxy,
|
|
333
|
+
userAgent: providerRoutingSettings.userAgent,
|
|
334
|
+
} as const;
|
|
335
|
+
|
|
336
|
+
const POOL_COLUMNS = {
|
|
337
|
+
id: networkPools.id,
|
|
338
|
+
tenantId: networkPools.tenantId,
|
|
339
|
+
status: networkPools.status,
|
|
340
|
+
maxInflight: networkPools.maxInflight,
|
|
341
|
+
weight: networkPools.weight,
|
|
342
|
+
} as const;
|
|
343
|
+
|
|
344
|
+
const DISABLED_MODEL_COLUMNS = {
|
|
345
|
+
tenantId: tenantDisabledModels.tenantId,
|
|
346
|
+
providerId: tenantDisabledModels.providerId,
|
|
347
|
+
modelId: tenantDisabledModels.modelId,
|
|
348
|
+
endpointPath: tenantDisabledModels.endpointPath,
|
|
349
|
+
} as const;
|
|
350
|
+
|
|
351
|
+
const CLI_MAPPING_COLUMNS = {
|
|
352
|
+
tenantId: cliToolMappings.tenantId,
|
|
353
|
+
apiKeyId: cliToolMappings.apiKeyId,
|
|
354
|
+
toolId: cliToolMappings.toolId,
|
|
355
|
+
sourceModel: cliToolMappings.sourceModel,
|
|
356
|
+
targetModel: cliToolMappings.targetModel,
|
|
357
|
+
enabled: cliToolMappings.enabled,
|
|
358
|
+
} as const;
|
|
359
|
+
|
|
360
|
+
/** The per-(tenant, tool, key) Remote Routing switch. A mapping row only
|
|
361
|
+
* routes when its bucket's settings row has `mappings_enabled` set. */
|
|
362
|
+
const CLI_MAPPING_SETTING_COLUMNS = {
|
|
363
|
+
tenantId: cliToolSettings.tenantId,
|
|
364
|
+
apiKeyId: cliToolSettings.apiKeyId,
|
|
365
|
+
toolId: cliToolSettings.toolId,
|
|
366
|
+
mappingsEnabled: cliToolSettings.mappingsEnabled,
|
|
367
|
+
} as const;
|
|
368
|
+
|
|
369
|
+
const POOL_SETTING_COLUMNS = {
|
|
370
|
+
tenantId: poolRoutingSettings.tenantId,
|
|
371
|
+
strategy: poolRoutingSettings.strategy,
|
|
372
|
+
rotateCount: poolRoutingSettings.rotateCount,
|
|
373
|
+
} as const;
|
|
374
|
+
|
|
375
|
+
/** A row of `models` as this module reads it. */
|
|
376
|
+
type ModelRouteRow = { [K in keyof typeof MODEL_COLUMNS]: (typeof models.$inferSelect)[K] };
|
|
377
|
+
|
|
378
|
+
/** Snapshot load result from the 10-table catalog read. */
|
|
379
|
+
type RouteCatalogSnapshotResult = Omit<RouteSnapshot, "revision" | "created_at"> & { created_at?: number };
|
|
380
|
+
|
|
381
|
+
/** Repository abstracting database queries for the route catalog snapshot. */
|
|
382
|
+
class RouteCatalogRepository {
|
|
383
|
+
constructor(private readonly db: CartethyiaDatabase) {}
|
|
384
|
+
|
|
385
|
+
async loadRouteCatalogSnapshot(tenantId?: string): Promise<RouteCatalogSnapshotResult> {
|
|
386
|
+
const [providerRows, modelRows, accountRows, aliasRows, comboRows, routingRows, poolRows, disabledModelRows, cliMappingRows, cliMappingSettingRows, poolSettingRows] =
|
|
387
|
+
await Promise.all([
|
|
388
|
+
this.db.select(PROVIDER_COLUMNS).from(providers).where(eq(providers.enabled, true)),
|
|
389
|
+
this.db.select(MODEL_COLUMNS).from(models),
|
|
390
|
+
this.db
|
|
391
|
+
.select(ACCOUNT_COLUMNS)
|
|
392
|
+
.from(providerAccounts)
|
|
393
|
+
.where(ne(providerAccounts.status, "disabled"))
|
|
394
|
+
.orderBy(asc(providerAccounts.status), asc(providerAccounts.createdAt), asc(providerAccounts.id)),
|
|
395
|
+
this.db.select(ALIAS_COLUMNS).from(modelAliases),
|
|
396
|
+
this.db.select(COMBO_COLUMNS).from(modelCombos),
|
|
397
|
+
this.db.select(ROUTING_COLUMNS).from(providerRoutingSettings),
|
|
398
|
+
this.db.select(POOL_COLUMNS).from(networkPools),
|
|
399
|
+
this.db.select(DISABLED_MODEL_COLUMNS).from(tenantDisabledModels),
|
|
400
|
+
this.db.select(CLI_MAPPING_COLUMNS).from(cliToolMappings),
|
|
401
|
+
this.db.select(CLI_MAPPING_SETTING_COLUMNS).from(cliToolSettings),
|
|
402
|
+
this.db.select(POOL_SETTING_COLUMNS).from(poolRoutingSettings),
|
|
403
|
+
]);
|
|
404
|
+
const mergedModelRows = mergeModelCatalog(modelRows);
|
|
405
|
+
|
|
406
|
+
// Compile tenant-scoped disables into a lookup keyed by the same
|
|
407
|
+
// composite identity the `models` table uses. A disabled key suppresses
|
|
408
|
+
// the candidate whose account belongs to that tenant.
|
|
409
|
+
const disabledModelKeys = new Set<string>(
|
|
410
|
+
disabledModelRows.map(
|
|
411
|
+
(row) => `${row.tenantId}:${row.providerId}:${row.modelId}:${row.endpointPath}`,
|
|
412
|
+
),
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const providerTenantById = new Map<string, string | null>(
|
|
416
|
+
providerRows.map((p) => [p.id, p.tenantId]),
|
|
417
|
+
);
|
|
418
|
+
const requiresAccountById = new Map<string, boolean>(
|
|
419
|
+
providerRows.map((p) => [p.id, p.requiresAccount]),
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
// Every active (non-disabled) pool a tenant owns, available for
|
|
423
|
+
// automatic per-request selection — see `resolveNetworkPools` below.
|
|
424
|
+
const activePoolsByTenant = new Map<
|
|
425
|
+
string,
|
|
426
|
+
Array<{ id: string; maxInflight: number; weight: number }>
|
|
427
|
+
>();
|
|
428
|
+
const configuredPoolTenants = new Set<string>();
|
|
429
|
+
for (const pool of poolRows) {
|
|
430
|
+
configuredPoolTenants.add(pool.tenantId);
|
|
431
|
+
if (pool.status !== "active") continue;
|
|
432
|
+
if (!pool.tenantId) continue;
|
|
433
|
+
const list = activePoolsByTenant.get(pool.tenantId) ?? [];
|
|
434
|
+
list.push({ id: pool.id, maxInflight: pool.maxInflight ?? 10, weight: pool.weight ?? 100 });
|
|
435
|
+
activePoolsByTenant.set(pool.tenantId, list);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Per-tenant pool selection strategy; an absent row reads as the
|
|
439
|
+
// `least_loaded` default at selection time.
|
|
440
|
+
const poolRouting: Record<string, PoolRoutingSetting> = {};
|
|
441
|
+
for (const row of poolSettingRows) {
|
|
442
|
+
if (tenantId === undefined || row.tenantId === tenantId) {
|
|
443
|
+
poolRouting[row.tenantId] = { strategy: row.strategy, rotateCount: row.rotateCount };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Build per-tenant per-provider routing preferences before the
|
|
448
|
+
// candidates loop below, since skipProxy has to be known while
|
|
449
|
+
// deciding each candidate's network pool set. Global settings
|
|
450
|
+
// (tenant_id IS NULL) are stored under sentinel "__global__".
|
|
451
|
+
const providerRouting: Record<string, Record<string, ProviderRoutingMap[string][string]>> = {};
|
|
452
|
+
for (const row of routingRows) {
|
|
453
|
+
const tenantKey = row.tenantId ?? "__global__";
|
|
454
|
+
const bucket = (providerRouting[tenantKey] ??= {});
|
|
455
|
+
bucket[row.providerId] = {
|
|
456
|
+
strategy: row.strategy as ProviderRoutingMap[string][string]["strategy"],
|
|
457
|
+
rotateCount: row.rotateCount ?? 1,
|
|
458
|
+
maxInflight: row.maxInflight,
|
|
459
|
+
maxInflightPerProxy: row.maxInflightPerProxy,
|
|
460
|
+
usageFloor: row.usageFloor,
|
|
461
|
+
usageFloorUnit: row.usageFloorUnit === "credit" ? "credit" : "percent",
|
|
462
|
+
enabled: row.enabled,
|
|
463
|
+
skipProxy: row.skipProxy,
|
|
464
|
+
userAgent: row.userAgent,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
/** Tenant-specific setting wins over global; an unconfigured provider
|
|
468
|
+
* falls back to `DEFAULT_PROXY_SKIP_PROVIDER_IDS`, mirroring the same
|
|
469
|
+
* default `DrizzleProviderDetailStore.getRouting` reports to the API —
|
|
470
|
+
* so the dashboard's displayed default and the real dispatch decision
|
|
471
|
+
* never disagree. */
|
|
472
|
+
function resolveSkipProxy(providerId: string, rowTenantId: string | null): boolean {
|
|
473
|
+
const effectiveTenantId = rowTenantId;
|
|
474
|
+
const tenantSetting = effectiveTenantId ? providerRouting[effectiveTenantId]?.[providerId] : undefined;
|
|
475
|
+
const globalSetting = providerRouting.__global__?.[providerId];
|
|
476
|
+
return resolveTenantOverride(
|
|
477
|
+
tenantSetting?.skipProxy,
|
|
478
|
+
globalSetting?.skipProxy,
|
|
479
|
+
DEFAULT_PROXY_SKIP_PROVIDER_IDS.has(providerId),
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
function resolveUserAgent(providerId: string, rowTenantId: string | null): string | undefined {
|
|
483
|
+
const tenantSetting = rowTenantId ? providerRouting[rowTenantId]?.[providerId]?.userAgent : undefined;
|
|
484
|
+
const globalSetting = providerRouting.__global__?.[providerId]?.userAgent;
|
|
485
|
+
return resolveRouteUserAgent(providerId, tenantSetting, globalSetting);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Provider-wide concurrency ceiling shared by every account of the provider.
|
|
489
|
+
* Tenant setting wins over global, mirroring skipProxy. A stored `null`
|
|
490
|
+
* is the explicit unlimited choice; so is the default — a provider with no
|
|
491
|
+
* routing row at all is unlimited too, because an operator who never
|
|
492
|
+
* configured a ceiling asked for none. */
|
|
493
|
+
function resolveMaxInflight(
|
|
494
|
+
providerId: string,
|
|
495
|
+
rowTenantId: string | null,
|
|
496
|
+
): number | undefined {
|
|
497
|
+
const tenantSetting = rowTenantId
|
|
498
|
+
? providerRouting[rowTenantId]?.[providerId]?.maxInflight
|
|
499
|
+
: undefined;
|
|
500
|
+
const globalSetting = providerRouting.__global__?.[providerId]?.maxInflight;
|
|
501
|
+
const resolved = tenantSetting ?? globalSetting;
|
|
502
|
+
if (resolved === null) return undefined;
|
|
503
|
+
return resolved ?? DEFAULT_PROVIDER_MAX_INFLIGHT ?? undefined;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* This provider's per-pool inflight ceiling, from Routing Strategy.
|
|
508
|
+
* `undefined` means unlimited — the default, and the state of every
|
|
509
|
+
* provider nobody has configured. A stored `null` is the same choice made
|
|
510
|
+
* explicitly; the two are deliberately indistinguishable downstream so the
|
|
511
|
+
* hot path never branches on which one it read.
|
|
512
|
+
*/
|
|
513
|
+
function resolveMaxInflightPerProxy(
|
|
514
|
+
providerId: string,
|
|
515
|
+
rowTenantId: string | null,
|
|
516
|
+
): number | undefined {
|
|
517
|
+
const tenantSetting = rowTenantId
|
|
518
|
+
? providerRouting[rowTenantId]?.[providerId]?.maxInflightPerProxy
|
|
519
|
+
: undefined;
|
|
520
|
+
const globalSetting = providerRouting.__global__?.[providerId]?.maxInflightPerProxy;
|
|
521
|
+
return tenantSetting ?? globalSetting ?? undefined;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Every active pool the account's tenant owns — dispatch picks the
|
|
525
|
+
* least-loaded/non-cooldown one per request (`tryAcquireAvailablePool`),
|
|
526
|
+
* never an admin-pinned single pool. */
|
|
527
|
+
function resolveNetworkPools(
|
|
528
|
+
providerId: string,
|
|
529
|
+
rowTenantId: string | null,
|
|
530
|
+
):
|
|
531
|
+
| {
|
|
532
|
+
ids: readonly string[];
|
|
533
|
+
limits: Record<string, number>;
|
|
534
|
+
weights: Record<string, number>;
|
|
535
|
+
routing?: PoolRoutingSetting & { tenantId: string };
|
|
536
|
+
}
|
|
537
|
+
| undefined {
|
|
538
|
+
if (!rowTenantId || resolveSkipProxy(providerId, rowTenantId)) return undefined;
|
|
539
|
+
if (!configuredPoolTenants.has(rowTenantId)) return undefined;
|
|
540
|
+
const pools = activePoolsByTenant.get(rowTenantId) ?? [];
|
|
541
|
+
const limits: Record<string, number> = {};
|
|
542
|
+
const weights: Record<string, number> = {};
|
|
543
|
+
for (const pool of pools) {
|
|
544
|
+
limits[pool.id] = pool.maxInflight;
|
|
545
|
+
weights[pool.id] = pool.weight;
|
|
546
|
+
}
|
|
547
|
+
const routing = poolRouting[rowTenantId];
|
|
548
|
+
return {
|
|
549
|
+
ids: pools.map((pool) => pool.id),
|
|
550
|
+
limits,
|
|
551
|
+
weights,
|
|
552
|
+
...(routing ? { routing: { tenantId: rowTenantId, ...routing } } : {}),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** The candidate fragment for a provider ceiling, empty when unlimited —
|
|
557
|
+
* so an unconfigured provider's candidate is byte-for-byte what it was. */
|
|
558
|
+
function providerLimitFor(
|
|
559
|
+
providerId: string,
|
|
560
|
+
rowTenantId: string | null,
|
|
561
|
+
): { network_pool_provider_limit?: number } {
|
|
562
|
+
const limit = resolveMaxInflightPerProxy(providerId, rowTenantId);
|
|
563
|
+
return limit === undefined ? {} : { network_pool_provider_limit: limit };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Group usable accounts per provider (not per provider-tenant). Built-in
|
|
567
|
+
// providers are `tenant_id IS NULL` but their accounts are per-tenant, so
|
|
568
|
+
// grouping by `providerTenantId` would make every global model look
|
|
569
|
+
// disabled. Instead keep all accounts for a provider together and fan out
|
|
570
|
+
// candidates per account tenant below — e.g. `opencodeze/muse-spark`
|
|
571
|
+
// hits with `model: "opencodeze/muse-spark"` must resolve to the tenant's
|
|
572
|
+
// own account even though the provider row is global.
|
|
573
|
+
const accountsByProvider = new Map<string, typeof accountRows>();
|
|
574
|
+
for (const account of accountRows) {
|
|
575
|
+
if (account.status === "disabled") continue;
|
|
576
|
+
const list = accountsByProvider.get(account.providerId) ?? [];
|
|
577
|
+
list.push(account);
|
|
578
|
+
accountsByProvider.set(account.providerId, list);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const candidates: RouteCandidateWithHealth[] = [];
|
|
582
|
+
for (const model of mergedModelRows) {
|
|
583
|
+
const providerTenantId = providerTenantById.get(model.providerId);
|
|
584
|
+
if (providerTenantId === undefined) continue;
|
|
585
|
+
const accounts = accountsByProvider.get(model.providerId) ?? [];
|
|
586
|
+
// One profile per model row, memoized across snapshot rebuilds. The
|
|
587
|
+
// profile is derived purely from the row's metadata, is never mutated
|
|
588
|
+
// (grep finds no write to `capability_profile`), and is shared by every
|
|
589
|
+
// candidate this row fans out to — so a rebuild no longer constructs a
|
|
590
|
+
// fresh object per model on top of the fan-out it already had.
|
|
591
|
+
const capabilityProfile = capabilityProfileFor(model);
|
|
592
|
+
if (accounts.length === 0) {
|
|
593
|
+
const providerRequiresAccount = requiresAccountById.get(model.providerId) ?? true;
|
|
594
|
+
if (!providerRequiresAccount) {
|
|
595
|
+
// Public models inherit active pools for each tenant that has active pools
|
|
596
|
+
for (const tId of configuredPoolTenants) {
|
|
597
|
+
const tenantPools = resolveNetworkPools(model.providerId, tId);
|
|
598
|
+
if (tenantPools) {
|
|
599
|
+
const tenantCandidate: RouteCandidateWithHealth = {
|
|
600
|
+
provider_id: model.providerId,
|
|
601
|
+
model_id: model.modelId,
|
|
602
|
+
wire_family: model.wireFamily as WireFamily,
|
|
603
|
+
service_kind: model.serviceKind,
|
|
604
|
+
endpoint: model.endpointPath,
|
|
605
|
+
...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
|
|
606
|
+
capability_profile: capabilityProfile,
|
|
607
|
+
tenant_id: tId,
|
|
608
|
+
requires_account: false as const,
|
|
609
|
+
network_pool_ids: tenantPools.ids,
|
|
610
|
+
network_pool_required: true,
|
|
611
|
+
network_pool_limits: tenantPools.limits,
|
|
612
|
+
network_pool_weights: tenantPools.weights,
|
|
613
|
+
...(tenantPools.routing ? { network_pool_routing: tenantPools.routing } : {}),
|
|
614
|
+
...providerLimitFor(model.providerId, tId),
|
|
615
|
+
};
|
|
616
|
+
if (tenantId === undefined || tenantId === tId) {
|
|
617
|
+
candidates.push(tenantCandidate);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
const networkPools = providerRequiresAccount
|
|
623
|
+
? undefined
|
|
624
|
+
: resolveNetworkPools(model.providerId, providerTenantId);
|
|
625
|
+
const candidate: RouteCandidateWithHealth = {
|
|
626
|
+
provider_id: model.providerId,
|
|
627
|
+
model_id: model.modelId,
|
|
628
|
+
wire_family: model.wireFamily as WireFamily,
|
|
629
|
+
service_kind: model.serviceKind,
|
|
630
|
+
endpoint: model.endpointPath,
|
|
631
|
+
...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
|
|
632
|
+
capability_profile: capabilityProfile,
|
|
633
|
+
tenant_id: providerTenantId,
|
|
634
|
+
...(providerRequiresAccount
|
|
635
|
+
? { health_status: "disabled" as const }
|
|
636
|
+
: { requires_account: false as const }),
|
|
637
|
+
...(networkPools
|
|
638
|
+
? {
|
|
639
|
+
network_pool_ids: networkPools.ids,
|
|
640
|
+
network_pool_required: true,
|
|
641
|
+
network_pool_limits: networkPools.limits,
|
|
642
|
+
network_pool_weights: networkPools.weights,
|
|
643
|
+
...(networkPools.routing ? { network_pool_routing: networkPools.routing } : {}),
|
|
644
|
+
...providerLimitFor(model.providerId, providerTenantId),
|
|
645
|
+
}
|
|
646
|
+
: {}),
|
|
647
|
+
};
|
|
648
|
+
if (tenantId === undefined || candidate.tenant_id === null || candidate.tenant_id === tenantId) {
|
|
649
|
+
candidates.push(candidate);
|
|
650
|
+
}
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
for (const account of accounts) {
|
|
654
|
+
const rowTenantId = account.tenantId ?? providerTenantId;
|
|
655
|
+
if (
|
|
656
|
+
rowTenantId !== null &&
|
|
657
|
+
disabledModelKeys.has(
|
|
658
|
+
`${rowTenantId}:${model.providerId}:${model.modelId}:${model.endpointPath}`,
|
|
659
|
+
)
|
|
660
|
+
) {
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
const networkPools = resolveNetworkPools(model.providerId, rowTenantId);
|
|
664
|
+
const routeUserAgent = resolveUserAgent(model.providerId, rowTenantId);
|
|
665
|
+
const candidate: RouteCandidateWithHealth = {
|
|
666
|
+
provider_id: model.providerId,
|
|
667
|
+
model_id: model.modelId,
|
|
668
|
+
wire_family: model.wireFamily as WireFamily,
|
|
669
|
+
service_kind: model.serviceKind,
|
|
670
|
+
endpoint: model.endpointPath,
|
|
671
|
+
...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
|
|
672
|
+
capability_profile: capabilityProfile,
|
|
673
|
+
...(routeUserAgent === undefined ? {} : { user_agent: routeUserAgent }),
|
|
674
|
+
tenant_id: rowTenantId,
|
|
675
|
+
provider_account_id: account.id,
|
|
676
|
+
// The plan gate is per (account, model): the model may be refused on
|
|
677
|
+
// a free plan and the account may be on one. Both ride here because
|
|
678
|
+
// `plan()` sees the candidate and nothing else.
|
|
679
|
+
...(model.proOnly === true ? { pro_only: true } : {}),
|
|
680
|
+
...(account.quotaPlan === null || account.quotaPlan === undefined
|
|
681
|
+
? {}
|
|
682
|
+
: { account_plan: account.quotaPlan }),
|
|
683
|
+
// Provider-wide concurrency ceiling from Routing Strategy; legacy
|
|
684
|
+
// account overrides are never read here.
|
|
685
|
+
...(() => {
|
|
686
|
+
const resolved = resolveMaxInflight(model.providerId, rowTenantId);
|
|
687
|
+
return resolved === undefined ? {} : { max_inflight: resolved };
|
|
688
|
+
})(),
|
|
689
|
+
...(networkPools
|
|
690
|
+
? {
|
|
691
|
+
network_pool_required: true,
|
|
692
|
+
network_pool_ids: networkPools.ids,
|
|
693
|
+
network_pool_limits: networkPools.limits,
|
|
694
|
+
network_pool_weights: networkPools.weights,
|
|
695
|
+
...(networkPools.routing ? { network_pool_routing: networkPools.routing } : {}),
|
|
696
|
+
...providerLimitFor(model.providerId, rowTenantId),
|
|
697
|
+
}
|
|
698
|
+
: {}),
|
|
699
|
+
};
|
|
700
|
+
if (account.status === "cooldown") {
|
|
701
|
+
// Expired cooldowns read as healthy here without a fire-and-forget
|
|
702
|
+
// write: AccountHealthSweeper materializes the recovery every 30s,
|
|
703
|
+
// so the builder stays a pure read (no UPDATE racing the request).
|
|
704
|
+
if (account.cooldownUntil && account.cooldownUntil.getTime() > Date.now()) {
|
|
705
|
+
candidate.health_status = "cooldown";
|
|
706
|
+
candidate.cooldown_kind = hardCooldownCategory(account.lastErrorCategory)
|
|
707
|
+
? "hard"
|
|
708
|
+
: "soft";
|
|
709
|
+
// The deadline rides with the exclusion it explains. `plan()` reads
|
|
710
|
+
// it to answer the all-cooling case with the real reset instead of
|
|
711
|
+
// a bare 429, so a client backs off for the window that is actually
|
|
712
|
+
// open rather than inventing one.
|
|
713
|
+
candidate.cooldown_until = account.cooldownUntil.getTime();
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Per-model cooldown: an account may be healthy globally but cooling down
|
|
718
|
+
// for a specific model. A non-expired entry for the model THIS candidate
|
|
719
|
+
// serves is a hard exclusion, not a deprioritization: the upstream said
|
|
720
|
+
// this exact (account, model) pair is exhausted until a stated reset
|
|
721
|
+
// ("Daily free limit reached … Try again in 11h"), so dialing it can only
|
|
722
|
+
// reproduce the same refusal. Deprioritizing instead made failover spend
|
|
723
|
+
// a full round trip on it after every healthy sibling had failed, and
|
|
724
|
+
// recorded another `active → cooldown` row each time — the operator's
|
|
725
|
+
// report of one account churning while ten siblings sat healthy.
|
|
726
|
+
//
|
|
727
|
+
// `model_cooldown` (distinct from `cooldown`) is what carries that
|
|
728
|
+
// distinction to the evaluator: an account-wide `cooldown` stays
|
|
729
|
+
// deprioritized so a single-account deployment still routes, while a
|
|
730
|
+
// model-scoped one is dropped for this model only and the account keeps
|
|
731
|
+
// serving every other model it holds.
|
|
732
|
+
const modelCooldowns = account.modelCooldowns as Record<string, string> | null;
|
|
733
|
+
const modelCooldownUntil = modelCooldowns?.[model.modelId];
|
|
734
|
+
if (modelCooldownUntil && new Date(modelCooldownUntil).getTime() > Date.now()) {
|
|
735
|
+
candidate.health_status = "model_cooldown";
|
|
736
|
+
candidate.cooldown_until = new Date(modelCooldownUntil).getTime();
|
|
737
|
+
}
|
|
738
|
+
if (tenantId === undefined || candidate.tenant_id === null || candidate.tenant_id === tenantId) {
|
|
739
|
+
candidates.push(candidate);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const aliases: Record<string, Record<string, string>> = {};
|
|
745
|
+
const cliAliases: Record<string, Record<string, string>> = {};
|
|
746
|
+
for (const row of aliasRows) {
|
|
747
|
+
if (tenantId === undefined || row.tenantId === tenantId) {
|
|
748
|
+
(aliases[row.tenantId] ??= {})[row.alias] = row.targetModel;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
// CLI-tool mappings are per-(tenant, key): each API key can route the same
|
|
752
|
+
// CLI slot to a different target. The snapshot keys the alias bucket by
|
|
753
|
+
// `${tenantId}:${apiKeyId}` so the preparer can look up the exact key's
|
|
754
|
+
// routes without merging across keys.
|
|
755
|
+
// Remote Routing is opt-in per (tenant, tool, key): a mapping row routes
|
|
756
|
+
// only when its bucket's settings row explicitly enabled it. The CLI tool
|
|
757
|
+
// page and the API-key edit form both write that one flag, so either place
|
|
758
|
+
// can turn the routes on or off.
|
|
759
|
+
const enabledMappingBuckets = new Set<string>();
|
|
760
|
+
for (const row of cliMappingSettingRows) {
|
|
761
|
+
if (row.mappingsEnabled) enabledMappingBuckets.add(`${row.tenantId}:${row.apiKeyId}`);
|
|
762
|
+
}
|
|
763
|
+
for (const row of cliMappingRows) {
|
|
764
|
+
if (!row.enabled) continue;
|
|
765
|
+
if (!enabledMappingBuckets.has(`${row.tenantId}:${row.apiKeyId}`)) continue;
|
|
766
|
+
if (tenantId === undefined || row.tenantId === tenantId) {
|
|
767
|
+
const bucketKey = `${row.tenantId}:${row.apiKeyId}`;
|
|
768
|
+
const aliasBucket = (cliAliases[bucketKey] ??= {});
|
|
769
|
+
for (const sourceKey of cliMappingSourceKeys(row.toolId, row.sourceModel)) {
|
|
770
|
+
aliasBucket[sourceKey] = row.targetModel;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
const combos: Record<string, Record<string, ComboDefinition>> = {};
|
|
775
|
+
for (const row of comboRows) {
|
|
776
|
+
if (tenantId === undefined || row.tenantId === tenantId) {
|
|
777
|
+
(combos[row.tenantId] ??= {})[row.name] = { members: row.members, strategy: row.strategy };
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const routingMap: ProviderRoutingMap | undefined =
|
|
782
|
+
Object.keys(providerRouting).length > 0 ? (providerRouting as ProviderRoutingMap) : undefined;
|
|
783
|
+
|
|
784
|
+
return {
|
|
785
|
+
candidates,
|
|
786
|
+
aliases,
|
|
787
|
+
cli_aliases: cliAliases,
|
|
788
|
+
combos,
|
|
789
|
+
...(routingMap ? { providerRouting: routingMap } : {}),
|
|
790
|
+
...(Object.keys(poolRouting).length > 0 ? { poolRouting } : {}),
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Builds a `SnapshotBuilder` reading the live catalog from `db`. */
|
|
796
|
+
export function createDatabaseSnapshotBuilder(
|
|
797
|
+
db: CartethyiaDatabase,
|
|
798
|
+
refreshActiveCatalogs?: () => Promise<void>,
|
|
799
|
+
): SnapshotBuilder {
|
|
800
|
+
const repository = new RouteCatalogRepository(db);
|
|
801
|
+
return async () => {
|
|
802
|
+
await refreshActiveCatalogs?.();
|
|
803
|
+
return repository.loadRouteCatalogSnapshot(undefined);
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|