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
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* already spent.
|
|
26
26
|
*/
|
|
27
27
|
import { log } from "../../../observability/logger";
|
|
28
|
+
import { ConsoleDomainError } from "../../shared/errors";
|
|
28
29
|
|
|
29
30
|
/** A loopback endpoint a callback can actually be delivered to. */
|
|
30
31
|
export interface LoopbackCallbackEndpoint {
|
|
@@ -224,10 +225,15 @@ export class OAuthCallbackListener {
|
|
|
224
225
|
try {
|
|
225
226
|
servers.push(this.#serve({ hostname: "127.0.0.1", port: endpoint.port, fetch: handler }));
|
|
226
227
|
} catch (cause) {
|
|
227
|
-
|
|
228
|
+
// A domain error, not a bare Error: the console route maps an untyped
|
|
229
|
+
// failure to a generic `internal_error`, which hid an actionable "free
|
|
230
|
+
// the port" message behind "Failed to start OAuth".
|
|
231
|
+
throw new ConsoleDomainError(
|
|
232
|
+
"oauth_callback_port_unavailable",
|
|
233
|
+
409,
|
|
228
234
|
`OAuth callback port ${endpoint.port} is in use, so the login redirect to ${endpoint.path} cannot be delivered. ` +
|
|
229
235
|
`Free port ${endpoint.port} and retry.`,
|
|
230
|
-
{
|
|
236
|
+
{ port: endpoint.port, path: endpoint.path },
|
|
231
237
|
);
|
|
232
238
|
}
|
|
233
239
|
try {
|
|
@@ -253,7 +259,16 @@ export class OAuthCallbackListener {
|
|
|
253
259
|
}
|
|
254
260
|
const error = url.searchParams.get("error");
|
|
255
261
|
const state = url.searchParams.get("state") ?? "";
|
|
256
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Not every authorization server hands back a `code`. Trae's page redirects
|
|
264
|
+
* with the refresh token already in the query (`?isRedirect=true&
|
|
265
|
+
* refreshToken=…`), and the client's parser reads the whole query. So an
|
|
266
|
+
* absent `code` falls back to the query itself — the client decides whether
|
|
267
|
+
* it can redeem it, and one that cannot still fails with its own message
|
|
268
|
+
* rather than a bare 400 here.
|
|
269
|
+
*/
|
|
270
|
+
const codeParam = url.searchParams.get("code") ?? "";
|
|
271
|
+
const code = codeParam.length > 0 ? codeParam : url.search.slice(1);
|
|
257
272
|
if (error !== null) {
|
|
258
273
|
// A denial carries the state only when the server echoes one; clear it
|
|
259
274
|
// when present so a retry starts a fresh flow.
|
|
@@ -28,14 +28,22 @@ import type { ConsoleAccessResolver } from "../../auth/access";
|
|
|
28
28
|
* identity the provider itself reports (an email or org name), which is stable
|
|
29
29
|
* across logins, so it is what a re-login replaces.
|
|
30
30
|
*
|
|
31
|
-
* A provider
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
31
|
+
* A label the provider never resolved is *not* that identity. Callers fill the
|
|
32
|
+
* gap with the provider id (`cursor`, `github`), and hashing that made every
|
|
33
|
+
* login of every account of that provider collide: the second login matched the
|
|
34
|
+
* first row and replaced it, so adding an account silently consumed the one
|
|
35
|
+
* already there. Only a label the provider actually reported — or the refresh
|
|
36
|
+
* token, which still catches an exact replay — may be the identity.
|
|
37
|
+
*
|
|
38
|
+
* Test seam: exported so the merge rule can be pinned without a database.
|
|
36
39
|
*/
|
|
37
|
-
function oauthIdentityFingerprint(input: {
|
|
38
|
-
|
|
40
|
+
export function oauthIdentityFingerprint(input: {
|
|
41
|
+
readonly label: string;
|
|
42
|
+
readonly providerId: string;
|
|
43
|
+
} & OAuthExchangeResult): string {
|
|
44
|
+
const label = input.label.trim();
|
|
45
|
+
const resolved = label.length > 0 && label.toLowerCase() !== input.providerId.trim().toLowerCase();
|
|
46
|
+
return hashSecret(resolved ? label : input.refresh);
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
export class DrizzleOAuthAccountStore implements OAuthAccountStore {
|
|
@@ -46,7 +54,7 @@ export class DrizzleOAuthAccountStore implements OAuthAccountStore {
|
|
|
46
54
|
providerId: string,
|
|
47
55
|
input: { readonly label: string } & OAuthExchangeResult,
|
|
48
56
|
): Promise<PersistedOAuthAccount> {
|
|
49
|
-
const credentialFingerprint = oauthIdentityFingerprint(input);
|
|
57
|
+
const credentialFingerprint = oauthIdentityFingerprint({ ...input, providerId });
|
|
50
58
|
const sameAccount = and(
|
|
51
59
|
eq(providerAccounts.providerId, providerId),
|
|
52
60
|
// The identity index coalesces a null tenant to a sentinel, so "same
|
|
@@ -375,12 +383,32 @@ export function createOAuthLoginOperations(config: OAuthLoginConfig) {
|
|
|
375
383
|
}
|
|
376
384
|
const state = generateOAuthState();
|
|
377
385
|
const { codeVerifier, codeChallenge } = generatePkcePair();
|
|
386
|
+
/**
|
|
387
|
+
* A client whose authorize URL depends on an upstream discovery runs it
|
|
388
|
+
* here, before the URL is built: `buildAuthorizeUrl` is synchronous by
|
|
389
|
+
* contract, so a client cannot fetch inside it.
|
|
390
|
+
*
|
|
391
|
+
* Trae is the reason this is wired up: its login host comes from
|
|
392
|
+
* `GetLoginGuidance`, and without this step the URL was built with no
|
|
393
|
+
* host and the login failed at the first click. Kiro declares the hook
|
|
394
|
+
* as a no-op, so nothing that already worked changes.
|
|
395
|
+
*/
|
|
396
|
+
const authorizeRequest = {
|
|
397
|
+
state,
|
|
398
|
+
codeChallenge,
|
|
399
|
+
redirectUri: client.browserRedirectUri ?? browserAuthorizeRedirectUri(),
|
|
400
|
+
...(parameters === undefined ? {} : { parameters }),
|
|
401
|
+
};
|
|
402
|
+
const prepared =
|
|
403
|
+
typeof client.prepareAuthorize === "function"
|
|
404
|
+
? await client.prepareAuthorize(authorizeRequest)
|
|
405
|
+
: authorizeRequest;
|
|
378
406
|
// A client that names its own allowlisted redirect URI wins over the
|
|
379
407
|
// gateway default: OpenAI registers exactly `http://localhost:1455/auth/callback`
|
|
380
408
|
// for the Codex client and answers any other value with
|
|
381
409
|
// `invalid_request` before consent, so the shared default cannot serve
|
|
382
410
|
// every provider.
|
|
383
|
-
const redirectUri =
|
|
411
|
+
const redirectUri = authorizeRequest.redirectUri;
|
|
384
412
|
// Bind the redirect's loopback port before the flow is recorded. The
|
|
385
413
|
// advertised URI names the operator's own machine, so a login that only
|
|
386
414
|
// advertises it leaves the browser on a dead page and the code stranded
|
|
@@ -404,12 +432,7 @@ export function createOAuthLoginOperations(config: OAuthLoginConfig) {
|
|
|
404
432
|
// deliver itself; a non-loopback redirect (a custom scheme, a remote
|
|
405
433
|
// host) returns false and keeps the manual path.
|
|
406
434
|
return {
|
|
407
|
-
authorizeUrl: client.buildAuthorizeUrl(
|
|
408
|
-
state,
|
|
409
|
-
codeChallenge,
|
|
410
|
-
redirectUri,
|
|
411
|
-
...(parameters === undefined ? {} : { parameters }),
|
|
412
|
-
}),
|
|
435
|
+
authorizeUrl: client.buildAuthorizeUrl(prepared),
|
|
413
436
|
state,
|
|
414
437
|
};
|
|
415
438
|
},
|
|
@@ -29,6 +29,7 @@ import type { ProviderId } from "../../providers/provider-contracts";
|
|
|
29
29
|
import {
|
|
30
30
|
DAILY_CHECKIN_PROVIDER_IDS,
|
|
31
31
|
checkinAttemptedToday,
|
|
32
|
+
checkinAttemptedTodayBatch,
|
|
32
33
|
dailyCheckinEgressForPass,
|
|
33
34
|
markCheckinAttempted,
|
|
34
35
|
} from "../../workers/daily-checkin";
|
|
@@ -334,12 +335,14 @@ export function registerAccountQuotaTenantRoutes(
|
|
|
334
335
|
// request; only check-in-capable accounts can carry one, so only they
|
|
335
336
|
// are read.
|
|
336
337
|
const checkinCandidates = rows.filter((row) => supportsAccountCheckin(row.providerId));
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
338
|
+
// One batched read for the whole page. Per-account reads made opening
|
|
339
|
+
// this route scale with the account count — a round trip each, on the
|
|
340
|
+
// route the dashboard polls — for a marker that is one key per account.
|
|
341
|
+
const checkinAttempted = await checkinAttemptedTodayBatch({
|
|
342
|
+
db,
|
|
343
|
+
redis,
|
|
344
|
+
accountIds: checkinCandidates.map((row) => row.id),
|
|
345
|
+
});
|
|
343
346
|
// Real credit deductions, read from the stored snapshots rather than from
|
|
344
347
|
// the upstream: the page renders every account at once, and re-hitting the
|
|
345
348
|
// billing endpoint per account would make opening it scale with the
|
|
@@ -377,6 +377,25 @@ async function runQuotaRefresh(
|
|
|
377
377
|
// unsupported") is itself the answer the page must render, and caching it
|
|
378
378
|
// stops every page open from re-hitting a provider that just refused us.
|
|
379
379
|
await setCachedQuota(targetLens(target), target.accountId, quota, deps.redis);
|
|
380
|
+
// Persist the plan the provider reported, because routing gates on it: some
|
|
381
|
+
// upstreams refuse a model on a free plan and nothing but the account's own
|
|
382
|
+
// plan says which. The cache above is a short in-process TTL and empty after
|
|
383
|
+
// a restart, so it cannot be the only copy — the row is what survives one.
|
|
384
|
+
//
|
|
385
|
+
// A failed fetch leaves the last known plan in place rather than clearing
|
|
386
|
+
// it. The refresh failing is evidence about the network or the credential,
|
|
387
|
+
// not evidence that the account's plan changed, and clearing on failure
|
|
388
|
+
// would drop the router back to "unknown" on every transient error.
|
|
389
|
+
if (quota.plan !== null) {
|
|
390
|
+
try {
|
|
391
|
+
await deps.db
|
|
392
|
+
.update(providerAccounts)
|
|
393
|
+
.set({ quotaPlan: quota.plan })
|
|
394
|
+
.where(eq(providerAccounts.id, target.accountId));
|
|
395
|
+
} catch (error) {
|
|
396
|
+
log.warn(`[quota] failed to record plan for account=${target.accountId}`, error as Error);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
380
399
|
// A quota surface that names the account adopts the identity — but only over
|
|
381
400
|
// a default label. An operator's custom rename is never overwritten, and the
|
|
382
401
|
// CASE keeps the read and the write in one statement so a concurrent rename
|
|
@@ -9,6 +9,18 @@
|
|
|
9
9
|
* what the browser spends its time receiving, and shaving microseconds off a
|
|
10
10
|
* handler cannot compete with removing 94% of the bytes.
|
|
11
11
|
*
|
|
12
|
+
* ## Why the gate is a path allowlist inside the function
|
|
13
|
+
*
|
|
14
|
+
* The wrap is mounted on the console Elysia instance (`console-router.ts`),
|
|
15
|
+
* but Elysia's `use()` merges the child's `hoc` into the **root** app's ext
|
|
16
|
+
* (`node_modules/elysia/dist/base.js:886-887`). Measured: the wrap therefore
|
|
17
|
+
* runs for `/v1/*` too, so instance-level scoping cannot express "console
|
|
18
|
+
* only". A path test can, and it lives inside `gzipResponse` — the one place
|
|
19
|
+
* that runs on every request.
|
|
20
|
+
*
|
|
21
|
+
* Consequence: `/v1/models` was previously compressed as a side effect of
|
|
22
|
+
* that `hoc` propagation. It deliberately is not now.
|
|
23
|
+
*
|
|
12
24
|
* ## Why this is scoped to the console, not the proxy
|
|
13
25
|
*
|
|
14
26
|
* `/v1/*` is deliberately excluded, for two independent reasons:
|
|
@@ -26,9 +38,26 @@
|
|
|
26
38
|
* Below `MIN_COMPRESSIBLE_BYTES` the envelope, the header, and the CPU are all
|
|
27
39
|
* pure loss: a 200-byte reply is not worth a gzip stream plus a browser
|
|
28
40
|
* decompression step. The threshold sits where the saving stops being noise.
|
|
41
|
+
*
|
|
42
|
+
* ## Opting a route out
|
|
43
|
+
*
|
|
44
|
+
* A route that knows its body is already compressed — or is an encoded
|
|
45
|
+
* envelope that would gain nothing — sets {@link NO_COMPRESS_HEADER}. The
|
|
46
|
+
* honest-sounding alternative, declaring `content-encoding: gzip`, is a lie
|
|
47
|
+
* when the bytes on the wire are not gzip, and the client that believes it
|
|
48
|
+
* fails trying to inflate them.
|
|
29
49
|
*/
|
|
30
50
|
import { gzipSync } from "node:zlib";
|
|
31
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Header a route sets to say "do not compress this", popped before the
|
|
54
|
+
* response leaves.
|
|
55
|
+
*
|
|
56
|
+
* Deleted rather than left on the response, so it stays an internal signal
|
|
57
|
+
* between two layers in this process and never reaches a client or a cache.
|
|
58
|
+
*/
|
|
59
|
+
export const NO_COMPRESS_HEADER = "x-cartethyia-no-compress";
|
|
60
|
+
|
|
32
61
|
/**
|
|
33
62
|
* Smallest body worth compressing.
|
|
34
63
|
*
|
|
@@ -37,6 +66,19 @@ import { gzipSync } from "node:zlib";
|
|
|
37
66
|
*/
|
|
38
67
|
export const MIN_COMPRESSIBLE_BYTES = 1024;
|
|
39
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Largest body this module will compress, as a ceiling on the event-loop
|
|
71
|
+
* stall.
|
|
72
|
+
*
|
|
73
|
+
* `gzipSync` is synchronous, so its cost is paid out of the request's own
|
|
74
|
+
* turn and is not interruptible. `/console/api/backup/export` answers with a
|
|
75
|
+
* multi-MB JSON document; at that size level 6 costs tens of milliseconds,
|
|
76
|
+
* which is longer than an entire dashboard poll. Those bytes go to disk
|
|
77
|
+
* rather than being polled, so the compression ratio is the wrong thing to
|
|
78
|
+
* optimise — above this size the response is returned as-is.
|
|
79
|
+
*/
|
|
80
|
+
export const MAX_COMPRESSIBLE_BYTES = 1024 * 1024;
|
|
81
|
+
|
|
40
82
|
/** Content types worth compressing, matched by prefix. */
|
|
41
83
|
const COMPRESSIBLE_TYPES: readonly string[] = [
|
|
42
84
|
"application/json",
|
|
@@ -99,20 +141,42 @@ export function isCompressible(contentType: string | null, bodyLength: number):
|
|
|
99
141
|
* type: `text/event-stream` is not in {@link COMPRESSIBLE_TYPES}, so a stream
|
|
100
142
|
* can never reach the buffering path.
|
|
101
143
|
*
|
|
102
|
-
* The size gate runs on the **actual** byte length, not a declared
|
|
144
|
+
* The size gate runs on the **actual** byte length, not only a declared
|
|
103
145
|
* `content-length`: Elysia does not set that header for a serialized object
|
|
104
|
-
* (measured), so gating on it rejected every route worth compressing.
|
|
146
|
+
* (measured), so gating on it alone rejected every route worth compressing. A
|
|
147
|
+
* declared length is still used as a fast path — it can skip the read for a
|
|
148
|
+
* small reply — but silence from the header must never mean "too small". The
|
|
105
149
|
* content-type gate is what keeps a stream from being read here, and it is
|
|
106
150
|
* checked before the body is touched.
|
|
107
151
|
*/
|
|
108
152
|
export async function gzipResponse(request: Request, response: Response): Promise<Response> {
|
|
153
|
+
// Scoped to the paths this module documents. Instance-level scoping is not
|
|
154
|
+
// available here: the wrap's `hoc` propagates to the root app, so every
|
|
155
|
+
// route in the process — `/v1/*` included — reaches this function.
|
|
156
|
+
const pathname = new URL(request.url).pathname;
|
|
157
|
+
if (!pathname.startsWith("/console/api/") && pathname !== "/metrics") return response;
|
|
109
158
|
if (request.method === "HEAD") return response;
|
|
110
159
|
if (response.status === 204 || response.status === 304) return response;
|
|
111
160
|
// Already encoded — by a route or by an upstream. Never encode twice.
|
|
112
161
|
if (response.headers.has("content-encoding")) return response;
|
|
162
|
+
// A route that knows better: the body is an encoded envelope, or already
|
|
163
|
+
// compressed in a form this wire layer cannot describe. Deleted here so the
|
|
164
|
+
// signal stays internal and never reaches a client or a cache.
|
|
165
|
+
if (response.headers.has(NO_COMPRESS_HEADER)) {
|
|
166
|
+
response.headers.delete(NO_COMPRESS_HEADER);
|
|
167
|
+
return response;
|
|
168
|
+
}
|
|
113
169
|
if (!acceptsGzip(request.headers.get("accept-encoding"))) return response;
|
|
114
170
|
// Type first, before any read: this is what keeps an event stream out.
|
|
115
171
|
if (!isCompressibleType(response.headers.get("content-type"))) return response;
|
|
172
|
+
// A bodiless reply has nothing to compress, and `arrayBuffer()` on a null
|
|
173
|
+
// body yields a 0-byte buffer that would rebuild into a non-null body.
|
|
174
|
+
if (response.body === null) return response;
|
|
175
|
+
// A declared length below the threshold skips the read entirely. It cannot
|
|
176
|
+
// be the only gate — Elysia sets no `content-length` for a serialized
|
|
177
|
+
// object (measured) — so the byte-length read below still has to exist.
|
|
178
|
+
const declared = response.headers.get("content-length");
|
|
179
|
+
if (declared !== null && Number(declared) < MIN_COMPRESSIBLE_BYTES) return response;
|
|
116
180
|
|
|
117
181
|
const body = await response.arrayBuffer();
|
|
118
182
|
// Below the threshold the envelope, the header, and the CPU are all pure
|
|
@@ -120,6 +184,9 @@ export async function gzipResponse(request: Request, response: Response): Promis
|
|
|
120
184
|
// than compressed. A response that was read must always be rebuilt, or the
|
|
121
185
|
// client would receive an empty body.
|
|
122
186
|
if (body.byteLength < MIN_COMPRESSIBLE_BYTES) return rebuild(response, body);
|
|
187
|
+
// Above the ceiling the synchronous `gzipSync` stall costs more than the
|
|
188
|
+
// bytes it would save. Same rebuild obligation as the threshold path.
|
|
189
|
+
if (body.byteLength > MAX_COMPRESSIBLE_BYTES) return rebuild(response, body);
|
|
123
190
|
|
|
124
191
|
const compressed = gzipSync(new Uint8Array(body), { level: GZIP_LEVEL });
|
|
125
192
|
const headers = new Headers(response.headers);
|
|
@@ -58,3 +58,75 @@ export function classifyProviderProbeAnswer(httpStatus: number, body: string): P
|
|
|
58
58
|
reason: `Reachable; upstream answered ${httpStatus} for the probe body`,
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Error codes that mean "this attempt never reached the upstream", not "the
|
|
64
|
+
* upstream refused this address".
|
|
65
|
+
*
|
|
66
|
+
* Every one of them is worth a second attempt, and the reason is the same in
|
|
67
|
+
* each case: the failure is a property of one socket, not of the egress. A
|
|
68
|
+
* proxy that has just been dialled for the first time has to resolve, open a
|
|
69
|
+
* tunnel, and complete a TLS handshake, and any of those steps can be refused
|
|
70
|
+
* or reset by a middlebox that accepts the very next attempt — which is exactly
|
|
71
|
+
* the "fails once, works on retry" report. Retiring the pool on the first one
|
|
72
|
+
* blames the address for a cold-start race.
|
|
73
|
+
*/
|
|
74
|
+
const RETRYABLE_TRANSPORT_CODES = new Set([
|
|
75
|
+
"ECONNREFUSED",
|
|
76
|
+
"ECONNRESET",
|
|
77
|
+
"ECONNABORTED",
|
|
78
|
+
"EHOSTUNREACH",
|
|
79
|
+
"ENETUNREACH",
|
|
80
|
+
"ENETRESET",
|
|
81
|
+
"EPIPE",
|
|
82
|
+
"ETIMEDOUT",
|
|
83
|
+
"EAI_AGAIN",
|
|
84
|
+
"UND_ERR_SOCKET",
|
|
85
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
86
|
+
"ECONNECT_TIMEOUT",
|
|
87
|
+
]);
|
|
88
|
+
/** How many `cause` links to follow; wraps are shallow in practice. */
|
|
89
|
+
const PROBE_ERROR_CAUSE_DEPTH = 5;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether one probe error should be retried before it becomes a verdict.
|
|
93
|
+
*
|
|
94
|
+
* Deliberately narrow: an abort is *our* deadline firing, so retrying it would
|
|
95
|
+
* spend the whole probe budget on a slow egress, and an unknown error is left
|
|
96
|
+
* alone rather than guessed at. Only transport codes that describe a single
|
|
97
|
+
* dead connection qualify.
|
|
98
|
+
*/
|
|
99
|
+
export function isRetryableProbeError(error: unknown): boolean {
|
|
100
|
+
|
|
101
|
+
// Walk the `cause` chain: a transport failure is routinely wrapped — the
|
|
102
|
+
// proxy layer raises `ProxyConnectHandshakeError` whose *message* carries the
|
|
103
|
+
// real code, with nothing on `code`. Checking only the top error would miss
|
|
104
|
+
// exactly the failure this exists for.
|
|
105
|
+
let current: unknown = error;
|
|
106
|
+
for (let depth = 0; depth < PROBE_ERROR_CAUSE_DEPTH && current !== undefined && current !== null; depth += 1) {
|
|
107
|
+
if (!(current instanceof Error)) break;
|
|
108
|
+
if (current.name === "AbortError" || current.name === "TimeoutError") return false;
|
|
109
|
+
// `code` is not on the base `Error`; narrow before reading it rather than
|
|
110
|
+
// asserting a shape the runtime may not have.
|
|
111
|
+
const code: string | undefined =
|
|
112
|
+
"code" in current && typeof current.code === "string" ? current.code : undefined;
|
|
113
|
+
if (code !== undefined && RETRYABLE_TRANSPORT_CODES.has(code.toUpperCase())) return true;
|
|
114
|
+
const message = String(current.message ?? "");
|
|
115
|
+
// The wrapped code is often only in the message, so match it there too.
|
|
116
|
+
for (const candidate of RETRYABLE_TRANSPORT_CODES) {
|
|
117
|
+
if (message.includes(candidate)) return true;
|
|
118
|
+
}
|
|
119
|
+
if (
|
|
120
|
+
message.includes("fetch failed") ||
|
|
121
|
+
message.includes("socket connection was closed unexpectedly") ||
|
|
122
|
+
message.includes("other side closed") ||
|
|
123
|
+
message.startsWith("terminated") ||
|
|
124
|
+
/connection (?:was|is) closed/i.test(message)
|
|
125
|
+
) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
current = current.cause;
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
@@ -33,11 +33,14 @@ import {
|
|
|
33
33
|
const MIN_TOKEN_LENGTH = 20;
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Cadence of the share stats SSE snapshot.
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* Cadence of the share stats SSE snapshot.
|
|
37
|
+
*
|
|
38
|
+
* Raised with `SHARE_STATS_CACHE_TTL_SECONDS` (10 s). At 2 s the tick was
|
|
39
|
+
* shorter than the rollup's own cache TTL, so every push re-parsed the whole
|
|
40
|
+
* retained telemetry window — the interval was the thing making this read
|
|
41
|
+
* expensive, not the number of viewers. Short enough to still read as live.
|
|
39
42
|
*/
|
|
40
|
-
const SHARE_STATS_STREAM_INTERVAL_MS =
|
|
43
|
+
const SHARE_STATS_STREAM_INTERVAL_MS = 10_000;
|
|
41
44
|
|
|
42
45
|
/**
|
|
43
46
|
* Context and capabilities for one allowed model, sourced from the public
|
|
@@ -227,12 +227,19 @@ const TOP_IPS_LIMIT = 50;
|
|
|
227
227
|
const HOURS_WINDOW = 24;
|
|
228
228
|
|
|
229
229
|
/**
|
|
230
|
-
* Cache lifetime for one family's rollup.
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
230
|
+
* Cache lifetime for one family's rollup.
|
|
231
|
+
*
|
|
232
|
+
* Matched to the share page's SSE tick, which was the point of the old 2 s
|
|
233
|
+
* value — but at 2 s the cache expired on every tick, so every single push was
|
|
234
|
+
* a full parse of the retained telemetry window. A share page's numbers move
|
|
235
|
+
* when a request does, not every 2 s; 10 s still reads as live, and cuts the
|
|
236
|
+
* parse rate by five.
|
|
237
|
+
*
|
|
238
|
+
* Declared here rather than imported from the router so this port does not
|
|
239
|
+
* depend on it. `SHARE_STATS_STREAM_INTERVAL_MS` must be raised with it, or
|
|
240
|
+
* the two drift apart and the cache expires between ticks for no reason.
|
|
234
241
|
*/
|
|
235
|
-
export const SHARE_STATS_CACHE_TTL_SECONDS =
|
|
242
|
+
export const SHARE_STATS_CACHE_TTL_SECONDS = 10;
|
|
236
243
|
|
|
237
244
|
/**
|
|
238
245
|
* Cache key for one family's rollup. Both inputs change the payload — `keyIds`
|
|
@@ -10,8 +10,26 @@
|
|
|
10
10
|
* A Redis that is slow or down must never fail a request: every cache step
|
|
11
11
|
* falls through to computing the answer, which is exactly what the request
|
|
12
12
|
* would have done before there was a cache.
|
|
13
|
+
*
|
|
14
|
+
* ## Validators
|
|
15
|
+
*
|
|
16
|
+
* The endpoints answer dashboard polls that repeat every few seconds while the
|
|
17
|
+
* underlying telemetry window changes every minute. Without a validator every
|
|
18
|
+
* poll ships the whole payload — 8.8 KiB to 150 KiB of it — even when nothing
|
|
19
|
+
* changed. An ETag turns that into a ~200-byte `304`.
|
|
20
|
+
*
|
|
21
|
+
* Strong, not weak: the hash covers the exact serialized bytes the client
|
|
22
|
+
* would otherwise have received, so a matching validator means byte-identical
|
|
23
|
+
* content. A weaker or unstable hash would answer `304` for changed data and
|
|
24
|
+
* leave the dashboard showing stale numbers, which is worse than the bytes it
|
|
25
|
+
* saves.
|
|
26
|
+
*
|
|
27
|
+
* The ETag is computed **once per write**, never per read: hashing a 150 KiB
|
|
28
|
+
* body on every poll would move the cost off the wire and onto the CPU.
|
|
13
29
|
*/
|
|
14
30
|
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
32
|
+
|
|
15
33
|
/** The slice of Redis this cache needs, so it can be tested without a server. */
|
|
16
34
|
export interface ReadThroughCacheClient {
|
|
17
35
|
get(key: string): Promise<string | null>;
|
|
@@ -25,25 +43,133 @@ export interface ReadThroughCacheClient {
|
|
|
25
43
|
*/
|
|
26
44
|
export const READ_THROUGH_DEFAULT_TTL_SECONDS = 10;
|
|
27
45
|
|
|
28
|
-
/**
|
|
29
|
-
export
|
|
46
|
+
/** The cached value plus the strong validator for the bytes it serializes to. */
|
|
47
|
+
export interface CachedResult<T> {
|
|
48
|
+
readonly value: T;
|
|
49
|
+
readonly etag: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Quoted per RFC 9110: an entity-tag is always `"…"`. */
|
|
53
|
+
function etagFor(bytes: string): string {
|
|
54
|
+
// SHA-256 over the exact serialized bytes. Not the FNV-1a in
|
|
55
|
+
// `payload-store.ts` — that one is module-private, and adding a second hash
|
|
56
|
+
// implementation would risk two different collision profiles for one
|
|
57
|
+
// purpose. SHA-256 is already used in `share-stats.ts` for cache identity.
|
|
58
|
+
return `"${createHash("sha256").update(bytes, "utf8").digest("hex").slice(0, 32)}"`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The sibling key holding the validator, so a cached body is never re-hashed. */
|
|
62
|
+
export function etagKey(key: string): string {
|
|
63
|
+
return `${key}:etag`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reads through the cache and returns a strong validator alongside the value.
|
|
68
|
+
*
|
|
69
|
+
* The validator is computed once, at the moment the body is written, and read
|
|
70
|
+
* back from its own key: a hit never hashes the payload. On a validator miss
|
|
71
|
+
* with a body hit — a partial eviction, or a row written by an older build —
|
|
72
|
+
* the body is re-hashed rather than discarded, so the pair cannot disagree.
|
|
73
|
+
*
|
|
74
|
+
* With no cache configured (`client === undefined`) the value is computed and
|
|
75
|
+
* the validator is still returned, so conditional requests keep working for
|
|
76
|
+
* the length of the client's own polling loop; only the cross-instance sharing
|
|
77
|
+
* is lost.
|
|
78
|
+
*/
|
|
79
|
+
export async function cachedReadWithEtag<T>(
|
|
30
80
|
client: ReadThroughCacheClient | undefined,
|
|
31
81
|
key: string,
|
|
32
82
|
compute: () => Promise<T>,
|
|
33
83
|
ttlSeconds: number = READ_THROUGH_DEFAULT_TTL_SECONDS,
|
|
34
|
-
): Promise<T
|
|
35
|
-
if (client === undefined)
|
|
84
|
+
): Promise<CachedResult<T>> {
|
|
85
|
+
if (client === undefined) {
|
|
86
|
+
const fresh = await compute();
|
|
87
|
+
return { value: fresh, etag: etagFor(JSON.stringify(fresh) ?? "null") };
|
|
88
|
+
}
|
|
36
89
|
try {
|
|
37
90
|
const hit = await client.get(key);
|
|
38
|
-
if (hit !== null)
|
|
91
|
+
if (hit !== null) {
|
|
92
|
+
const value = JSON.parse(hit) as T;
|
|
93
|
+
try {
|
|
94
|
+
const stored = await client.get(etagKey(key));
|
|
95
|
+
return { value, etag: stored ?? etagFor(hit) };
|
|
96
|
+
} catch {
|
|
97
|
+
return { value, etag: etagFor(hit) };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
39
100
|
} catch {
|
|
40
|
-
|
|
101
|
+
// A cache read must never fail a request; compute instead.
|
|
41
102
|
}
|
|
42
103
|
const value = await compute();
|
|
104
|
+
const bytes = JSON.stringify(value) ?? "null";
|
|
105
|
+
const etag = etagFor(bytes);
|
|
43
106
|
try {
|
|
44
|
-
await client.set(key,
|
|
107
|
+
await client.set(key, bytes, "EX", ttlSeconds);
|
|
108
|
+
await client.set(etagKey(key), etag, "EX", ttlSeconds);
|
|
45
109
|
} catch {
|
|
46
110
|
// A cache write must never fail a request that already has its answer.
|
|
47
111
|
}
|
|
112
|
+
return { value, etag };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `undefined` when the row was never cached, so a computed `null` is cacheable. */
|
|
116
|
+
export async function cachedRead<T>(
|
|
117
|
+
client: ReadThroughCacheClient | undefined,
|
|
118
|
+
key: string,
|
|
119
|
+
compute: () => Promise<T>,
|
|
120
|
+
ttlSeconds: number = READ_THROUGH_DEFAULT_TTL_SECONDS,
|
|
121
|
+
): Promise<T> {
|
|
122
|
+
const { value } = await cachedReadWithEtag(client, key, compute, ttlSeconds);
|
|
48
123
|
return value;
|
|
49
124
|
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `cache-control` for a cached console read.
|
|
128
|
+
*
|
|
129
|
+
* `no-cache`, not `no-store`: the response may be stored, it simply must be
|
|
130
|
+
* revalidated, which is exactly what the ETag is for. `no-store` would forbid
|
|
131
|
+
* storing and so forbid the `304` this exists to enable.
|
|
132
|
+
*/
|
|
133
|
+
const CACHE_CONTROL = "no-cache";
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Answers a conditional GET from a {@link CachedResult}.
|
|
137
|
+
*
|
|
138
|
+
* A match returns a bodiless `304`. An `If-None-Match` that does not match, or
|
|
139
|
+
* a request that sent none, returns the body with the validator attached so
|
|
140
|
+
* the next poll can be the cheap one.
|
|
141
|
+
*
|
|
142
|
+
* `If-None-Match` may carry several validators or the `*` form, so the header
|
|
143
|
+
* is parsed rather than compared as one string.
|
|
144
|
+
*/
|
|
145
|
+
export function conditionalResponse<T>(
|
|
146
|
+
ifNoneMatch: string | null,
|
|
147
|
+
result: CachedResult<T>,
|
|
148
|
+
body: unknown,
|
|
149
|
+
): Response {
|
|
150
|
+
if (matchesEtag(ifNoneMatch, result.etag)) {
|
|
151
|
+
return new Response(null, {
|
|
152
|
+
status: 304,
|
|
153
|
+
headers: { etag: result.etag, "cache-control": CACHE_CONTROL },
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const response = Response.json(body);
|
|
157
|
+
response.headers.set("etag", result.etag);
|
|
158
|
+
response.headers.set("cache-control", CACHE_CONTROL);
|
|
159
|
+
return response;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** True when an `If-None-Match` header names this validator. */
|
|
163
|
+
export function matchesEtag(header: string | null, etag: string): boolean {
|
|
164
|
+
if (header === null) return false;
|
|
165
|
+
const trimmed = header.trim();
|
|
166
|
+
if (trimmed === "*") return true;
|
|
167
|
+
for (const part of trimmed.split(",")) {
|
|
168
|
+
// The weak prefix is stripped: a weak validator still names the same
|
|
169
|
+
// representation, and the client that sent it asked to be told if the
|
|
170
|
+
// current one matches.
|
|
171
|
+
const candidate = part.trim().replace(/^W\//, "");
|
|
172
|
+
if (candidate === etag) return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|