cartethyia 2.4.5 → 2.5.0
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 +4 -1
- package/README.md +63 -13
- package/dist/dashboard/assets/AccountCooldown-ChpdzxRH.js.gz +0 -0
- package/dist/dashboard/assets/BannedPage-9igfctfZ.js +1 -0
- package/dist/dashboard/assets/BannedPage-9igfctfZ.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-BzGevuql.js → CliToolDetailPage-ZRr2K2sF.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-ZRr2K2sF.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-BaRM_7We.js → CliToolsPage-D3xqZbN-.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-D3xqZbN-.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-CVnQPBBz.js +1 -0
- package/dist/dashboard/assets/CombosPage-CVnQPBBz.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js +1 -0
- package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-DqUGcoH0.js → CustomizationPage-CTTpCb25.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-CTTpCb25.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-DbhTGfhb.js → LoginPage-DHoB3Agn.js} +1 -1
- package/dist/dashboard/assets/LoginPage-DHoB3Agn.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js +1 -0
- package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js +1 -0
- package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js +7 -0
- package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js +1 -0
- package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-YxRml8AT.js → SettingsPage-CvjZxHE4.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-CvjZxHE4.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-D3nza7LH.js +1 -0
- package/dist/dashboard/assets/SetupPage-D3nza7LH.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-Bol1ASmI.js → SortableList-Dtt12WT1.js} +1 -1
- package/dist/dashboard/assets/SortableList-Dtt12WT1.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-CbK3hXvU.js +31 -0
- package/dist/dashboard/assets/StudioPage-CbK3hXvU.js.gz +0 -0
- package/dist/dashboard/assets/ToolIcon-K1ckcDKg.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BQlWbWg2.js +56 -0
- package/dist/dashboard/assets/UsagePage-BQlWbWg2.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-BmDbsior.js +1 -0
- package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
- package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
- package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
- package/dist/dashboard/assets/{app-BDJWMThu.js → app-C-Y4oZT0.js} +3 -3
- package/dist/dashboard/assets/app-C-Y4oZT0.js.gz +0 -0
- package/dist/dashboard/assets/arrow-left-CpBxkoxI.js.gz +0 -0
- package/dist/dashboard/assets/arrow-right-Dl5yKnp8.js.gz +0 -0
- package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
- package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
- package/dist/dashboard/assets/backup-file-gdKIlg8U.js.gz +0 -0
- package/dist/dashboard/assets/{badge-BwmwEOgA.js → badge-DpXT4Ixt.js} +1 -1
- package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
- package/dist/dashboard/assets/{banner-BKBfKTqn.js → banner-CSAAWLJv.js} +1 -1
- package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
- package/dist/dashboard/assets/brain-Cwed_72z.js.gz +0 -0
- package/dist/dashboard/assets/{card-BTdbRFFg.js → card-DEhRXXw4.js} +1 -1
- package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
- package/dist/dashboard/assets/check-D69FzdvC.js.gz +0 -0
- package/dist/dashboard/assets/chevron-right-BXIy5l3h.js.gz +0 -0
- package/dist/dashboard/assets/circle-alert-CntYHV5v.js.gz +0 -0
- package/dist/dashboard/assets/circle-check-BU3v_mFW.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-B0wBq-RL.js +1 -0
- package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js +1 -0
- package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
- package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
- package/dist/dashboard/assets/copy-ngmQ9zcZ.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-iocKA3jr.js → dialog-BvQeBMfP.js} +1 -1
- package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
- package/dist/dashboard/assets/dialog-actions-B1axpEg7.js.gz +0 -0
- package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
- package/dist/dashboard/assets/download-dRFo5_Wu.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-CDKOTZdY.js → drawer-CMwPXwQB.js} +1 -1
- package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
- package/dist/dashboard/assets/entry-D4O2229X.js +1 -0
- package/dist/dashboard/assets/entry-D4O2229X.js.gz +0 -0
- package/dist/dashboard/assets/entry-DKvr5Q9K.js +1 -0
- package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
- package/dist/dashboard/assets/entry-FQCejoDY.js +4 -0
- package/dist/dashboard/assets/entry-FQCejoDY.js.gz +0 -0
- package/dist/dashboard/assets/eye-C_hawfH4.js.gz +0 -0
- package/dist/dashboard/assets/eye-off-Bk5g2luF.js.gz +0 -0
- package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
- package/dist/dashboard/assets/gauge-BbIMy7MB.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-zliLvBwr.js +1 -0
- package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
- package/dist/dashboard/assets/image-BPaouViG.js.gz +0 -0
- package/dist/dashboard/assets/{index-C4hXfAMs.js → index-CXm3FIyt.js} +2 -2
- package/dist/dashboard/assets/index-CXm3FIyt.js.gz +0 -0
- package/dist/dashboard/assets/{inline-DCejSLlA.js → inline-D1IlZZ8H.js} +1 -1
- package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
- package/dist/dashboard/assets/{input-Bn4_kNav.js → input-BpVZdrbc.js} +1 -1
- package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
- package/dist/dashboard/assets/jsx-runtime-CXUMngI_.js.gz +0 -0
- package/dist/dashboard/assets/key-round-UkavsGxp.js.gz +0 -0
- package/dist/dashboard/assets/layers-2-glBbjl.js.gz +0 -0
- package/dist/dashboard/assets/{layout-Bv-NxqZ5.js → layout-BioLW7gN.js} +1 -1
- package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-BLA0sldD.js → list-row-CwVcGAIc.js} +1 -1
- package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
- package/dist/dashboard/assets/{live-YlifPYox.js → live-yfEJFF9T.js} +1 -1
- package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
- package/dist/dashboard/assets/log-in-BmRjy60c.js.gz +0 -0
- package/dist/dashboard/assets/message-circle-CCxGwRvv.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CT-TxSNX.js +1 -0
- package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
- package/dist/dashboard/assets/mutation-BkoLM5Ba.js +1 -0
- package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
- package/dist/dashboard/assets/numbers-CFL4_Dqc.js.gz +0 -0
- package/dist/dashboard/assets/pencil-768ysRAr.js.gz +0 -0
- package/dist/dashboard/assets/plus-DIdX_g-x.js.gz +0 -0
- package/dist/dashboard/assets/power-off-Yjw9GMJh.js +1 -0
- package/dist/dashboard/assets/power-off-Yjw9GMJh.js.gz +0 -0
- package/dist/dashboard/assets/privacy-mode-GM8bej85.js +1 -0
- package/dist/dashboard/assets/privacy-mode-GM8bej85.js.gz +0 -0
- package/dist/dashboard/assets/providers/agentrouter.svg.gz +0 -0
- package/dist/dashboard/assets/providers/aihubmix.svg.gz +0 -0
- package/dist/dashboard/assets/providers/alibaba-coding-plan.svg.gz +0 -0
- package/dist/dashboard/assets/providers/alibaba.svg.gz +0 -0
- package/dist/dashboard/assets/providers/antigravity.svg.gz +0 -0
- package/dist/dashboard/assets/providers/bai.svg.gz +0 -0
- package/dist/dashboard/assets/providers/blackbox.svg.gz +0 -0
- package/dist/dashboard/assets/providers/claude-code.svg.gz +0 -0
- package/dist/dashboard/assets/providers/cursor.webp +0 -0
- package/dist/dashboard/assets/providers/exa.svg.gz +0 -0
- package/dist/dashboard/assets/providers/gmi-cloud.svg.gz +0 -0
- package/dist/dashboard/assets/providers/inferhub.svg.gz +0 -0
- package/dist/dashboard/assets/providers/muse.svg.gz +0 -0
- package/dist/dashboard/assets/providers/nous-research.svg.gz +0 -0
- package/dist/dashboard/assets/providers/openai-light.svg.gz +0 -0
- package/dist/dashboard/assets/providers/parallel.svg.gz +0 -0
- package/dist/dashboard/assets/providers/tokenharbor.svg.gz +0 -0
- package/dist/dashboard/assets/providers/trae.webp +0 -0
- package/dist/dashboard/assets/providers/zai-coding-plan.svg.gz +0 -0
- package/dist/dashboard/assets/{query-keys-ROf-g1Wi.js → query-keys-D7wX2vM5.js} +1 -1
- package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
- package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
- package/dist/dashboard/assets/refresh-cw-BqJFk4sU.js.gz +0 -0
- package/dist/dashboard/assets/rotate-ccw-CufcqITi.js.gz +0 -0
- package/dist/dashboard/assets/route-CNSNj9Zm.js.gz +0 -0
- package/dist/dashboard/assets/routing-Cs6jl49L.js +1 -0
- package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
- package/dist/dashboard/assets/scaling-nWE4Cx8f.js.gz +0 -0
- package/dist/dashboard/assets/search-JOKus9rb.js.gz +0 -0
- package/dist/dashboard/assets/select-C0Rer3gF.js +41 -0
- package/dist/dashboard/assets/select-C0Rer3gF.js.gz +0 -0
- package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
- package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
- package/dist/dashboard/assets/shield-check-DVS0Foqh.js.gz +0 -0
- package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
- package/dist/dashboard/assets/{stack-C1kTcOmP.js → stack-C9_uUjaA.js} +1 -1
- package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
- package/dist/dashboard/assets/{state-DHKdg-pm.js → state-DA0B95UQ.js} +1 -1
- package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
- package/dist/dashboard/assets/stream-retry-5QnVuSSo.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DIDeAiz9.js → switch-Cittxy-x.js} +1 -1
- package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js +1 -0
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
- package/dist/dashboard/assets/toast-CNt_jzO4.js +2 -0
- package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
- package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
- package/dist/dashboard/assets/trash-Cppn5c3R.js.gz +0 -0
- package/dist/dashboard/assets/use-clipboard-B0Sy03fE.js.gz +0 -0
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
- package/dist/dashboard/assets/use-reduced-motion-C4B0R6Sz.js.gz +0 -0
- package/dist/dashboard/assets/use-timeout-BwcBBRrT.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-DVmwOKU-.js → useMutation-BQKMcJ1H.js} +1 -1
- package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
- package/dist/dashboard/assets/wrench-B6E1oEuY.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 +122 -0
- package/migrations/full-postgres/0013_add_providers_tenant_id_index.sql +22 -0
- package/migrations/full-postgres/0014_add_provider_accounts_tenant_id_index.sql +21 -0
- package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
- package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
- package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
- package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
- package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
- package/migrations/lite-sqlite3/0000_baseline.sql +4 -2
- package/migrations/lite-sqlite3/0013_add_providers_tenant_id_index.sql +4 -0
- package/migrations/lite-sqlite3/0014_add_provider_accounts_tenant_id_index.sql +4 -0
- package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
- package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
- package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
- package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
- package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
- package/package.json +26 -30
- package/scripts/cli/{carte-launcher.cjs → bin/carte.cjs} +8 -8
- package/scripts/cli/{cli-launcher.cjs → bin/cartethyia.cjs} +167 -134
- package/scripts/cli/command/admin.ts +65 -0
- package/scripts/cli/command/console-menu.ts +246 -0
- package/scripts/cli/{doctor.ts → command/doctor.ts} +257 -258
- package/scripts/cli/{setup.ts → command/setup.ts} +8 -12
- package/scripts/cli/command/start.ts +77 -0
- package/scripts/cli/command/tray.ts +165 -0
- package/scripts/cli/command/update.ts +23 -0
- package/scripts/cli/{runtime-state.ts → gateway/drain-token.ts} +78 -78
- package/scripts/cli/gateway/instance-lock.ts +105 -0
- package/scripts/cli/gateway/spawn.ts +173 -0
- package/scripts/cli/gateway/stop.ts +51 -0
- package/scripts/cli/main.ts +94 -0
- package/scripts/cli/support/bun.ts +27 -0
- package/scripts/cli/support/env.ts +99 -0
- package/scripts/cli/support/platform.ts +64 -0
- package/scripts/cli/support/services.ts +88 -0
- package/scripts/cli/support/startup-signal.ts +23 -0
- package/scripts/cli/{version.ts → support/version.ts} +1 -1
- package/scripts/cli/tray/hidden-window.vbs +28 -0
- package/scripts/cli/{tray.ts → tray/index.ts} +3 -3
- package/scripts/cli/{tray.ps1 → tray/windows.ps1} +107 -98
- package/scripts/cli/ui/background.ts +110 -0
- package/src/boot/app.ts +23 -7
- package/src/boot/dependencies.ts +55 -13
- package/src/boot/elysia-typebox-bridge.ts +37 -0
- package/src/boot/lifecycle.ts +2 -0
- package/src/config/cooldowns.ts +138 -138
- package/src/config/providers.ts +31 -5
- package/src/config/runtime.ts +19 -3
- package/src/config/spec.ts +20 -0
- package/src/console/api-keys/contracts.ts +414 -391
- package/src/console/api-keys/routes.ts +880 -870
- package/src/console/auth/account-reset-store.ts +14 -0
- package/src/console/auth/account-reset.ts +3 -2
- package/src/console/auth/setup-service.ts +6 -3
- package/src/console/backup/routes.ts +8 -0
- package/src/console/cli-tools/contracts.ts +6 -6
- package/src/console/console-router.ts +5 -3
- package/src/console/dashboard-assets.ts +81 -11
- package/src/console/domain-registration.ts +9 -2
- package/src/console/observability/contracts.ts +475 -423
- package/src/console/observability/routes.ts +31 -6
- package/src/console/observability/store.ts +1015 -912
- package/src/console/providers/catalog/contracts.ts +31 -0
- package/src/console/providers/catalog/provider-operations.ts +8 -0
- package/src/console/providers/catalog/public-model-store.ts +91 -60
- package/src/console/providers/catalog/store.ts +1532 -1521
- package/src/console/providers/oauth/callback-listener.ts +18 -3
- package/src/console/providers/oauth/routes.ts +38 -15
- package/src/console/quota/account-quota-tenant.ts +9 -6
- package/src/console/quota/refresh.ts +19 -0
- package/src/console/response-compression.ts +69 -2
- package/src/console/routing/pools/probe-verdict.ts +72 -0
- package/src/console/share/share-router.ts +7 -4
- package/src/console/share/share-stats.ts +12 -5
- package/src/console/shared/read-through-cache.ts +133 -7
- package/src/console/updates/repository-updates.ts +39 -34
- package/src/console/updates/routes.ts +162 -4
- package/src/main.ts +3 -0
- package/src/network/http2-bidi.ts +85 -0
- package/src/network/outbound-fetch.ts +87 -24
- package/src/network/pool/agent.ts +478 -460
- package/src/network/pool/resolver.ts +1104 -544
- package/src/network/pool-health-machine.ts +12 -5
- package/src/observability/event-log.ts +29 -8
- package/src/observability/metrics.ts +40 -13
- package/src/observability/payload-capture.ts +344 -324
- package/src/observability/runtime-metrics.ts +7 -0
- package/src/persistence/db-pg.ts +9 -3
- package/src/persistence/db-sqlite.ts +497 -495
- package/src/persistence/health-event-flush.ts +137 -0
- package/src/persistence/migrate.ts +181 -129
- package/src/persistence/schema.ts +1360 -1257
- package/src/persistence/settings-revision.ts +25 -0
- package/src/persistence/shared-cache.ts +217 -0
- package/src/persistence/stores/share-store.ts +556 -528
- package/src/persistence/usage-flush.ts +61 -1
- package/src/protocol/canonical-model.ts +23 -1
- package/src/protocol/response/chat.ts +143 -10
- package/src/protocol/response/codex.ts +9 -75
- package/src/protocol/response/omp-tool-call-markup.ts +221 -0
- package/src/protocol/tool-identity.ts +43 -9
- package/src/providers/authentication/jwt-validator.ts +130 -187
- package/src/providers/authentication/oauth-flow-store.ts +16 -9
- package/src/providers/default-registry.ts +177 -1
- package/src/providers/discovery/catalog/catalog-format.ts +20 -0
- package/src/providers/discovery/models-dev-catalog.ts +39 -22
- package/src/providers/discovery/openai-model-discovery.ts +39 -3
- package/src/providers/discovery/openrouter-free-roster.ts +98 -0
- package/src/providers/discovery/probe-wire.ts +29 -5
- package/src/providers/discovery/probing-service.ts +1022 -1001
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +42 -2
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
- package/src/providers/integrations/buddy/buddy-cli-headers.ts +88 -0
- package/src/providers/integrations/buddy/buddy-conversation-headers.ts +22 -5
- package/src/providers/integrations/buddy/buddy-discovery-shared.ts +33 -1
- package/src/providers/integrations/buddy/buddy-identity.ts +46 -0
- package/src/providers/integrations/buddy/buddy-oauth-shared.ts +37 -1
- package/src/providers/integrations/buddy/buddy-usage-store.ts +40 -5
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
- package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
- package/src/providers/integrations/buddy/codebuddy/shared.ts +115 -73
- package/src/providers/integrations/buddy/workbuddy/cn-provider.ts +5 -2
- package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
- package/src/providers/integrations/buddy/workbuddy/quota.ts +172 -159
- package/src/providers/integrations/buddy/workbuddy/shared.ts +200 -171
- package/src/providers/integrations/claude/claude.ts +606 -582
- package/src/providers/integrations/cline/cline-quota.ts +18 -16
- package/src/providers/integrations/cline/cline.ts +539 -468
- package/src/providers/integrations/codex/codex.ts +5 -8
- package/src/providers/integrations/commandcode/commandcode.ts +675 -541
- package/src/providers/integrations/commandcode/discovery.ts +116 -0
- package/src/providers/integrations/commandcode/quota.ts +160 -0
- package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
- package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
- package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
- package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
- package/src/providers/integrations/cursor/cursor-request.ts +471 -0
- package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
- package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
- package/src/providers/integrations/cursor/cursor.ts +960 -0
- package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
- package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
- package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
- package/src/providers/integrations/factory/factory-oauth.ts +7 -11
- package/src/providers/integrations/github/github.ts +4 -4
- package/src/providers/integrations/hermes.ts +79 -8
- package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
- package/src/providers/integrations/kilo/kilo.ts +153 -146
- package/src/providers/integrations/opencode/opencode-go-quota.ts +130 -0
- package/src/providers/integrations/opencode/provider.ts +532 -499
- package/src/providers/integrations/openrouter/provider.ts +139 -281
- package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
- package/src/providers/integrations/qoder/qoder-oauth.ts +310 -263
- package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
- package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
- package/src/providers/integrations/qoder/qoder.ts +865 -730
- package/src/providers/integrations/shared/install-id.ts +29 -2
- package/src/providers/integrations/trae/trae-catalog.ts +190 -0
- package/src/providers/integrations/trae/trae-headers.ts +149 -0
- package/src/providers/integrations/trae/trae-oauth.ts +773 -0
- package/src/providers/integrations/trae/trae-protocol.ts +400 -0
- package/src/providers/integrations/trae/trae-realm.ts +220 -0
- package/src/providers/integrations/trae/trae.ts +458 -0
- package/src/providers/operations/account-health-service.ts +107 -10
- package/src/providers/operations/client-versions.ts +143 -6
- package/src/providers/operations/model-catalog-cache.ts +18 -3
- package/src/providers/operations/provider-catalog-seeder.ts +95 -86
- package/src/providers/operations/provider-catalog-service.ts +76 -0
- package/src/providers/operations/provider-credential-service.ts +46 -7
- package/src/providers/provider-contracts.ts +76 -0
- package/src/providers/provider-metadata.ts +78 -6
- package/src/providers/provider-model-definition.ts +373 -349
- package/src/security/api-key-auth.ts +126 -8
- package/src/transport/contracts.ts +3 -0
- package/src/transport/dispatch/attempt-finalize.ts +39 -2
- package/src/transport/dispatch/attempt-loop.ts +530 -513
- package/src/transport/dispatch/empty-stream.ts +17 -26
- package/src/transport/dispatch/generation.ts +88 -21
- package/src/transport/dispatch/leases.ts +99 -2
- package/src/transport/dispatch/proxy-request.ts +60 -3
- package/src/transport/dispatch/streaming/finalize.ts +16 -1
- package/src/transport/dispatch/streaming/stream.ts +22 -2
- package/src/transport/dispatch/upstream.ts +19 -1
- package/src/transport/failure-policy.ts +20 -0
- package/src/transport/guards/auth-guard.ts +26 -1
- package/src/transport/guards/body-policy.ts +5 -4
- package/src/transport/request/preparer.ts +103 -0
- package/src/transport/request/state.ts +14 -0
- package/src/transport/routing/plan-gate.ts +45 -0
- package/src/transport/routing/route-catalog.ts +799 -738
- package/src/transport/routing/route-model.ts +52 -1
- package/src/transport/routing/router.ts +51 -0
- package/src/transport/surface/chat/encode.ts +18 -31
- package/src/transport/surface/messages/stream.ts +3 -2
- package/src/transport/translation/tool-repair.ts +37 -16
- package/src/workers/account-identity-sweep.ts +14 -12
- package/src/workers/daily-checkin.ts +62 -0
- package/src/workers/quota-refresh-sweep.ts +27 -2
- package/dist/dashboard/assets/BannedPage-DUrXpp-X.js +0 -1
- package/dist/dashboard/assets/CombosPage-B7ETkfVH.js +0 -1
- package/dist/dashboard/assets/ConsoleLogPage-AisQzCI1.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-BNflDm_f.js +0 -1
- package/dist/dashboard/assets/ModelPicker-CAiPR5Vd.js +0 -1
- package/dist/dashboard/assets/OverviewPage-C3NIzsDx.js +0 -1
- package/dist/dashboard/assets/ProviderDetailPage-BK8i5B5X.js +0 -3
- package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-CvCVgm1e.js +0 -1
- package/dist/dashboard/assets/ProxyPage-DjabkuzP.js +0 -7
- package/dist/dashboard/assets/QuotaPage-Dxe0B0iu.js +0 -1
- package/dist/dashboard/assets/SetupPage-BaD4P_jW.js +0 -1
- package/dist/dashboard/assets/StudioPage-CLP4le3U.js +0 -31
- package/dist/dashboard/assets/UsagePage-Djes-Epy.js +0 -56
- package/dist/dashboard/assets/api-keys-CjIGQhe7.js +0 -1
- package/dist/dashboard/assets/app-Cjgo4-Lf.css +0 -2
- package/dist/dashboard/assets/assert-Bwh6DJTK.js +0 -1
- package/dist/dashboard/assets/cli-tools-DccaOfhX.js +0 -1
- package/dist/dashboard/assets/clipboard-button-DQmMikNQ.js +0 -1
- package/dist/dashboard/assets/entry-Cpi0rph1.js +0 -4
- package/dist/dashboard/assets/entry-Dm3PpDCO.js +0 -1
- package/dist/dashboard/assets/entry-keNDZ_Wz.js +0 -1
- package/dist/dashboard/assets/github-badge-BuAJr8oU.js +0 -1
- package/dist/dashboard/assets/model-bans-CVvHZ-qz.js +0 -1
- package/dist/dashboard/assets/mutation-BIizFZav.js +0 -1
- package/dist/dashboard/assets/privacy-mode-CQjbZ3xk.js +0 -1
- package/dist/dashboard/assets/routing-jntw9-Ui.js +0 -1
- package/dist/dashboard/assets/select-CGUuTQmj.js +0 -41
- package/dist/dashboard/assets/settings-BAS6QbmJ.js +0 -1
- package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
- package/dist/dashboard/assets/terminal-C9mZ4Ugm.js +0 -1
- package/dist/dashboard/assets/toast-DKAdP2mo.js +0 -2
- package/scripts/cli/cli.ts +0 -771
- package/scripts/operations/probe-egress.ts +0 -335
- package/scripts/operations/restart.ts +0 -120
- package/scripts/shared/env.ts +0 -269
- /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.ico +0 -0
- /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.png +0 -0
- /package/scripts/cli/{menu.ts → ui/prompt.ts} +0 -0
- /package/scripts/cli/{wordmark.ts → ui/wordmark.ts} +0 -0
|
@@ -1,1257 +1,1360 @@
|
|
|
1
|
-
/** Canonical Drizzle schema: every persistence table, enum, index, and shared type. */
|
|
2
|
-
import { sql } from "drizzle-orm";
|
|
3
|
-
import {
|
|
4
|
-
bigint, boolean, check, customType, index, integer, jsonb, numeric, pgEnum, pgTable,
|
|
5
|
-
primaryKey, text, timestamp, uniqueIndex, uuid, type AnyPgColumn,
|
|
6
|
-
} from "drizzle-orm/pg-core";
|
|
7
|
-
|
|
8
|
-
export const TENANT_STATUSES = ["active", "suspended", "deleted"] as const;
|
|
9
|
-
export type TenantStatus = (typeof TENANT_STATUSES)[number];
|
|
10
|
-
|
|
11
|
-
export const tenants = pgTable(
|
|
12
|
-
"tenants",
|
|
13
|
-
{
|
|
14
|
-
id: uuid("id").primaryKey().defaultRandom(),
|
|
15
|
-
name: text("name").notNull(),
|
|
16
|
-
status: text("status").$type<TenantStatus>().notNull(),
|
|
17
|
-
// Intentionally inline (not createdAtColumn()): this root table is imported
|
|
18
|
-
// by the helpers below; sharing the helper here would make a use-before-define.
|
|
19
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
20
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
21
|
-
},
|
|
22
|
-
(table) => [
|
|
23
|
-
check("tenants_status_check", sql`${table.status} IN ('active', 'suspended', 'deleted')`),
|
|
24
|
-
],
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
// Canonical tenant/timestamp column definitions shared by every table.
|
|
28
|
-
|
|
29
|
-
export function tenantRefNullable() {
|
|
30
|
-
return uuid("tenant_id").references(() => tenants.id, { onDelete: "cascade" });
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function tenantRefRequired() {
|
|
34
|
-
return uuid("tenant_id")
|
|
35
|
-
.notNull()
|
|
36
|
-
.references(() => tenants.id, { onDelete: "cascade" });
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function tenantRefPrimaryKey() {
|
|
40
|
-
return uuid("tenant_id")
|
|
41
|
-
.primaryKey()
|
|
42
|
-
.references(() => tenants.id, { onDelete: "cascade" });
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function tenantCascadeSetNull() {
|
|
46
|
-
return uuid("tenant_id").references(() => tenants.id, { onDelete: "set null" });
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function createdAtColumn() {
|
|
50
|
-
return timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function updatedAtColumn() {
|
|
54
|
-
return timestamp("updated_at", { withTimezone: true }).notNull().defaultNow();
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function timestampColumns() {
|
|
58
|
-
return { createdAt: createdAtColumn(), updatedAt: updatedAtColumn() };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* `bytea` column for encrypted credential ciphertext (application-layer
|
|
63
|
-
* envelope encryption: the ciphertext stored here is opaque to the
|
|
64
|
-
* database). Drizzle's pg-core does not ship a first-class `bytea`
|
|
65
|
-
* builder, so this is the documented `customType` escape hatch (the same
|
|
66
|
-
* DDL-shape escape-hatch principle applied to column-type gaps in the
|
|
67
|
-
* ORM's builder).
|
|
68
|
-
*/
|
|
69
|
-
export const bytea = customType<{ data: Buffer }>({
|
|
70
|
-
dataType() {
|
|
71
|
-
return "bytea";
|
|
72
|
-
},
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
// Shared three-state health machine for provider_accounts and network_pools —
|
|
76
|
-
// the account state machine is reused for network pools rather than
|
|
77
|
-
// inventing a second one.
|
|
78
|
-
//
|
|
79
|
-
// `degraded` was retired. It sat between "working" and "parked" and every
|
|
80
|
-
// consumer had to decide what it meant: routing treated it as a hard exclusion,
|
|
81
|
-
// the sweep treated it as recoverable, and the console showed it as a third
|
|
82
|
-
// badge — three readings of one value, and the operator reported the result as
|
|
83
|
-
// ambiguous. The two facts it mixed are now each stated by a value that means
|
|
84
|
-
// exactly one thing: a fault that clears on its own is `cooldown` (with a
|
|
85
|
-
// deadline the sweep honors), and a fault that needs an operator is `disabled`.
|
|
86
|
-
export const healthStatus = pgEnum("health_status", ["active", "cooldown", "disabled"]);
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* The health state machine shared by `provider_accounts` and `network_pools`.
|
|
90
|
-
* One definition, because the two tables are driven by the same transitions and
|
|
91
|
-
* a column added to one of them by hand is how they drift.
|
|
92
|
-
*/
|
|
93
|
-
export function healthColumns() {
|
|
94
|
-
return {
|
|
95
|
-
status: healthStatus("status").notNull().default("active"),
|
|
96
|
-
consecutiveFailures: integer("consecutive_failures").notNull().default(0),
|
|
97
|
-
lastSuccessAt: timestamp("last_success_at", { withTimezone: true }),
|
|
98
|
-
lastError: text("last_error"),
|
|
99
|
-
lastErrorCategory: text("last_error_category"),
|
|
100
|
-
lastErrorAt: timestamp("last_error_at", { withTimezone: true }),
|
|
101
|
-
cooldownUntil: timestamp("cooldown_until", { withTimezone: true }),
|
|
102
|
-
lastRecoveredAt: timestamp("last_recovered_at", { withTimezone: true }),
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export const credentialKind = pgEnum("credential_kind", ["api_key", "oauth", "none"]);
|
|
107
|
-
|
|
108
|
-
// Shared wire-family domain used by providers.wire_family_default and
|
|
109
|
-
// models.wire_family.
|
|
110
|
-
//
|
|
111
|
-
// `gemini` is Factory's native generateContent wire. Added by forward
|
|
112
|
-
// migration `0002_add_gemini_wire_family.sql`; the enum here must stay in
|
|
113
|
-
// step with what that migration applies, or a fresh database (built from the
|
|
114
|
-
// baseline) and a migrated one would disagree.
|
|
115
|
-
export const wireFamily = pgEnum("wire_family", ["chat", "responses", "messages", "gemini"]);
|
|
116
|
-
|
|
117
|
-
// `https` is a hosted relay front door (Vercel, Cloudflare, Deno, Netlify):
|
|
118
|
-
// plain HTTP CONNECT to an origin that terminates TLS itself, so the transport
|
|
119
|
-
// is recorded as what it speaks rather than as a generic proxy.
|
|
120
|
-
export const networkPoolKind = pgEnum("network_pool_kind", ["http", "https", "socks5"]);
|
|
121
|
-
|
|
122
|
-
// health_events.entity_kind — distinguishes which owning
|
|
123
|
-
// table entity_id/account_id/network_pool_id references.
|
|
124
|
-
export const healthEntityKind = pgEnum("health_entity_kind", ["account", "pool"]);
|
|
125
|
-
|
|
126
|
-
export const telemetrySourceSurface = pgEnum("telemetry_source_surface", [
|
|
127
|
-
"chat",
|
|
128
|
-
"responses",
|
|
129
|
-
"messages",
|
|
130
|
-
"completion",
|
|
131
|
-
]);
|
|
132
|
-
|
|
133
|
-
export const telemetryStatus = pgEnum("telemetry_status", [
|
|
134
|
-
"completed",
|
|
135
|
-
"failed",
|
|
136
|
-
"cancelled",
|
|
137
|
-
"truncated",
|
|
138
|
-
]);
|
|
139
|
-
|
|
140
|
-
// The id matches the seven canonical builtin IDs (openai, anthropic, claude,
|
|
141
|
-
// codex, opencode-zen, ollamacloud, cerebras) or a custom/BYOK slug
|
|
142
|
-
// rejected at the identity boundary when it collides case-insensitively with
|
|
143
|
-
// a reserved ID — that rejection is application-layer, not a DB constraint
|
|
144
|
-
// here. `capability_profile` is a JSONB routing-capability map with no index:
|
|
145
|
-
// it is read with the provider row the snapshot builder already loads, so a
|
|
146
|
-
// separate index would only add write cost. `tenant_id`
|
|
147
|
-
// null means the provider is global/built-in, matching the same convention
|
|
148
|
-
// as `provider_accounts.tenant_id`; populated means tenant-owned/BYOK.
|
|
149
|
-
// `base_url`/`compatibility_profile` are BYOK-only: `compatibility_profile`
|
|
150
|
-
// carries HTTP dispatch config (extra headers/query params/streaming mode)
|
|
151
|
-
// and is intentionally a separate column from `capability_profile`, which
|
|
152
|
-
// drives routing capability flags — conflating the two would corrupt routing
|
|
153
|
-
// data with unrelated HTTP config.
|
|
154
|
-
export const providers = pgTable(
|
|
155
|
-
"providers",
|
|
156
|
-
{
|
|
157
|
-
id: text("id").primaryKey(),
|
|
158
|
-
tenantId: tenantRefNullable(),
|
|
159
|
-
wireFamilyDefault: wireFamily("wire_family_default"),
|
|
160
|
-
capabilityProfile: jsonb("capability_profile"),
|
|
161
|
-
baseUrl: text("base_url"),
|
|
162
|
-
compatibilityProfile: jsonb("compatibility_profile"),
|
|
163
|
-
enabled: boolean("enabled").notNull().default(true),
|
|
164
|
-
// Builtin default `true` (most providers need a real credential). Only a
|
|
165
|
-
// genuinely public/unauthenticated builtin (OpenCode Free) is seeded
|
|
166
|
-
// `false`. When `false`, the snapshot builder emits a routable candidate
|
|
167
|
-
// with zero `provider_accounts` rows instead of marking it `disabled` —
|
|
168
|
-
// see `src/transport/routing/route-catalog.ts`.
|
|
169
|
-
requiresAccount: boolean("requires_account").notNull().default(true),
|
|
170
|
-
},
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
-
// `tenant_id
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
* which
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
),
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
)
|
|
351
|
-
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
)
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
.
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
)
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
),
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
*
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
)
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
)
|
|
1
|
+
/** Canonical Drizzle schema: every persistence table, enum, index, and shared type. */
|
|
2
|
+
import { desc, sql } from "drizzle-orm";
|
|
3
|
+
import {
|
|
4
|
+
bigint, boolean, check, customType, index, integer, jsonb, numeric, pgEnum, pgTable,
|
|
5
|
+
primaryKey, text, timestamp, uniqueIndex, uuid, type AnyPgColumn,
|
|
6
|
+
} from "drizzle-orm/pg-core";
|
|
7
|
+
|
|
8
|
+
export const TENANT_STATUSES = ["active", "suspended", "deleted"] as const;
|
|
9
|
+
export type TenantStatus = (typeof TENANT_STATUSES)[number];
|
|
10
|
+
|
|
11
|
+
export const tenants = pgTable(
|
|
12
|
+
"tenants",
|
|
13
|
+
{
|
|
14
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
15
|
+
name: text("name").notNull(),
|
|
16
|
+
status: text("status").$type<TenantStatus>().notNull(),
|
|
17
|
+
// Intentionally inline (not createdAtColumn()): this root table is imported
|
|
18
|
+
// by the helpers below; sharing the helper here would make a use-before-define.
|
|
19
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
20
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
21
|
+
},
|
|
22
|
+
(table) => [
|
|
23
|
+
check("tenants_status_check", sql`${table.status} IN ('active', 'suspended', 'deleted')`),
|
|
24
|
+
],
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// Canonical tenant/timestamp column definitions shared by every table.
|
|
28
|
+
|
|
29
|
+
export function tenantRefNullable() {
|
|
30
|
+
return uuid("tenant_id").references(() => tenants.id, { onDelete: "cascade" });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function tenantRefRequired() {
|
|
34
|
+
return uuid("tenant_id")
|
|
35
|
+
.notNull()
|
|
36
|
+
.references(() => tenants.id, { onDelete: "cascade" });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function tenantRefPrimaryKey() {
|
|
40
|
+
return uuid("tenant_id")
|
|
41
|
+
.primaryKey()
|
|
42
|
+
.references(() => tenants.id, { onDelete: "cascade" });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function tenantCascadeSetNull() {
|
|
46
|
+
return uuid("tenant_id").references(() => tenants.id, { onDelete: "set null" });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createdAtColumn() {
|
|
50
|
+
return timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function updatedAtColumn() {
|
|
54
|
+
return timestamp("updated_at", { withTimezone: true }).notNull().defaultNow();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function timestampColumns() {
|
|
58
|
+
return { createdAt: createdAtColumn(), updatedAt: updatedAtColumn() };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `bytea` column for encrypted credential ciphertext (application-layer
|
|
63
|
+
* envelope encryption: the ciphertext stored here is opaque to the
|
|
64
|
+
* database). Drizzle's pg-core does not ship a first-class `bytea`
|
|
65
|
+
* builder, so this is the documented `customType` escape hatch (the same
|
|
66
|
+
* DDL-shape escape-hatch principle applied to column-type gaps in the
|
|
67
|
+
* ORM's builder).
|
|
68
|
+
*/
|
|
69
|
+
export const bytea = customType<{ data: Buffer }>({
|
|
70
|
+
dataType() {
|
|
71
|
+
return "bytea";
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Shared three-state health machine for provider_accounts and network_pools —
|
|
76
|
+
// the account state machine is reused for network pools rather than
|
|
77
|
+
// inventing a second one.
|
|
78
|
+
//
|
|
79
|
+
// `degraded` was retired. It sat between "working" and "parked" and every
|
|
80
|
+
// consumer had to decide what it meant: routing treated it as a hard exclusion,
|
|
81
|
+
// the sweep treated it as recoverable, and the console showed it as a third
|
|
82
|
+
// badge — three readings of one value, and the operator reported the result as
|
|
83
|
+
// ambiguous. The two facts it mixed are now each stated by a value that means
|
|
84
|
+
// exactly one thing: a fault that clears on its own is `cooldown` (with a
|
|
85
|
+
// deadline the sweep honors), and a fault that needs an operator is `disabled`.
|
|
86
|
+
export const healthStatus = pgEnum("health_status", ["active", "cooldown", "disabled"]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The health state machine shared by `provider_accounts` and `network_pools`.
|
|
90
|
+
* One definition, because the two tables are driven by the same transitions and
|
|
91
|
+
* a column added to one of them by hand is how they drift.
|
|
92
|
+
*/
|
|
93
|
+
export function healthColumns() {
|
|
94
|
+
return {
|
|
95
|
+
status: healthStatus("status").notNull().default("active"),
|
|
96
|
+
consecutiveFailures: integer("consecutive_failures").notNull().default(0),
|
|
97
|
+
lastSuccessAt: timestamp("last_success_at", { withTimezone: true }),
|
|
98
|
+
lastError: text("last_error"),
|
|
99
|
+
lastErrorCategory: text("last_error_category"),
|
|
100
|
+
lastErrorAt: timestamp("last_error_at", { withTimezone: true }),
|
|
101
|
+
cooldownUntil: timestamp("cooldown_until", { withTimezone: true }),
|
|
102
|
+
lastRecoveredAt: timestamp("last_recovered_at", { withTimezone: true }),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const credentialKind = pgEnum("credential_kind", ["api_key", "oauth", "none"]);
|
|
107
|
+
|
|
108
|
+
// Shared wire-family domain used by providers.wire_family_default and
|
|
109
|
+
// models.wire_family.
|
|
110
|
+
//
|
|
111
|
+
// `gemini` is Factory's native generateContent wire. Added by forward
|
|
112
|
+
// migration `0002_add_gemini_wire_family.sql`; the enum here must stay in
|
|
113
|
+
// step with what that migration applies, or a fresh database (built from the
|
|
114
|
+
// baseline) and a migrated one would disagree.
|
|
115
|
+
export const wireFamily = pgEnum("wire_family", ["chat", "responses", "messages", "gemini"]);
|
|
116
|
+
|
|
117
|
+
// `https` is a hosted relay front door (Vercel, Cloudflare, Deno, Netlify):
|
|
118
|
+
// plain HTTP CONNECT to an origin that terminates TLS itself, so the transport
|
|
119
|
+
// is recorded as what it speaks rather than as a generic proxy.
|
|
120
|
+
export const networkPoolKind = pgEnum("network_pool_kind", ["http", "https", "socks5"]);
|
|
121
|
+
|
|
122
|
+
// health_events.entity_kind — distinguishes which owning
|
|
123
|
+
// table entity_id/account_id/network_pool_id references.
|
|
124
|
+
export const healthEntityKind = pgEnum("health_entity_kind", ["account", "pool"]);
|
|
125
|
+
|
|
126
|
+
export const telemetrySourceSurface = pgEnum("telemetry_source_surface", [
|
|
127
|
+
"chat",
|
|
128
|
+
"responses",
|
|
129
|
+
"messages",
|
|
130
|
+
"completion",
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
export const telemetryStatus = pgEnum("telemetry_status", [
|
|
134
|
+
"completed",
|
|
135
|
+
"failed",
|
|
136
|
+
"cancelled",
|
|
137
|
+
"truncated",
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
// The id matches the seven canonical builtin IDs (openai, anthropic, claude,
|
|
141
|
+
// codex, opencode-zen, ollamacloud, cerebras) or a custom/BYOK slug
|
|
142
|
+
// rejected at the identity boundary when it collides case-insensitively with
|
|
143
|
+
// a reserved ID — that rejection is application-layer, not a DB constraint
|
|
144
|
+
// here. `capability_profile` is a JSONB routing-capability map with no index:
|
|
145
|
+
// it is read with the provider row the snapshot builder already loads, so a
|
|
146
|
+
// separate index would only add write cost. `tenant_id`
|
|
147
|
+
// null means the provider is global/built-in, matching the same convention
|
|
148
|
+
// as `provider_accounts.tenant_id`; populated means tenant-owned/BYOK.
|
|
149
|
+
// `base_url`/`compatibility_profile` are BYOK-only: `compatibility_profile`
|
|
150
|
+
// carries HTTP dispatch config (extra headers/query params/streaming mode)
|
|
151
|
+
// and is intentionally a separate column from `capability_profile`, which
|
|
152
|
+
// drives routing capability flags — conflating the two would corrupt routing
|
|
153
|
+
// data with unrelated HTTP config.
|
|
154
|
+
export const providers = pgTable(
|
|
155
|
+
"providers",
|
|
156
|
+
{
|
|
157
|
+
id: text("id").primaryKey(),
|
|
158
|
+
tenantId: tenantRefNullable(),
|
|
159
|
+
wireFamilyDefault: wireFamily("wire_family_default"),
|
|
160
|
+
capabilityProfile: jsonb("capability_profile"),
|
|
161
|
+
baseUrl: text("base_url"),
|
|
162
|
+
compatibilityProfile: jsonb("compatibility_profile"),
|
|
163
|
+
enabled: boolean("enabled").notNull().default(true),
|
|
164
|
+
// Builtin default `true` (most providers need a real credential). Only a
|
|
165
|
+
// genuinely public/unauthenticated builtin (OpenCode Free) is seeded
|
|
166
|
+
// `false`. When `false`, the snapshot builder emits a routable candidate
|
|
167
|
+
// with zero `provider_accounts` rows instead of marking it `disabled` —
|
|
168
|
+
// see `src/transport/routing/route-catalog.ts`.
|
|
169
|
+
requiresAccount: boolean("requires_account").notNull().default(true),
|
|
170
|
+
},
|
|
171
|
+
(table) => [
|
|
172
|
+
// `globalOrOwnedBy(providers.tenantId, …)` is the predicate behind
|
|
173
|
+
// `listModelsForTenant` and `/quota/overview`, and it is a bare
|
|
174
|
+
// `tenant_id IS NULL OR tenant_id = ?` — which no other index here can
|
|
175
|
+
// serve, so both reads scanned the whole provider list. Cheap to keep:
|
|
176
|
+
// providers are few and written rarely.
|
|
177
|
+
index("providers_tenant_id_idx").on(table.tenantId),
|
|
178
|
+
],
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// Upstream account credentials plus the full health state machine.
|
|
182
|
+
// `tenant_id` null means the account is shared pool-wide; populated means
|
|
183
|
+
// tenant-owned/BYOK. The concurrency ceiling lives in provider routing
|
|
184
|
+
// (`provider_routing_settings.max_inflight`) and network pools
|
|
185
|
+
// (`network_pools.max_inflight`); the per-account override no longer exists.
|
|
186
|
+
export const providerAccounts = pgTable("provider_accounts", {
|
|
187
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
188
|
+
providerId: text("provider_id")
|
|
189
|
+
.notNull()
|
|
190
|
+
.references(() => providers.id, { onDelete: "cascade" }),
|
|
191
|
+
tenantId: tenantRefNullable(),
|
|
192
|
+
label: text("label").notNull(),
|
|
193
|
+
credentialCiphertext: bytea("credential_ciphertext"),
|
|
194
|
+
/**
|
|
195
|
+
* HMAC fingerprint of the credential, never the plaintext secret. Nullable
|
|
196
|
+
* for credential-less accounts and legacy rows without an identity.
|
|
197
|
+
*/
|
|
198
|
+
credentialFingerprint: text("credential_fingerprint"),
|
|
199
|
+
credentialKind: credentialKind("credential_kind").notNull(),
|
|
200
|
+
/**
|
|
201
|
+
* Per-account upstream auth configuration that is not a secret: auth method,
|
|
202
|
+
* region, profile ARN, OAuth client id, token endpoint, scopes. The adapter
|
|
203
|
+
* reads it where the credential is resolved (it selects the regional endpoint
|
|
204
|
+
* and the profile the upstream binds the request to) and token refresh reads
|
|
205
|
+
* it again to pick the right endpoint and client. Secrets never live here —
|
|
206
|
+
* see `credential_ciphertext` and `provider_oauth_states`.
|
|
207
|
+
*/
|
|
208
|
+
authState: jsonb("auth_state"),
|
|
209
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
210
|
+
|
|
211
|
+
...healthColumns(),
|
|
212
|
+
modelCooldowns: jsonb("model_cooldowns").notNull().default({}),
|
|
213
|
+
/**
|
|
214
|
+
* How many times each model has been reported unsupported for this account:
|
|
215
|
+
* `{ [modelId]: hits }`. Written only by the unsupported-model path, and it
|
|
216
|
+
* is the escalation level for that pair's cooldown.
|
|
217
|
+
*
|
|
218
|
+
* Deliberately a separate column from `model_cooldowns`: that value is
|
|
219
|
+
* `{ [modelId]: deadline }`, read by routing (`route-catalog.ts`) and by the
|
|
220
|
+
* console, and widening it to `{until, hits}` would have changed a shape two
|
|
221
|
+
* independent readers parse. Nullable — an account that never hit an
|
|
222
|
+
* unsupported model has nothing to count.
|
|
223
|
+
*/
|
|
224
|
+
modelBlockHits: jsonb("model_block_hits").$type<Readonly<Record<string, number>>>(),
|
|
225
|
+
/**
|
|
226
|
+
* Stable list position within (tenant, provider). Accounts were ordered by
|
|
227
|
+
* `created_at`, which let two same-millisecond rows swap between loads; this
|
|
228
|
+
* is explicit and can be rewritten when the operator reorders or appends.
|
|
229
|
+
*/
|
|
230
|
+
sortIndex: integer("sort_index").notNull().default(0),
|
|
231
|
+
/**
|
|
232
|
+
* The credential is a static bearer token (a JWT/access token pasted on its
|
|
233
|
+
* own, or an account an operator has pinned): it is used exactly as issued and
|
|
234
|
+
* must never be sent to a refresh endpoint. Distinct from `credentialKind`,
|
|
235
|
+
* which says what family the credential belongs to, not whether it can be
|
|
236
|
+
* re-minted — an `oauth` account can carry a static token when its refresh
|
|
237
|
+
* grant is absent or dead while the token itself is still valid. A static
|
|
238
|
+
* account is skipped by the refresh sweep and is never disabled by a refresh
|
|
239
|
+
* failure, because there is no refresh to run.
|
|
240
|
+
*/
|
|
241
|
+
staticToken: boolean("static_token").notNull().default(false),
|
|
242
|
+
/**
|
|
243
|
+
* When this account's credential was last read for its own identity (email,
|
|
244
|
+
* display name). Null means never read.
|
|
245
|
+
*
|
|
246
|
+
* This is the convergence stamp for the enrichment sweep, and the reason an
|
|
247
|
+
* import can be instant: the batch path persists the credential without
|
|
248
|
+
* parsing it, and the sweep fills the identity in afterwards. An account is
|
|
249
|
+
* only listed while this is null, so a completed pass never re-decrypts a
|
|
250
|
+
* credential it already read.
|
|
251
|
+
*/
|
|
252
|
+
identityCheckedAt: timestamp("identity_checked_at", { withTimezone: true }),
|
|
253
|
+
/**
|
|
254
|
+
* Email read out of the account's own JWT claims. Cosmetic: it names the
|
|
255
|
+
* account in the console and is never used to authenticate or route.
|
|
256
|
+
*/
|
|
257
|
+
accountEmail: text("account_email"),
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The plan the provider reports for this account, last seen by a quota read.
|
|
261
|
+
*
|
|
262
|
+
* Persisted rather than read from the quota cache: that cache is a short
|
|
263
|
+
* in-process TTL and empty on a cold start, so the plan would be unknown
|
|
264
|
+
* exactly when a freshly booted process makes its first routing decisions.
|
|
265
|
+
*
|
|
266
|
+
* Nullable: most providers report no plan at all, and an account never read
|
|
267
|
+
* must say so rather than have a plan invented for it. Routing reads absence
|
|
268
|
+
* as "unknown", never as "free" — guessing free would send pro-only traffic
|
|
269
|
+
* to an account that may well be refused.
|
|
270
|
+
*/
|
|
271
|
+
quotaPlan: text("quota_plan"),
|
|
272
|
+
|
|
273
|
+
},
|
|
274
|
+
(table) => [
|
|
275
|
+
uniqueIndex("provider_accounts_identity_uidx").on(
|
|
276
|
+
table.providerId,
|
|
277
|
+
// No `::uuid` cast: the migrations create this index without one on
|
|
278
|
+
// SQLite (baseline l.440) and with one on Postgres (l.423), and the
|
|
279
|
+
// Drizzle definition is what a test's in-memory database is built
|
|
280
|
+
// from — a Postgres-only cast there made every account insert fail
|
|
281
|
+
// with `unrecognized token: ":"`.
|
|
282
|
+
sql`coalesce(${table.tenantId}, '00000000-0000-0000-0000-000000000000')`,
|
|
283
|
+
table.credentialFingerprint,
|
|
284
|
+
),
|
|
285
|
+
index("provider_accounts_status_cooldown_idx").on(table.status, table.cooldownUntil),
|
|
286
|
+
index("provider_accounts_provider_tenant_idx").on(table.providerId, table.tenantId),
|
|
287
|
+
// The bare `tenant_id IS NULL OR tenant_id = ?` that `listAllAccounts`
|
|
288
|
+
// uses is served by neither index above: `(status, cooldown_until)` leads
|
|
289
|
+
// with the wrong column, and `(provider_id, tenant_id)` cannot serve a
|
|
290
|
+
// predicate that does not constrain `provider_id`. This one matches the
|
|
291
|
+
// hot listing path.
|
|
292
|
+
index("provider_accounts_tenant_id_idx").on(table.tenantId),
|
|
293
|
+
],
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
// OAuth refresh token + cross-process lease, normalized out of
|
|
298
|
+
// `provider_accounts` into its own 1:0/1 table. The vast majority of
|
|
299
|
+
// provider accounts are `credentialKind: "api_key"`, for which these four
|
|
300
|
+
// columns stayed null forever on the shared row; splitting them here keeps
|
|
301
|
+
// the hot health-machine row narrow and isolates the fenced compare-and-swap
|
|
302
|
+
// lease traffic (`providers/auth/refresh-service.ts`) from unrelated
|
|
303
|
+
// account reads/writes.
|
|
304
|
+
export const providerOauthStates = pgTable("provider_oauth_states", {
|
|
305
|
+
providerAccountId: uuid("provider_account_id")
|
|
306
|
+
.primaryKey()
|
|
307
|
+
.references(() => providerAccounts.id, { onDelete: "cascade" }),
|
|
308
|
+
/**
|
|
309
|
+
* Encrypted refresh token. Nullable: an account can be pasted with only an
|
|
310
|
+
* access token (or a credential whose refresh token never arrived), and the
|
|
311
|
+
* account must still be tracked so the refresh sweep can flag it for re-auth
|
|
312
|
+
* rather than leaving it invisible. A null value means "cannot refresh".
|
|
313
|
+
*/
|
|
314
|
+
refreshCiphertext: bytea("refresh_ciphertext"),
|
|
315
|
+
/**
|
|
316
|
+
* Encrypted companion secret for the minority of flows whose refresh is not
|
|
317
|
+
* authorized by the refresh token alone: a device-flow client registration
|
|
318
|
+
* mints a client secret that must be replayed beside the token. Nullable —
|
|
319
|
+
* most providers refresh with the refresh token by itself.
|
|
320
|
+
*/
|
|
321
|
+
clientSecretCiphertext: bytea("client_secret_ciphertext"),
|
|
322
|
+
/**
|
|
323
|
+
* Access-token expiry. Nullable: a credential pasted without an expiry (an
|
|
324
|
+
* opaque token, or a bare access token) has no known deadline, and reads as
|
|
325
|
+
* "always due" so the sweep refreshes it as soon as it can.
|
|
326
|
+
*/
|
|
327
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
|
328
|
+
// OAuth refresh lease: fenced compare-and-swap coordination so only one
|
|
329
|
+
// process refreshes a given account's OAuth token at a time, and a losing
|
|
330
|
+
// process reloads instead of clobbering a peer's fresher token.
|
|
331
|
+
leaseOwner: text("lease_owner"),
|
|
332
|
+
leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
// One row per (provider_id, model_id, route), with `endpoint_path`
|
|
337
|
+
// identifying the route — the same model through two providers, or through
|
|
338
|
+
// two routes of one provider, is two rows. `enabled` is a hard routing
|
|
339
|
+
// invariant read by ProviderCatalogService, not a display-only flag.
|
|
340
|
+
export const models = pgTable(
|
|
341
|
+
"models",
|
|
342
|
+
{
|
|
343
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
344
|
+
providerId: text("provider_id")
|
|
345
|
+
.notNull()
|
|
346
|
+
.references(() => providers.id, { onDelete: "cascade" }),
|
|
347
|
+
modelId: text("model_id").notNull(),
|
|
348
|
+
wireFamily: wireFamily("wire_family").notNull(),
|
|
349
|
+
// Which protocol shape at all (`llm` = the canonical chat pipeline). A
|
|
350
|
+
// non-`llm` row (System One) is served by its native passthrough route, not
|
|
351
|
+
// by the surface codecs. Kept a plain text column with a default rather than
|
|
352
|
+
// a pgEnum: unlike `wire_family`, the set is expected to grow (embeddings,
|
|
353
|
+
// tts, …) and a widened enum needs a migration per addition.
|
|
354
|
+
serviceKind: text("service_kind").notNull().default("llm"),
|
|
355
|
+
endpointPath: text("endpoint_path").notNull(),
|
|
356
|
+
contextLimit: integer("context_limit"),
|
|
357
|
+
outputLimit: integer("output_limit"),
|
|
358
|
+
modalities: jsonb("modalities"),
|
|
359
|
+
reasoning: boolean("reasoning").notNull().default(false),
|
|
360
|
+
toolCall: boolean("tool_call").notNull().default(false),
|
|
361
|
+
cost: jsonb("cost"),
|
|
362
|
+
source: text("source"),
|
|
363
|
+
sourceUpdatedAt: timestamp("source_updated_at", { withTimezone: true }),
|
|
364
|
+
enabled: boolean("enabled").notNull().default(true),
|
|
365
|
+
/**
|
|
366
|
+
* Whether the upstream refuses this model on a free plan.
|
|
367
|
+
*
|
|
368
|
+
* Nullable three-valued: `true` refused on free, `false` served on free,
|
|
369
|
+
* `NULL` nobody established it. `NULL` is the state of every model on every
|
|
370
|
+
* provider that publishes nothing about plan access, and collapsing it to
|
|
371
|
+
* `false` would assert a measured fact for rows nobody measured.
|
|
372
|
+
*
|
|
373
|
+
* A column rather than a derived rule because CodeBuddy publishes nothing
|
|
374
|
+
* that predicts access — `credits` and `vendor` were both measured against
|
|
375
|
+
* real dispatches and neither correlates. Only the refusal itself is a
|
|
376
|
+
* signal, so the answer has to be recorded. See
|
|
377
|
+
* `.skills/cartethyia-reverse-engineering/references/codebuddy-dispatch-headers.md`.
|
|
378
|
+
*/
|
|
379
|
+
proOnly: boolean("pro_only"),
|
|
380
|
+
/**
|
|
381
|
+
* Whether the provider publishes this model as free.
|
|
382
|
+
*
|
|
383
|
+
* Mirrors `proOnly` but sourced from the provider's own directory rather
|
|
384
|
+
* than inferred: Qoder states `is_free` per row, so the tier tracks a plan
|
|
385
|
+
* change without a code change. Nullable three-valued — absence means the
|
|
386
|
+
* provider did not state it, which is not the same as billed.
|
|
387
|
+
*/
|
|
388
|
+
freeTier: boolean("free_tier"),
|
|
389
|
+
},
|
|
390
|
+
(table) => [
|
|
391
|
+
uniqueIndex("models_provider_model_route_uidx").on(
|
|
392
|
+
table.providerId,
|
|
393
|
+
table.modelId,
|
|
394
|
+
table.endpointPath,
|
|
395
|
+
),
|
|
396
|
+
index("models_provider_enabled_idx").on(table.providerId, table.enabled),
|
|
397
|
+
// Bare-id lookups, with no provider named: see migration
|
|
398
|
+
// `0003_add_models_model_id_index`. Without it those seq-scan this table,
|
|
399
|
+
// which carries one row per provider x model x route.
|
|
400
|
+
index("models_model_id_idx").on(table.modelId),
|
|
401
|
+
],
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
// Tenant-scoped suppression of a GLOBAL/built-in model. Tenants cannot mutate
|
|
405
|
+
// the shared `models` row (see `DrizzleProviderCatalogStore.setModelEnabled`),
|
|
406
|
+
// so a disable is recorded here and filtered out of that tenant's routing
|
|
407
|
+
// snapshot. Tenant-owned/BYOK models are still toggled directly on `models`.
|
|
408
|
+
export const tenantDisabledModels = pgTable(
|
|
409
|
+
"tenant_disabled_models",
|
|
410
|
+
{
|
|
411
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
412
|
+
tenantId: tenantRefRequired(),
|
|
413
|
+
providerId: text("provider_id")
|
|
414
|
+
.notNull()
|
|
415
|
+
.references(() => providers.id, { onDelete: "cascade" }),
|
|
416
|
+
modelId: text("model_id").notNull(),
|
|
417
|
+
endpointPath: text("endpoint_path").notNull(),
|
|
418
|
+
createdAt: createdAtColumn(),
|
|
419
|
+
},
|
|
420
|
+
(t) => [
|
|
421
|
+
uniqueIndex("tenant_disabled_models_tenant_provider_model_uidx").on(
|
|
422
|
+
t.tenantId,
|
|
423
|
+
t.providerId,
|
|
424
|
+
t.modelId,
|
|
425
|
+
t.endpointPath,
|
|
426
|
+
),
|
|
427
|
+
],
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
// Tenant-owned HTTP/HTTPS/SOCKS5 pools share the provider-account health state machine.
|
|
432
|
+
export const networkPools = pgTable("network_pools", {
|
|
433
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
434
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
435
|
+
kind: networkPoolKind("kind").notNull(),
|
|
436
|
+
/**
|
|
437
|
+
* Operator-set family/tag for this pool, e.g. `residential-id` or
|
|
438
|
+
* `datacenter-sg`. Purely organisational: it names which family the node
|
|
439
|
+
* belongs to and nothing in routing or dispatch reads it. Null when the
|
|
440
|
+
* operator has not tagged the pool.
|
|
441
|
+
*/
|
|
442
|
+
family: text("family"),
|
|
443
|
+
endpointConfig: jsonb("endpoint_config").notNull(),
|
|
444
|
+
credentialCiphertext: bytea("credential_ciphertext"),
|
|
445
|
+
maxInflight: integer("max_inflight"),
|
|
446
|
+
/** Routing weight for weighted selection across the tenant pool group. */
|
|
447
|
+
weight: integer("weight"),
|
|
448
|
+
tenantId: tenantRefRequired(),
|
|
449
|
+
|
|
450
|
+
...healthColumns(),
|
|
451
|
+
lastLatencyMs: integer("last_latency_ms"),
|
|
452
|
+
lastHealthCheckAt: timestamp("last_health_check_at", { withTimezone: true }),
|
|
453
|
+
/** Public egress address reported by the last successful probe; null until probed. */
|
|
454
|
+
egressIp: text("egress_ip"),
|
|
455
|
+
/** Operator-set egress allowance in bytes; null means unmetered. */
|
|
456
|
+
quotaBytes: bigint("quota_bytes", { mode: "number" }),
|
|
457
|
+
/** Last speed-test payload actually transferred, in bytes; null until measured. */
|
|
458
|
+
lastSpeedtestBytes: integer("last_speedtest_bytes"),
|
|
459
|
+
/** Wall-clock duration of the last speed-test transfer, in milliseconds. */
|
|
460
|
+
lastSpeedtestDurationMs: integer("last_speedtest_duration_ms"),
|
|
461
|
+
/** "ok" | "failed" for the last speed-test; null until measured. */
|
|
462
|
+
lastSpeedtestStatus: text("last_speedtest_status"),
|
|
463
|
+
/** Failure message of the last speed-test; null on success or unmeasured. */
|
|
464
|
+
lastSpeedtestError: text("last_speedtest_error"),
|
|
465
|
+
/** When the last speed-test ran. */
|
|
466
|
+
lastSpeedtestAt: timestamp("last_speedtest_at", { withTimezone: true }),
|
|
467
|
+
/**
|
|
468
|
+
* Provider-scoped probe verdict, stored beside the other last-known facts
|
|
469
|
+
* (`egress_ip`, `last_latency_ms`, the speed-test columns) rather than in the
|
|
470
|
+
* browser.
|
|
471
|
+
*
|
|
472
|
+
* It lived in `localStorage` first, and that made it decorative: the verdict
|
|
473
|
+
* was visible to the operator who ran it and to nobody else — not to another
|
|
474
|
+
* machine, not to the gateway, and not to admission. A pool could read
|
|
475
|
+
* "OpenCode blocked" on screen while dispatch kept sending traffic through
|
|
476
|
+
* it, because nothing outside that one browser had ever heard the verdict.
|
|
477
|
+
*
|
|
478
|
+
* `null` means never probed. These describe the *address* recorded in
|
|
479
|
+
* `egress_ip` at the time, so a changed endpoint clears them.
|
|
480
|
+
*/
|
|
481
|
+
providerProbeProviderId: text("provider_probe_provider_id"),
|
|
482
|
+
/** `allowed` | `blocked` | `throttled` | `unreachable`; null until probed. */
|
|
483
|
+
providerProbeStatus: text("provider_probe_status"),
|
|
484
|
+
/** Upstream HTTP status when a response arrived. */
|
|
485
|
+
providerProbeHttpStatus: integer("provider_probe_http_status"),
|
|
486
|
+
/** Operator-facing reason: the upstream's own words, or why the probe failed. */
|
|
487
|
+
providerProbeReason: text("provider_probe_reason"),
|
|
488
|
+
providerProbeLatencyMs: integer("provider_probe_latency_ms"),
|
|
489
|
+
providerProbeAt: timestamp("provider_probe_at", { withTimezone: true }),
|
|
490
|
+
/** The endpoint the verdict was measured against. Compared on read, so an
|
|
491
|
+
* edited endpoint reports "never probed" instead of a stale verdict. */
|
|
492
|
+
providerProbeEndpoint: text("provider_probe_endpoint"),
|
|
493
|
+
/** Metered egress totals, flushed from the in-memory socket counters on
|
|
494
|
+
* every pool-touching write so the quota bar survives a restart. */
|
|
495
|
+
bytesSentTotal: bigint("bytes_sent_total", { mode: "number" }).notNull().default(0),
|
|
496
|
+
/** Metered ingress totals; see `bytesSentTotal`. */
|
|
497
|
+
bytesReceivedTotal: bigint("bytes_received_total", { mode: "number" }).notNull().default(0),
|
|
498
|
+
},
|
|
499
|
+
(table) => [
|
|
500
|
+
index("network_pools_tenant_id_idx").on(table.tenantId),
|
|
501
|
+
index("network_pools_status_cooldown_idx").on(table.status, table.cooldownUntil),
|
|
502
|
+
index("network_pools_family_idx").on(table.family),
|
|
503
|
+
],
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
// Account and network-pool health events share one table. The entity kind
|
|
508
|
+
// selects exactly one owner FK, and the CHECK constraint below preserves
|
|
509
|
+
// referential integrity for both variants.
|
|
510
|
+
export const healthEvents = pgTable(
|
|
511
|
+
"health_events",
|
|
512
|
+
{
|
|
513
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
514
|
+
entityKind: healthEntityKind("entity_kind").notNull(),
|
|
515
|
+
accountId: uuid("account_id").references(() => providerAccounts.id, { onDelete: "cascade" }),
|
|
516
|
+
networkPoolId: uuid("network_pool_id").references(() => networkPools.id, {
|
|
517
|
+
onDelete: "cascade",
|
|
518
|
+
}),
|
|
519
|
+
fromStatus: healthStatus("from_status"),
|
|
520
|
+
toStatus: healthStatus("to_status").notNull(),
|
|
521
|
+
reason: text("reason"),
|
|
522
|
+
errorCategory: text("error_category"),
|
|
523
|
+
modelId: text("model_id"),
|
|
524
|
+
createdAt: createdAtColumn(),
|
|
525
|
+
},
|
|
526
|
+
(table) => [
|
|
527
|
+
index("health_events_account_id_created_at_idx").on(table.accountId, table.createdAt),
|
|
528
|
+
// Descending `created_at` is deliberate: health history is always read
|
|
529
|
+
// newest-first. The SQLite baseline drops the direction (see
|
|
530
|
+
// `scripts/generate/sqlite-ddl.ts` — SQLite scans an index either way),
|
|
531
|
+
// so this is a Postgres-only access-path choice, not a cross-backend
|
|
532
|
+
// disagreement.
|
|
533
|
+
index("health_events_account_model_created_idx").on(
|
|
534
|
+
table.accountId,
|
|
535
|
+
table.modelId,
|
|
536
|
+
desc(table.createdAt),
|
|
537
|
+
),
|
|
538
|
+
index("health_events_network_pool_id_created_at_idx").on(table.networkPoolId, table.createdAt),
|
|
539
|
+
check(
|
|
540
|
+
"health_events_entity_reference_check",
|
|
541
|
+
sql`(${table.entityKind} = 'account' AND ${table.accountId} IS NOT NULL AND ${table.networkPoolId} IS NULL) OR (${table.entityKind} = 'pool' AND ${table.networkPoolId} IS NOT NULL AND ${table.accountId} IS NULL)`,
|
|
542
|
+
),
|
|
543
|
+
],
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
// Model aliasing & combos: replaces the dormant, disconnected
|
|
548
|
+
// `routing_policies` scaffolding. Aliases resolve a client-facing name to a
|
|
549
|
+
// real model; combos back a client-facing name with >=1 real models,
|
|
550
|
+
// auto-selected via `fallback` or `round_robin`.
|
|
551
|
+
export const modelComboStrategy = pgEnum("model_combo_strategy", [
|
|
552
|
+
"fallback",
|
|
553
|
+
"round_robin",
|
|
554
|
+
]);
|
|
555
|
+
|
|
556
|
+
/** Canonical `ComboStrategy` union, derived from the enum above. Console
|
|
557
|
+
* domain and routing layers import this instead of hand-typing their own mirror.
|
|
558
|
+
* `ProviderRoutingStrategy` covers the same two values but persists on
|
|
559
|
+
* `provider_routing_settings` beside `rotateCount`; sticky routing is gone. */
|
|
560
|
+
export type ComboStrategy = (typeof modelComboStrategy.enumValues)[number];
|
|
561
|
+
|
|
562
|
+
export const modelAliases = pgTable(
|
|
563
|
+
"model_aliases",
|
|
564
|
+
{
|
|
565
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
566
|
+
tenantId: tenantRefRequired(),
|
|
567
|
+
alias: text("alias").notNull(),
|
|
568
|
+
targetModel: text("target_model").notNull(),
|
|
569
|
+
/** Explicit list position. */
|
|
570
|
+
sortIndex: integer("sort_index").notNull().default(0),
|
|
571
|
+
...timestampColumns(),
|
|
572
|
+
},
|
|
573
|
+
(t) => [uniqueIndex("model_aliases_tenant_alias_uidx").on(t.tenantId, t.alias)],
|
|
574
|
+
);
|
|
575
|
+
export type ModelAlias = typeof modelAliases.$inferSelect;
|
|
576
|
+
|
|
577
|
+
export const modelCombos = pgTable(
|
|
578
|
+
"model_combos",
|
|
579
|
+
{
|
|
580
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
581
|
+
tenantId: tenantRefRequired(),
|
|
582
|
+
name: text("name").notNull(),
|
|
583
|
+
members: jsonb("members").notNull().$type<string[]>(),
|
|
584
|
+
strategy: modelComboStrategy("strategy").notNull().default("fallback"),
|
|
585
|
+
/** Explicit list position. */
|
|
586
|
+
sortIndex: integer("sort_index").notNull().default(0),
|
|
587
|
+
...timestampColumns(),
|
|
588
|
+
},
|
|
589
|
+
(t) => [uniqueIndex("model_combos_tenant_name_uidx").on(t.tenantId, t.name)],
|
|
590
|
+
);
|
|
591
|
+
export type ModelCombo = typeof modelCombos.$inferSelect;
|
|
592
|
+
|
|
593
|
+
export const providerRoutingStrategy = pgEnum("provider_routing_strategy", [
|
|
594
|
+
"fallback",
|
|
595
|
+
"round_robin",
|
|
596
|
+
]);
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Canonical provider-routing strategy union, derived from the enum above so
|
|
600
|
+
* the persisted values and the type cannot drift. The console catalog and the
|
|
601
|
+
* routing engine both import this instead of restating the two literals.
|
|
602
|
+
*/
|
|
603
|
+
export type ProviderRoutingStrategy = (typeof providerRoutingStrategy.enumValues)[number];
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Per-account inflight ceiling applied when a provider has no routing row yet.
|
|
607
|
+
* One literal: the console default and the route catalog's fallback both read
|
|
608
|
+
* it. `null` is the "unlimited" choice, and it is also this default — an
|
|
609
|
+
* operator who has never opened the routing card asked for no ceiling, and
|
|
610
|
+
* inventing one silently capped their concurrency. A ceiling they *did* set is
|
|
611
|
+
* still honoured, so this only decides the untouched case.
|
|
612
|
+
*/
|
|
613
|
+
export const DEFAULT_PROVIDER_MAX_INFLIGHT: null = null;
|
|
614
|
+
|
|
615
|
+
export const providerRoutingSettings = pgTable(
|
|
616
|
+
"provider_routing_settings",
|
|
617
|
+
{
|
|
618
|
+
providerId: text("provider_id")
|
|
619
|
+
.notNull()
|
|
620
|
+
.references(() => providers.id, { onDelete: "cascade" }),
|
|
621
|
+
tenantId: tenantRefNullable(),
|
|
622
|
+
strategy: providerRoutingStrategy("strategy").notNull().default("fallback"),
|
|
623
|
+
/** Requests served by one account before round robin advances. */
|
|
624
|
+
rotateCount: integer("rotate_count").notNull().default(1),
|
|
625
|
+
/** Per-account inflight ceiling applied to every account of this provider
|
|
626
|
+
* (the field sits next to the failover/round-robin strategy in the UI).
|
|
627
|
+
* `null` = UNLIMITED concurrency per account. */
|
|
628
|
+
maxInflight: integer("max_inflight"),
|
|
629
|
+
/**
|
|
630
|
+
* Per-pool inflight ceiling for this provider: how many concurrent
|
|
631
|
+
* requests one proxy pool may carry *for this provider*.
|
|
632
|
+
*
|
|
633
|
+
* A second, independent axis from `maxInflight`. That one bounds a single
|
|
634
|
+
* account; `network_pools.max_inflight` bounds a pool across every
|
|
635
|
+
* provider; this one bounds one provider's share of one pool. All three
|
|
636
|
+
* are enforced together — none replaces another.
|
|
637
|
+
*
|
|
638
|
+
* `null` = unlimited, and that is the default: a provider nobody has
|
|
639
|
+
* configured behaves exactly as it did before this column existed, with no
|
|
640
|
+
* extra Redis key and no extra round trip.
|
|
641
|
+
*/
|
|
642
|
+
maxInflightPerProxy: integer("max_inflight_per_proxy"),
|
|
643
|
+
/**
|
|
644
|
+
* Minimum remaining allowance an account must hold to stay routable, for
|
|
645
|
+
* this provider. `null` means no floor. Compared against the account's own
|
|
646
|
+
* unit: absolute remaining credit when the provider reports credits, the
|
|
647
|
+
* aggregate credit percentage and lowest independent percentage window
|
|
648
|
+
* otherwise. An account below it is skipped and the request fails over to
|
|
649
|
+
* the next account instead of erroring.
|
|
650
|
+
*/
|
|
651
|
+
usageFloor: integer("usage_floor"),
|
|
652
|
+
/** `credit` compares the summed remaining credit; `percent` compares the
|
|
653
|
+
* aggregate credit percentage and lowest independent percentage-only
|
|
654
|
+
* window. Ignored while `usageFloor` is null. */
|
|
655
|
+
usageFloorUnit: text("usage_floor_unit").$type<"credit" | "percent">().notNull().default("percent"),
|
|
656
|
+
enabled: boolean("enabled").notNull().default(false),
|
|
657
|
+
// Route-selected User-Agent for built-in API-key providers; OAuth and BYOK identities stay native.
|
|
658
|
+
// The column default is the Claude Code CLI identity; the live default for a
|
|
659
|
+
// row that never set one is `buildClaudeCliUserAgent()` in
|
|
660
|
+
// providers/operations/custom-cli-headers.ts, which this mirrors.
|
|
661
|
+
userAgent: text("user_agent").notNull().default("claude-cli/2.1.293 (external, cli)"),
|
|
662
|
+
// When true, this provider's requests always dial direct. When false,
|
|
663
|
+
// dispatch automatically selects among every active network pool the
|
|
664
|
+
// tenant owns (least-loaded/non-cooldown) — never an admin-pinned pool.
|
|
665
|
+
skipProxy: boolean("skip_proxy").notNull().default(false),
|
|
666
|
+
},
|
|
667
|
+
(table) => ({
|
|
668
|
+
tenantProviderUnique: uniqueIndex("provider_routing_settings_tenant_provider_idx").on(
|
|
669
|
+
table.tenantId,
|
|
670
|
+
table.providerId,
|
|
671
|
+
),
|
|
672
|
+
globalProviderUnique: uniqueIndex("provider_routing_settings_global_provider_idx")
|
|
673
|
+
.on(table.providerId)
|
|
674
|
+
.where(sql`tenant_id IS NULL`),
|
|
675
|
+
}),
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
export const poolRoutingStrategy = pgEnum("pool_routing_strategy", [
|
|
679
|
+
"least_loaded",
|
|
680
|
+
"round_robin",
|
|
681
|
+
]);
|
|
682
|
+
export type PoolRoutingStrategy = (typeof poolRoutingStrategy.enumValues)[number];
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Per-tenant selection strategy across the tenant's active network pools —
|
|
686
|
+
* the pool-group counterpart to `provider_routing_settings`. `least_loaded`
|
|
687
|
+
* (default) keeps the weighted least-loaded scan with its rotating tie-break;
|
|
688
|
+
* `round_robin` serves `rotate_count` requests per pool before advancing,
|
|
689
|
+
* mirroring the account strategy, and skips capacity/cooldown pools the way
|
|
690
|
+
* account failover does. An absent row reads as the default, so tenants that
|
|
691
|
+
* never touch the toggle own no rows.
|
|
692
|
+
*/
|
|
693
|
+
export const poolRoutingSettings = pgTable("pool_routing_settings", {
|
|
694
|
+
tenantId: tenantRefPrimaryKey(),
|
|
695
|
+
strategy: poolRoutingStrategy("strategy").notNull().default("least_loaded"),
|
|
696
|
+
/** Requests served by one pool before round robin advances. */
|
|
697
|
+
rotateCount: integer("rotate_count").notNull().default(1),
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
export const API_KEY_MODES = ["personal", "share"] as const;
|
|
702
|
+
export type ApiKeyMode = (typeof API_KEY_MODES)[number];
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* How a key's `modelList` is interpreted. Exactly two modes, never a
|
|
706
|
+
* combination: `whitelist` allows only the listed names (an empty list allows
|
|
707
|
+
* everything), `blacklist` refuses the listed names (an empty list refuses
|
|
708
|
+
* nothing). Every model-authorization surface — the request path, admission,
|
|
709
|
+
* `/v1/models`, and the share page — reads this one pair.
|
|
710
|
+
*/
|
|
711
|
+
export const API_KEY_MODEL_ACCESS_MODES = ["whitelist", "blacklist"] as const;
|
|
712
|
+
export type ApiKeyModelAccessMode = (typeof API_KEY_MODEL_ACCESS_MODES)[number];
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Inbound `/v1/*` keys never store plaintext secrets. Personal keys carry an
|
|
716
|
+
* authentication hash; share templates carry policy and child keys point back
|
|
717
|
+
* to the template that issued them.
|
|
718
|
+
*/
|
|
719
|
+
export const apiKeys = pgTable(
|
|
720
|
+
"api_keys",
|
|
721
|
+
{
|
|
722
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
723
|
+
tenantId: tenantRefRequired(),
|
|
724
|
+
keyHash: text("key_hash"),
|
|
725
|
+
keyMode: text("key_mode").$type<ApiKeyMode>().notNull().default("personal"),
|
|
726
|
+
parentKeyId: uuid("parent_key_id").references(
|
|
727
|
+
(): AnyPgColumn => apiKeys.id,
|
|
728
|
+
{ onDelete: "cascade" },
|
|
729
|
+
),
|
|
730
|
+
issuedClientIp: text("issued_client_ip"),
|
|
731
|
+
issuedClientIpKey: text("issued_client_ip_key"),
|
|
732
|
+
label: text("label").notNull(),
|
|
733
|
+
scopes: jsonb("scopes").notNull().$type<readonly string[]>(),
|
|
734
|
+
// Only the non-secret public key prefix is returned to the owner.
|
|
735
|
+
keyPrefix: text("key_prefix"),
|
|
736
|
+
// Personal keys retain an encrypted copy for the CLI handoff. Shared
|
|
737
|
+
// children are revealed once and persist only their authentication hash.
|
|
738
|
+
keyEncrypted: bytea("key_encrypted"),
|
|
739
|
+
notesTitle: text("notes_title"),
|
|
740
|
+
notesSubtitle: text("notes_subtitle"),
|
|
741
|
+
notesBody: text("notes_body"),
|
|
742
|
+
/** Explicit list position. */
|
|
743
|
+
sortIndex: integer("sort_index").notNull().default(0),
|
|
744
|
+
requestsPerMinute: integer("requests_per_minute"),
|
|
745
|
+
dailyTokenLimit: bigint("daily_token_limit", { mode: "number" }),
|
|
746
|
+
monthlyTokenLimit: bigint("monthly_token_limit", { mode: "number" }),
|
|
747
|
+
lifetimeTokenBudget: bigint("lifetime_token_budget", { mode: "number" }),
|
|
748
|
+
lifetimeTokensConsumed: bigint("lifetime_tokens_consumed", { mode: "number" })
|
|
749
|
+
.notNull()
|
|
750
|
+
.default(0),
|
|
751
|
+
maxConcurrentRequests: integer("max_concurrent_requests"),
|
|
752
|
+
/**
|
|
753
|
+
* `whitelist` = only the names in `modelList` may be used (empty = all);
|
|
754
|
+
* `blacklist` = the names in `modelList` are refused (empty = none). One
|
|
755
|
+
* list, one mode — the old separate allow/deny columns are retired.
|
|
756
|
+
*/
|
|
757
|
+
modelAccessMode: text("model_access_mode")
|
|
758
|
+
.$type<ApiKeyModelAccessMode>()
|
|
759
|
+
.notNull()
|
|
760
|
+
.default("whitelist"),
|
|
761
|
+
modelList: jsonb("model_list").$type<readonly string[]>(),
|
|
762
|
+
/**
|
|
763
|
+
* Client-router ids this key refuses. A request whose fingerprint names one
|
|
764
|
+
* of these is rejected before routing, so an operator can stop a downstream
|
|
765
|
+
* router from spending the key. Empty/NULL means "no router is refused".
|
|
766
|
+
*
|
|
767
|
+
* A label, not a security control: a client that sends no fingerprint is
|
|
768
|
+
* simply not matched, which is why this is a per-key policy rather than a
|
|
769
|
+
* boundary anything else in the system trusts.
|
|
770
|
+
*/
|
|
771
|
+
clientRouterDenylist: jsonb("client_router_denylist").$type<readonly string[]>(),
|
|
772
|
+
modelPrefix: text("model_prefix"),
|
|
773
|
+
createdAt: createdAtColumn(),
|
|
774
|
+
enabled: boolean("enabled").notNull().default(true),
|
|
775
|
+
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
|
776
|
+
/**
|
|
777
|
+
* The tenant's mandatory gateway key. Seeded at first boot whether or not
|
|
778
|
+
* `CARTETHYIA_API_KEY` was configured, and the one key the console refuses
|
|
779
|
+
* to revoke — it may only be rotated. A column rather than a label match,
|
|
780
|
+
* so renaming the key cannot break the CLI lookup or let a second
|
|
781
|
+
* default be created.
|
|
782
|
+
*/
|
|
783
|
+
isDefault: boolean("is_default").notNull().default(false),
|
|
784
|
+
},
|
|
785
|
+
(table) => [
|
|
786
|
+
uniqueIndex("api_keys_key_hash_idx").on(table.keyHash),
|
|
787
|
+
/**
|
|
788
|
+
* One *live* default key per tenant, enforced by the database.
|
|
789
|
+
*
|
|
790
|
+
* The baseline SQL has always created this; the schema never declared it,
|
|
791
|
+
* so the two descriptions disagreed and nothing noticed. It is the only
|
|
792
|
+
* database-level guarantee behind `isDefault`, and
|
|
793
|
+
* `test/persistence/sqlite-baseline.test.ts` pins it by name — so the
|
|
794
|
+
* schema was the incomplete side, not the migration.
|
|
795
|
+
*
|
|
796
|
+
* Partial on `revoked_at IS NULL` so a tenant can rotate its default key
|
|
797
|
+
* without the old, revoked row blocking the new one.
|
|
798
|
+
*/
|
|
799
|
+
uniqueIndex("api_keys_one_default_per_tenant_uidx")
|
|
800
|
+
.on(table.tenantId)
|
|
801
|
+
.where(sql`${table.isDefault} = true AND ${table.revokedAt} IS NULL`),
|
|
802
|
+
index("api_keys_tenant_id_idx").on(table.tenantId),
|
|
803
|
+
index("api_keys_enabled_tenant_idx").on(table.tenantId).where(sql`${table.enabled} = true`),
|
|
804
|
+
index("api_keys_parent_key_id_idx").on(table.parentKeyId),
|
|
805
|
+
check(
|
|
806
|
+
"api_keys_mode_shape_check",
|
|
807
|
+
sql`(
|
|
808
|
+
(${table.keyMode} = 'personal' AND ${table.keyHash} IS NOT NULL
|
|
809
|
+
AND ${table.parentKeyId} IS NULL AND ${table.issuedClientIp} IS NULL
|
|
810
|
+
AND ${table.issuedClientIpKey} IS NULL)
|
|
811
|
+
OR
|
|
812
|
+
(${table.keyMode} = 'share' AND (
|
|
813
|
+
(${table.parentKeyId} IS NULL AND ${table.keyHash} IS NULL
|
|
814
|
+
AND ${table.keyEncrypted} IS NULL
|
|
815
|
+
AND ${table.issuedClientIp} IS NULL AND ${table.issuedClientIpKey} IS NULL)
|
|
816
|
+
OR
|
|
817
|
+
(${table.parentKeyId} IS NOT NULL AND ${table.keyHash} IS NOT NULL
|
|
818
|
+
AND ${table.keyEncrypted} IS NULL
|
|
819
|
+
AND ${table.issuedClientIp} IS NOT NULL AND ${table.issuedClientIpKey} IS NOT NULL)
|
|
820
|
+
))
|
|
821
|
+
)`,
|
|
822
|
+
),
|
|
823
|
+
],
|
|
824
|
+
);
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* The optional popup a share link shows its recipient. Kept off `api_keys`
|
|
828
|
+
* because the auth path reads that row on every request and the popup is
|
|
829
|
+
* absent for nearly all of them. One row per key, present only when the owner
|
|
830
|
+
* configured one.
|
|
831
|
+
*/
|
|
832
|
+
export const sharePopups = pgTable(
|
|
833
|
+
"share_popups",
|
|
834
|
+
{
|
|
835
|
+
apiKeyId: uuid("api_key_id")
|
|
836
|
+
.primaryKey()
|
|
837
|
+
.references(() => apiKeys.id, { onDelete: "cascade" }),
|
|
838
|
+
enabled: boolean("enabled").notNull().default(false),
|
|
839
|
+
image: bytea("image"),
|
|
840
|
+
imageMime: text("image_mime"),
|
|
841
|
+
title: text("title"),
|
|
842
|
+
body: text("body"),
|
|
843
|
+
},
|
|
844
|
+
(table) => [
|
|
845
|
+
check(
|
|
846
|
+
"share_popups_image_mime_check",
|
|
847
|
+
sql`${table.imageMime} IS NULL OR ${table.imageMime} IN ('image/png', 'image/jpeg', 'image/webp', 'image/gif')`,
|
|
848
|
+
),
|
|
849
|
+
check(
|
|
850
|
+
"share_popups_image_size_check",
|
|
851
|
+
sql`${table.image} IS NULL OR octet_length(${table.image}) <= 2097152`,
|
|
852
|
+
// SQLite has no octet_length. The generator rewrites this check to
|
|
853
|
+
// `length(`; both count bytes for a blob column.
|
|
854
|
+
),
|
|
855
|
+
],
|
|
856
|
+
);
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
// Public bearer links for enrolling one shared key per resolved client IP, and
|
|
860
|
+
// for handing a personal key to its owner. The hash is the lookup key; the
|
|
861
|
+
// token is additionally retained encrypted so the owner's console can show the
|
|
862
|
+
// link again instead of losing it after the one response that minted it.
|
|
863
|
+
/**
|
|
864
|
+
* Share-link kinds, as a runtime tuple. Route schemas and DTOs project this
|
|
865
|
+
* list rather than restating it.
|
|
866
|
+
*/
|
|
867
|
+
export const SHARE_LINK_KINDS = ["enroll", "handoff"] as const;
|
|
868
|
+
|
|
869
|
+
/** One share-link kind. */
|
|
870
|
+
export type ShareLinkKind = (typeof SHARE_LINK_KINDS)[number];
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
export const shareLinks = pgTable(
|
|
874
|
+
"share_links",
|
|
875
|
+
{
|
|
876
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
877
|
+
apiKeyId: uuid("api_key_id")
|
|
878
|
+
.notNull()
|
|
879
|
+
.references(() => apiKeys.id, { onDelete: "cascade" }),
|
|
880
|
+
tokenHash: text("token_hash").notNull(),
|
|
881
|
+
// Encrypted bearer token. Present so the console can re-display a stable
|
|
882
|
+
// link; historical rows written before this column carry NULL and simply
|
|
883
|
+
// cannot be shown again.
|
|
884
|
+
tokenEncrypted: bytea("token_encrypted"),
|
|
885
|
+
/**
|
|
886
|
+
* The public slug this page is reachable at, in the clear.
|
|
887
|
+
*
|
|
888
|
+
* `tokenEncrypted` is the bearer token under the deployment's encryption
|
|
889
|
+
* key, so a link cannot be re-displayed once that key changes — which is
|
|
890
|
+
* exactly what a restore onto a fresh deployment does. Holding the slug
|
|
891
|
+
* unencrypted keeps every restored share page reachable under the address
|
|
892
|
+
* it was created with.
|
|
893
|
+
*
|
|
894
|
+
* Nullable: rows written before this column have no slug, and inventing one
|
|
895
|
+
* would publish an address the page never had.
|
|
896
|
+
*/
|
|
897
|
+
slug: text("slug"),
|
|
898
|
+
kind: text("kind").$type<ShareLinkKind>().notNull().default("enroll"),
|
|
899
|
+
active: boolean("active").notNull().default(true),
|
|
900
|
+
createdAt: createdAtColumn(),
|
|
901
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
|
902
|
+
lastViewedAt: timestamp("last_viewed_at", { withTimezone: true }),
|
|
903
|
+
},
|
|
904
|
+
(table) => [
|
|
905
|
+
uniqueIndex("share_links_token_hash_idx").on(table.tokenHash),
|
|
906
|
+
// Partial: rows without a slug (written before the column) are not
|
|
907
|
+
// constrained, so backfilling is never blocked by a uniqueness collision.
|
|
908
|
+
uniqueIndex("share_links_slug_uidx").on(table.slug).where(sql`${table.slug} IS NOT NULL`),
|
|
909
|
+
index("share_links_api_key_idx").on(table.apiKeyId),
|
|
910
|
+
index("share_links_active_kind_expires_idx").on(table.active, table.kind, table.expiresAt),
|
|
911
|
+
check("share_links_kind_check", sql`${table.kind} IN ('enroll', 'handoff')`),
|
|
912
|
+
],
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Console dashboard users — distinct from API-key tenants.
|
|
918
|
+
*/
|
|
919
|
+
export const consoleUsers = pgTable(
|
|
920
|
+
"console_users",
|
|
921
|
+
{
|
|
922
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
923
|
+
tenantId: tenantRefRequired(),
|
|
924
|
+
username: text("username").notNull(),
|
|
925
|
+
email: text("email"),
|
|
926
|
+
passwordHash: text("password_hash").notNull(),
|
|
927
|
+
displayName: text("display_name"),
|
|
928
|
+
isActive: boolean("is_active").notNull().default(true),
|
|
929
|
+
isPlatformAdmin: boolean("is_platform_admin").notNull().default(false),
|
|
930
|
+
isFirstBoot: boolean("is_first_boot").notNull().default(true),
|
|
931
|
+
...timestampColumns(),
|
|
932
|
+
},
|
|
933
|
+
(table) => [
|
|
934
|
+
index("console_users_tenant_id_idx").on(table.tenantId),
|
|
935
|
+
uniqueIndex("console_users_tenant_username_uidx").on(table.tenantId, table.username),
|
|
936
|
+
],
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
export type ConsoleUser = typeof consoleUsers.$inferSelect;
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Console sessions — HTTP-only, signed cookies.
|
|
943
|
+
* Session metadata only; no secrets in JSON.
|
|
944
|
+
*/
|
|
945
|
+
export const consoleSessions = pgTable(
|
|
946
|
+
"console_sessions",
|
|
947
|
+
{
|
|
948
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
949
|
+
userId: uuid("user_id")
|
|
950
|
+
.notNull()
|
|
951
|
+
.references(() => consoleUsers.id, { onDelete: "cascade" }),
|
|
952
|
+
sessionToken: text("session_token").notNull().unique(),
|
|
953
|
+
ipAddress: text("ip_address"),
|
|
954
|
+
userAgent: text("user_agent"),
|
|
955
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
956
|
+
createdAt: createdAtColumn(),
|
|
957
|
+
},
|
|
958
|
+
(table) => [
|
|
959
|
+
index("console_sessions_user_id_idx").on(table.userId),
|
|
960
|
+
],
|
|
961
|
+
);
|
|
962
|
+
|
|
963
|
+
export type ConsoleSession = typeof consoleSessions.$inferSelect;
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Console login lockout tracking — fail-closed rate limiting keyed by
|
|
967
|
+
* client IP (login attempts happen before any user identity is known, so
|
|
968
|
+
* this cannot be keyed by `console_users.id`). Persisted so a ban survives
|
|
969
|
+
* a process restart and is shared across every app instance, instead of a
|
|
970
|
+
* static in-process map that a second instance or a redeploy would forget.
|
|
971
|
+
*/
|
|
972
|
+
export const consoleLockouts = pgTable(
|
|
973
|
+
"console_lockouts",
|
|
974
|
+
{
|
|
975
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
976
|
+
ip: text("ip").notNull(),
|
|
977
|
+
failureCount: integer("failure_count").notNull().default(0),
|
|
978
|
+
windowUntil: timestamp("window_until", { withTimezone: true }),
|
|
979
|
+
lockedUntil: timestamp("locked_until", { withTimezone: true }),
|
|
980
|
+
reason: text("reason"),
|
|
981
|
+
...timestampColumns(),
|
|
982
|
+
},
|
|
983
|
+
(table) => [
|
|
984
|
+
uniqueIndex("idx_console_lockouts_ip").on(table.ip),
|
|
985
|
+
],
|
|
986
|
+
);
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
// Configuration-change audit has its own retention lifecycle; it is separate
|
|
990
|
+
// from metadata telemetry and short-lived optional payload capture.
|
|
991
|
+
export const adminAuditLog = pgTable(
|
|
992
|
+
"admin_audit_log",
|
|
993
|
+
{
|
|
994
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
995
|
+
createdAt: createdAtColumn(),
|
|
996
|
+
actor: text("actor").notNull(),
|
|
997
|
+
tenantId: tenantCascadeSetNull(),
|
|
998
|
+
action: text("action").notNull(),
|
|
999
|
+
target: text("target").notNull(),
|
|
1000
|
+
detail: jsonb("detail"),
|
|
1001
|
+
},
|
|
1002
|
+
(table) => [
|
|
1003
|
+
index("admin_audit_log_tenant_id_created_at_id_idx").on(
|
|
1004
|
+
table.tenantId,
|
|
1005
|
+
table.createdAt,
|
|
1006
|
+
table.id,
|
|
1007
|
+
),
|
|
1008
|
+
],
|
|
1009
|
+
);
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
// Runtime preferences are stored in one validated JSONB bag. The filter
|
|
1013
|
+
// master toggle remains a typed column because it is read on the proxy path.
|
|
1014
|
+
export interface ConsoleSettingsPreferences {
|
|
1015
|
+
tenantConcurrencyLimit?: number | null;
|
|
1016
|
+
/** Normalizes provider-native thinking config for non-user final turns. */
|
|
1017
|
+
thinkingNormalizationEnabled?: boolean;
|
|
1018
|
+
responsesReasoningSummary?: "auto" | "concise" | "detailed";
|
|
1019
|
+
/**
|
|
1020
|
+
* Request compression: compact bulky tool-result text in the message history
|
|
1021
|
+
* before dispatch (RTK prune). Default off.
|
|
1022
|
+
*/
|
|
1023
|
+
rtkPruneEnabled?: boolean;
|
|
1024
|
+
/** RTK prune strength when enabled (`lite`/`full`/`ultra`); `full` when unset. */
|
|
1025
|
+
rtkPruneLevel?: "lite" | "full" | "ultra";
|
|
1026
|
+
/**
|
|
1027
|
+
* Request compression: append a minimal-code directive to the system content.
|
|
1028
|
+
* Default off; `ponyTailLevel` selects the intensity when on.
|
|
1029
|
+
*/
|
|
1030
|
+
ponyTailEnabled?: boolean;
|
|
1031
|
+
/** PonyTail directive intensity; `full` when unset. Only applied when enabled. */
|
|
1032
|
+
ponyTailLevel?: "lite" | "full" | "ultra" | null;
|
|
1033
|
+
/**
|
|
1034
|
+
* Payload capture mode (default `none` when unset):
|
|
1035
|
+
* - `metadata` — Proxy→Provider method + allowlisted headers only
|
|
1036
|
+
* - `full` — full redacted bodies up to the configured capture limit
|
|
1037
|
+
* - `none` — no drawer capture (request metadata events still retained)
|
|
1038
|
+
*/
|
|
1039
|
+
telemetryPayloads?: "full" | "metadata" | "none";
|
|
1040
|
+
/**
|
|
1041
|
+
* Capture depth for `full` mode (default `minimum` when unset). Fixed size
|
|
1042
|
+
* tiers so every debug sees all four drawer panels — only the cap changes:
|
|
1043
|
+
* - `minimum` — 1 MiB combined cap, light enough to leave on
|
|
1044
|
+
* - `moderate` — 8 MiB combined cap, expect CPU/memory spikes while active
|
|
1045
|
+
* - `maximum` — 16 MiB combined cap, expect CPU/memory spikes while active
|
|
1046
|
+
*/
|
|
1047
|
+
telemetryPayloadDepth?: "minimum" | "moderate" | "maximum";
|
|
1048
|
+
privacyMode?: "masked" | "full";
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
export const consoleSettings = pgTable("console_settings", {
|
|
1052
|
+
tenantId: tenantRefPrimaryKey(),
|
|
1053
|
+
preferences: jsonb("preferences").notNull().default({}).$type<ConsoleSettingsPreferences>(),
|
|
1054
|
+
updatedAt: updatedAtColumn(),
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
// CLI Tools (CLI tool configuration): per-tenant mapping table that lets a coding-agent
|
|
1059
|
+
// slot (e.g. Claude Code's "opus" slot) target a specific Cartethyia model. Rows
|
|
1060
|
+
// are opaque strings — validation happens at the API boundary against the
|
|
1061
|
+
// tool registry, not in Postgres.
|
|
1062
|
+
export const cliToolMappings = pgTable(
|
|
1063
|
+
"cli_tool_mappings",
|
|
1064
|
+
{
|
|
1065
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
1066
|
+
tenantId: tenantRefRequired(),
|
|
1067
|
+
apiKeyId: uuid("api_key_id")
|
|
1068
|
+
.notNull()
|
|
1069
|
+
.references((): AnyPgColumn => apiKeys.id, { onDelete: "cascade" }),
|
|
1070
|
+
toolId: text("tool_id").notNull(),
|
|
1071
|
+
slotKey: text("slot_key").notNull(),
|
|
1072
|
+
sourceModel: text("source_model").notNull(),
|
|
1073
|
+
targetModel: text("target_model").notNull(),
|
|
1074
|
+
enabled: boolean("enabled").notNull().default(true),
|
|
1075
|
+
updatedAt: updatedAtColumn(),
|
|
1076
|
+
},
|
|
1077
|
+
(table) => [
|
|
1078
|
+
uniqueIndex("cli_tool_mappings_key").on(
|
|
1079
|
+
table.tenantId,
|
|
1080
|
+
table.toolId,
|
|
1081
|
+
table.apiKeyId,
|
|
1082
|
+
table.slotKey,
|
|
1083
|
+
),
|
|
1084
|
+
],
|
|
1085
|
+
);
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
// Per-(tenant, tool, api_key) settings: whether mappings are active and which
|
|
1089
|
+
// mode the tool is configured for. Primary key is the composite of all three.
|
|
1090
|
+
export const cliToolSettings = pgTable(
|
|
1091
|
+
"cli_tool_settings",
|
|
1092
|
+
{
|
|
1093
|
+
tenantId: tenantRefRequired(),
|
|
1094
|
+
apiKeyId: uuid("api_key_id")
|
|
1095
|
+
.notNull()
|
|
1096
|
+
.references((): AnyPgColumn => apiKeys.id, { onDelete: "cascade" }),
|
|
1097
|
+
toolId: text("tool_id").notNull(),
|
|
1098
|
+
mappingsEnabled: boolean("mappings_enabled").notNull().default(false),
|
|
1099
|
+
mode: text("mode").notNull().default("remote"),
|
|
1100
|
+
updatedAt: updatedAtColumn(),
|
|
1101
|
+
},
|
|
1102
|
+
(table) => [primaryKey({ columns: [table.tenantId, table.toolId, table.apiKeyId] })],
|
|
1103
|
+
);
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
// Metadata-only request telemetry. Retention is configured independently of
|
|
1107
|
+
// payload capture (`CARTETHYIA_TELEMETRY_RETENTION_DAYS`, default 30 days).
|
|
1108
|
+
// No prompt text, raw body, API key, encrypted-reasoning payload, or generic
|
|
1109
|
+
// `payload` column — the schema structurally prevents those fields.
|
|
1110
|
+
// Correlation columns intentionally have no catalog foreign keys, so a
|
|
1111
|
+
// telemetry write never depends on provider-row lifetime.
|
|
1112
|
+
export const telemetryEvents = pgTable(
|
|
1113
|
+
"telemetry_events",
|
|
1114
|
+
{
|
|
1115
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
1116
|
+
createdAt: createdAtColumn(),
|
|
1117
|
+
tenantId: tenantRefRequired(),
|
|
1118
|
+
requestId: uuid("request_id").notNull(),
|
|
1119
|
+
sourceSurface: telemetrySourceSurface("source_surface"),
|
|
1120
|
+
requestedModel: text("requested_model"),
|
|
1121
|
+
/**
|
|
1122
|
+
* The `model` the upstream put in its own response, verbatim.
|
|
1123
|
+
*
|
|
1124
|
+
* Separate from `requested_model` because they answer different questions:
|
|
1125
|
+
* one is what the client asked for, the other is what the provider said it
|
|
1126
|
+
* ran. A provider that answers `muse-spark` as `deepseek-v4.1-flash` is
|
|
1127
|
+
* telling the operator something, and overwriting one with the other would
|
|
1128
|
+
* erase it. NULL when the response carried no model.
|
|
1129
|
+
*/
|
|
1130
|
+
reportedModel: text("reported_model"),
|
|
1131
|
+
/** Canonical reasoning effort the client asked for; NULL when it asked for none. */
|
|
1132
|
+
requestedEffort: text("requested_effort"),
|
|
1133
|
+
providerId: text("provider_id"),
|
|
1134
|
+
accountId: uuid("account_id"),
|
|
1135
|
+
networkPoolId: uuid("network_pool_id"),
|
|
1136
|
+
endpoint: text("endpoint"),
|
|
1137
|
+
apiKeyId: uuid("api_key_id"),
|
|
1138
|
+
userAgent: text("user_agent"),
|
|
1139
|
+
clientIp: text("client_ip"),
|
|
1140
|
+
latencyMs: integer("latency_ms"),
|
|
1141
|
+
ttfbMs: integer("ttfb_ms"),
|
|
1142
|
+
stream: boolean("stream"),
|
|
1143
|
+
status: telemetryStatus("status"),
|
|
1144
|
+
/**
|
|
1145
|
+
* HTTP status the client actually received. The `status` enum is the
|
|
1146
|
+
* internal terminal state (`completed`/`failed`/…), which collapses every
|
|
1147
|
+
* failure to one bucket; this column keeps the wire code, so a 503
|
|
1148
|
+
* admission rejection stays distinguishable from a 500 upstream failure
|
|
1149
|
+
* in the Usage status breakdown. NULL on rows written before the column.
|
|
1150
|
+
*/
|
|
1151
|
+
httpStatus: integer("http_status"),
|
|
1152
|
+
/**
|
|
1153
|
+
* Which layer produced the failure: the gateway itself, the upstream
|
|
1154
|
+
* provider, or the network in between. Recorded beside `error_category`
|
|
1155
|
+
* because the category alone cannot separate them — `invalid_request` is
|
|
1156
|
+
* both "the caller sent a bad body" (ours) and "the upstream rejected the
|
|
1157
|
+
* body" (theirs), and an operator triaging a failure needs to know which.
|
|
1158
|
+
*/
|
|
1159
|
+
errorOrigin: text("error_origin"),
|
|
1160
|
+
errorCategory: text("error_category"),
|
|
1161
|
+
inputTokens: integer("input_tokens"),
|
|
1162
|
+
cachedInputTokens: integer("cached_input_tokens"),
|
|
1163
|
+
outputTokens: integer("output_tokens"),
|
|
1164
|
+
reasoningTokens: integer("reasoning_tokens"),
|
|
1165
|
+
estimatedCostUsd: numeric("estimated_cost_usd", { precision: 12, scale: 6 }),
|
|
1166
|
+
/** Provider-billed credits (Tencent buddy-meter `credit`); NULL when unreported. */
|
|
1167
|
+
creditUsed: numeric("credit_used", { precision: 12, scale: 4 }),
|
|
1168
|
+
/**
|
|
1169
|
+
* Gateway-side time before the upstream dispatch began: routing,
|
|
1170
|
+
* credential lease, and request preparation. `ttfbMs` is measured from
|
|
1171
|
+
* the dispatch start, so this is the overhead the client waited on before
|
|
1172
|
+
* the provider was even called.
|
|
1173
|
+
*/
|
|
1174
|
+
resolveMs: integer("resolve_ms"),
|
|
1175
|
+
tokensPerSec: numeric("tokens_per_sec", { precision: 10, scale: 2 }),
|
|
1176
|
+
// Absolute epoch-millisecond event timestamps (~1.8e12) overflow integer
|
|
1177
|
+
// (int4): completed requests carrying observed stream timings failed
|
|
1178
|
+
// their telemetry insert and vanished from the console. bigint "number"
|
|
1179
|
+
// mode is safe here — epoch millis stay far below MAX_SAFE_INTEGER —
|
|
1180
|
+
// and keeps the columns JSON-safe like the token-budget bigints above.
|
|
1181
|
+
firstContentDeltaAtMs: bigint("first_content_delta_at_ms", { mode: "number" }),
|
|
1182
|
+
lastEventAtMs: bigint("last_event_at_ms", { mode: "number" }),
|
|
1183
|
+
/**
|
|
1184
|
+
* Why this request's drawer payloads were not captured, or `captured` when
|
|
1185
|
+
* they were. Remote-diagnosis column: the drawer reads empty for reasons
|
|
1186
|
+
* that are invisible from the client — the tenant's capture mode, a missing
|
|
1187
|
+
* tenant, a non-terminal attempt — and without this an operator could not
|
|
1188
|
+
* tell "not configured" from "broken".
|
|
1189
|
+
*/
|
|
1190
|
+
payloadCaptureState: text("payload_capture_state"),
|
|
1191
|
+
},
|
|
1192
|
+
(table) => [
|
|
1193
|
+
index("telemetry_events_created_at_idx").on(table.createdAt),
|
|
1194
|
+
index("telemetry_events_tenant_created_idx").on(table.tenantId, table.createdAt),
|
|
1195
|
+
index("telemetry_events_request_id_idx").on(table.requestId),
|
|
1196
|
+
index("telemetry_events_tenant_account_created_idx").on(
|
|
1197
|
+
table.tenantId,
|
|
1198
|
+
table.accountId,
|
|
1199
|
+
table.createdAt,
|
|
1200
|
+
),
|
|
1201
|
+
/**
|
|
1202
|
+
* Serves the public share page's per-key aggregate (`share-usage.ts`):
|
|
1203
|
+
* equality on `api_key_id`, range and aggregate on `created_at`. Without
|
|
1204
|
+
* it every share render scanned the largest table in the schema.
|
|
1205
|
+
*/
|
|
1206
|
+
index("telemetry_events_api_key_created_idx").on(table.apiKeyId, table.createdAt),
|
|
1207
|
+
],
|
|
1208
|
+
);
|
|
1209
|
+
|
|
1210
|
+
/** Durable aggregates retained after metadata telemetry expires. */
|
|
1211
|
+
export const TELEMETRY_USAGE_IDENTITY_TYPES = ["account", "api_key"] as const;
|
|
1212
|
+
export type TelemetryUsageIdentityType = (typeof TELEMETRY_USAGE_IDENTITY_TYPES)[number];
|
|
1213
|
+
|
|
1214
|
+
export const telemetryUsageTotals = pgTable(
|
|
1215
|
+
"telemetry_usage_totals",
|
|
1216
|
+
{
|
|
1217
|
+
tenantId: tenantRefRequired(),
|
|
1218
|
+
identityType: text("identity_type").$type<TelemetryUsageIdentityType>().notNull(),
|
|
1219
|
+
entityId: uuid("entity_id").notNull(),
|
|
1220
|
+
requests: bigint("requests", { mode: "number" }).notNull().default(0),
|
|
1221
|
+
errors: bigint("errors", { mode: "number" }).notNull().default(0),
|
|
1222
|
+
inputTokens: bigint("input_tokens", { mode: "number" }).notNull().default(0),
|
|
1223
|
+
outputTokens: bigint("output_tokens", { mode: "number" }).notNull().default(0),
|
|
1224
|
+
lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull(),
|
|
1225
|
+
updatedAt: updatedAtColumn(),
|
|
1226
|
+
},
|
|
1227
|
+
(table) => [
|
|
1228
|
+
primaryKey({
|
|
1229
|
+
name: "telemetry_usage_totals_identity_pk",
|
|
1230
|
+
columns: [table.tenantId, table.identityType, table.entityId],
|
|
1231
|
+
}),
|
|
1232
|
+
check(
|
|
1233
|
+
"telemetry_usage_totals_identity_type_check",
|
|
1234
|
+
sql`${table.identityType} IN ('account', 'api_key')`,
|
|
1235
|
+
),
|
|
1236
|
+
],
|
|
1237
|
+
);
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
export const telemetryPayloads = pgTable(
|
|
1241
|
+
"telemetry_payloads",
|
|
1242
|
+
{
|
|
1243
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
1244
|
+
tenantId: tenantRefRequired(),
|
|
1245
|
+
requestId: uuid("request_id"),
|
|
1246
|
+
capturedAt: timestamp("captured_at", { withTimezone: true }).notNull().defaultNow(),
|
|
1247
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
1248
|
+
/**
|
|
1249
|
+
* Typed index into the on-disk frame store. Prompt/response bodies live
|
|
1250
|
+
* only in the frame file these columns name — never in Postgres. The
|
|
1251
|
+
* previous `request_body jsonb` column is gone so a body cannot be
|
|
1252
|
+
* written here by accident.
|
|
1253
|
+
*/
|
|
1254
|
+
storage: text("storage").notNull(),
|
|
1255
|
+
file: text("file").notNull(),
|
|
1256
|
+
offset: integer("offset").notNull(),
|
|
1257
|
+
length: integer("length").notNull(),
|
|
1258
|
+
checksum: text("checksum").notNull(),
|
|
1259
|
+
version: integer("version").notNull(),
|
|
1260
|
+
/**
|
|
1261
|
+
* Which of the four payload stages were captured, as a bitmask. The Usage
|
|
1262
|
+
* list shows one icon per stage; learning presence from the frame would
|
|
1263
|
+
* cost one inflate per row, so the writer records it here and the list
|
|
1264
|
+
* reads it off the indexed row.
|
|
1265
|
+
*
|
|
1266
|
+
* Bits: 1=request, 2=provider_request, 4=provider_response,
|
|
1267
|
+
* 8=client_response. Nullable: NULL means the row predates this column,
|
|
1268
|
+
* which renders as all icons greyed rather than inventing presence.
|
|
1269
|
+
*/
|
|
1270
|
+
stages: integer("stages"),
|
|
1271
|
+
},
|
|
1272
|
+
(table) => [
|
|
1273
|
+
index("telemetry_payloads_request_id_idx").on(table.requestId),
|
|
1274
|
+
index("telemetry_payloads_tenant_request_idx").on(table.tenantId, table.requestId),
|
|
1275
|
+
index("telemetry_payloads_expires_idx").on(table.expiresAt),
|
|
1276
|
+
check("telemetry_payloads_storage_check", sql`${table.storage} IN ('jsonb-file', 'jsonl-file')`),
|
|
1277
|
+
check("telemetry_payloads_version_check", sql`${table.version} = 1`),
|
|
1278
|
+
check("telemetry_payloads_offset_check", sql`${table.offset} >= 0`),
|
|
1279
|
+
check("telemetry_payloads_length_check", sql`${table.length} > 0`),
|
|
1280
|
+
],
|
|
1281
|
+
);
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
// Daily check-in ledger: one row per (account, calendar day) once the sweep has
|
|
1286
|
+
// attempted that account's claim.
|
|
1287
|
+
//
|
|
1288
|
+
// The day-scoped composite PK is the claim guard — an insert that conflicts is
|
|
1289
|
+
// exactly the "already settled today" answer — so it replaces the Redis
|
|
1290
|
+
// `SET NX EX` the Lite backend never had. The console reads the same table to
|
|
1291
|
+
// answer "was this attempted today", which is what makes the Quota page show
|
|
1292
|
+
// the real state instead of a permanent "Check-in due" when Redis is absent.
|
|
1293
|
+
export const dailyCheckinLedger = pgTable(
|
|
1294
|
+
"daily_checkin_ledger",
|
|
1295
|
+
{
|
|
1296
|
+
dayKey: text("day_key").notNull(),
|
|
1297
|
+
accountId: uuid("account_id").notNull(),
|
|
1298
|
+
claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().defaultNow(),
|
|
1299
|
+
},
|
|
1300
|
+
(table) => [
|
|
1301
|
+
primaryKey({ columns: [table.dayKey, table.accountId] }),
|
|
1302
|
+
],
|
|
1303
|
+
);
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* Per-day, per-model credit deductions the buddy billing meter reported.
|
|
1308
|
+
*
|
|
1309
|
+
* A snapshot, not a ledger: the upstream is the source of truth and answers the
|
|
1310
|
+
* whole window on every call, so a row is upserted rather than appended. What it
|
|
1311
|
+
* buys is that the console renders a *stored* figure — without it every page
|
|
1312
|
+
* open would re-hit the billing endpoint for every account, and a failed read
|
|
1313
|
+
* would show nothing at all instead of the last number obtained.
|
|
1314
|
+
*
|
|
1315
|
+
* Keyed (account, day, model): one row per bucket, and the conflict target is
|
|
1316
|
+
* what makes the write idempotent across passes.
|
|
1317
|
+
*/
|
|
1318
|
+
export const buddyUsageSnapshots = pgTable(
|
|
1319
|
+
"buddy_usage_snapshots",
|
|
1320
|
+
{
|
|
1321
|
+
accountId: uuid("account_id")
|
|
1322
|
+
.notNull()
|
|
1323
|
+
.references(() => providerAccounts.id, { onDelete: "cascade" }),
|
|
1324
|
+
/** Calendar day the requests were metered on, `YYYY-MM-DD`. */
|
|
1325
|
+
dayKey: text("day_key").notNull(),
|
|
1326
|
+
model: text("model").notNull(),
|
|
1327
|
+
/** Credits deducted across every request in this bucket. */
|
|
1328
|
+
credit: numeric("credit", { precision: 14, scale: 4 }).notNull(),
|
|
1329
|
+
syncedAt: timestamp("synced_at", { withTimezone: true }).notNull().defaultNow(),
|
|
1330
|
+
},
|
|
1331
|
+
(table) => [
|
|
1332
|
+
primaryKey({ columns: [table.accountId, table.dayKey, table.model] }),
|
|
1333
|
+
index("buddy_usage_snapshots_day_idx").on(table.dayKey),
|
|
1334
|
+
],
|
|
1335
|
+
);
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* One row per (day, account, growth task) once that day's claim was attempted.
|
|
1339
|
+
*
|
|
1340
|
+
* The composite PK is the idempotency guard: an insert that conflicts is
|
|
1341
|
+
* exactly the "already settled today" answer, which is what stops a sweep from
|
|
1342
|
+
* re-POSTing a claim on every pass. Same shape as `daily_checkin_ledger`
|
|
1343
|
+
* because it is the same problem — one claim per account per day — widened by
|
|
1344
|
+
* the task name now that there is more than one thing to claim.
|
|
1345
|
+
*/
|
|
1346
|
+
export const buddyTaskLedger = pgTable(
|
|
1347
|
+
"buddy_task_ledger",
|
|
1348
|
+
{
|
|
1349
|
+
dayKey: text("day_key").notNull(),
|
|
1350
|
+
accountId: uuid("account_id")
|
|
1351
|
+
.notNull()
|
|
1352
|
+
.references(() => providerAccounts.id, { onDelete: "cascade" }),
|
|
1353
|
+
task: text("task").notNull(),
|
|
1354
|
+
claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().defaultNow(),
|
|
1355
|
+
},
|
|
1356
|
+
(table) => [
|
|
1357
|
+
primaryKey({ columns: [table.dayKey, table.accountId, table.task] }),
|
|
1358
|
+
index("buddy_task_ledger_account_idx").on(table.accountId),
|
|
1359
|
+
],
|
|
1360
|
+
);
|