cartethyia 2.5.10 → 2.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +8 -0
- package/dist/dashboard/assets/{BannedPage-Cd30Z0b9.js → BannedPage-wr370U7a.js} +1 -1
- package/dist/dashboard/assets/BannedPage-wr370U7a.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-wf5u-ww6.js → CliToolDetailPage-D1sCezWa.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-D1sCezWa.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DykPvuck.js → CliToolsPage-DVWbJPEQ.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-DVWbJPEQ.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-DwvSD3vs.js → CombosPage-CZc7fyMe.js} +1 -1
- package/dist/dashboard/assets/CombosPage-CZc7fyMe.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-CIr7KDex.js → ConsoleLogPage-OPN3qRCa.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-OPN3qRCa.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-82X0DDdH.js → CustomizationPage-DGTSqmwm.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-DGTSqmwm.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BujG2QP4.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-BujG2QP4.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-CS5mPuVF.js → LoginPage-C_Jd_KgP.js} +1 -1
- package/dist/dashboard/assets/LoginPage-C_Jd_KgP.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-C0UCUsVJ.js → ModelPicker-DVny4S5_.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-Bpbu-VeU.js +1 -0
- package/dist/dashboard/assets/OverviewPage-Bpbu-VeU.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-BEhXOO4L.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-BEhXOO4L.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-vdja5ZPK.js → ProvidersPage-DbQnexQd.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-DbQnexQd.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-BB4CNm4-.js → ProxyPage-ByprzADo.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-ByprzADo.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-CvSfv6tP.js +1 -0
- package/dist/dashboard/assets/QuotaPage-CvSfv6tP.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-_NzLq3C-.js +1 -0
- package/dist/dashboard/assets/SettingsPage-_NzLq3C-.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-FadUOl9S.js → SetupPage-Bg9DcG-G.js} +1 -1
- package/dist/dashboard/assets/SetupPage-Bg9DcG-G.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-Cfy6_Khv.js → SortableList-C2PWPzJm.js} +1 -1
- package/dist/dashboard/assets/SortableList-C2PWPzJm.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CM92fgJf.js → StudioPage-Dky1zZMt.js} +1 -1
- package/dist/dashboard/assets/StudioPage-Dky1zZMt.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-BT4MyQeT.js → UsagePage-C5Vdpd_c.js} +2 -2
- package/dist/dashboard/assets/UsagePage-C5Vdpd_c.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
- package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
- package/dist/dashboard/assets/{app-B7pVRhJ1.js → app-2KhtHCv9.js} +1 -1
- package/dist/dashboard/assets/{app-B7pVRhJ1.js.gz → app-2KhtHCv9.js.gz} +0 -0
- package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
- package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
- package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
- package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
- package/dist/dashboard/assets/{badge-Bvyr7D0u.js → badge-CKsF123d.js} +1 -1
- package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
- package/dist/dashboard/assets/{banner-DIvKAZih.js → banner-8j7uChA9.js} +1 -1
- package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
- package/dist/dashboard/assets/{card-D5_gYIS_.js → card-CdsSlWA3.js} +1 -1
- package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
- package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-B6vDBtbm.js → clipboard-button-uL5aYLfW.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-CpjrgdwJ.js → dialog-C07hr5_G.js} +1 -1
- package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-BWmt3b5y.js → drawer-BPO1TcKH.js} +1 -1
- package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
- package/dist/dashboard/assets/entry-BNFjUjQ0.js +4 -0
- package/dist/dashboard/assets/entry-BNFjUjQ0.js.gz +0 -0
- package/dist/dashboard/assets/{entry-C_oakV3z.js → entry-Bz_CtDfC.js} +1 -1
- package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
- package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
- package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-RRHHWGxh.js → github-badge-DmQreSDd.js} +1 -1
- package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
- package/dist/dashboard/assets/{index-Bhey_rg_.js → index-DyllNVCY.js} +2 -2
- package/dist/dashboard/assets/index-DyllNVCY.js.gz +0 -0
- package/dist/dashboard/assets/{inline-C2_EQ5Uf.js → inline-C9dCYaBZ.js} +1 -1
- package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
- package/dist/dashboard/assets/{input-BMRlQ450.js → input-CjCnXweJ.js} +1 -1
- package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
- package/dist/dashboard/assets/{layout-XR5xP8ew.js → layout-V6y8DAkl.js} +1 -1
- package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-Cw07dioc.js → list-row-CM1AaG_y.js} +1 -1
- package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
- package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
- package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
- package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
- package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-DrdA9oSM.js → numbers-BOevJzdz.js} +1 -1
- package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
- package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
- package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
- package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
- package/dist/dashboard/assets/{select-CxVVB__N.js → select-DAhoTabv.js} +1 -1
- package/dist/dashboard/assets/select-DAhoTabv.js.gz +0 -0
- package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
- package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
- package/dist/dashboard/assets/{stack-uPOwM9Hs.js → stack-_gfTdLVB.js} +1 -1
- package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
- package/dist/dashboard/assets/{state-D1Yz79iL.js → state-2c5V4zo7.js} +1 -1
- package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
- package/dist/dashboard/assets/{switch-sevsRrRo.js → switch-Dkke7Naf.js} +1 -1
- package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
- package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +51 -51
- package/migrations/README.md +52 -52
- package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
- package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
- package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
- package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
- package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
- package/package.json +96 -95
- package/scripts/cli/admin.ts +126 -128
- package/scripts/cli/autostart.ts +237 -237
- package/scripts/cli/background.ts +123 -110
- package/scripts/cli/bin/carte.cjs +8 -8
- package/scripts/cli/bin/cartethyia.cjs +177 -167
- package/scripts/cli/console-menu.ts +45 -30
- package/scripts/cli/controller.ts +145 -0
- package/scripts/cli/env.ts +99 -99
- package/scripts/cli/gateway.ts +264 -260
- package/scripts/cli/instance.ts +234 -224
- package/scripts/cli/main.ts +195 -167
- package/scripts/cli/platform.ts +64 -64
- package/scripts/cli/prompt.ts +217 -79
- package/scripts/cli/services.ts +88 -88
- package/scripts/cli/setup.ts +445 -445
- package/scripts/cli/start.ts +85 -85
- package/scripts/cli/startup-signal.ts +23 -23
- package/scripts/cli/telemetry.ts +70 -0
- package/scripts/cli/tray/hidden-window.vbs +16 -2
- package/scripts/cli/tray/index.ts +263 -263
- package/scripts/cli/tray/windows.ps1 +107 -107
- package/scripts/cli/tray.ts +29 -4
- package/scripts/cli/update.ts +39 -39
- package/scripts/cli/version.ts +67 -67
- package/scripts/cli/wordmark.ts +58 -58
- package/src/boot/app.ts +16 -1
- package/src/boot/dependencies.ts +128 -25
- package/src/config/resources.ts +137 -0
- package/src/config/runtime.ts +133 -76
- package/src/config/spec.ts +30 -29
- package/src/config/telemetry.ts +0 -25
- package/src/console/api-keys/bucket-spend.ts +14 -17
- package/src/console/api-keys/contracts.ts +410 -410
- package/src/console/api-keys/routes.ts +12 -0
- package/src/console/audit/store.ts +2 -3
- package/src/console/auth/setup-service.ts +8 -1
- package/src/console/backup/routes.ts +7 -0
- package/src/console/backup/service.ts +463 -463
- package/src/console/backup/store.ts +16 -11
- package/src/console/backup/validate.ts +1 -4
- package/src/console/cli-tools/fs-ops.ts +18 -2
- package/src/console/data-management/contracts.ts +1 -8
- package/src/console/data-management/operations.ts +61 -48
- package/src/console/data-management/routes.ts +4 -2
- package/src/console/domain-registration.ts +25 -3
- package/src/console/inferhub/client.ts +139 -0
- package/src/console/inferhub/key-store.ts +129 -0
- package/src/console/inferhub/normalize.ts +347 -0
- package/src/console/inferhub/routes.ts +411 -0
- package/src/console/inferhub/store.ts +109 -0
- package/src/console/inferhub/types.ts +183 -0
- package/src/console/observability/contracts.ts +2 -41
- package/src/console/observability/store.ts +176 -371
- package/src/console/providers/catalog/public-model-store.ts +0 -0
- package/src/console/providers/catalog/store.ts +23 -36
- package/src/console/quota/account-quota-shared.ts +52 -52
- package/src/console/quota/account-quota-tenant.ts +16 -24
- package/src/console/quota/refresh.ts +441 -441
- package/src/console/routing/pools/import-parse.ts +206 -206
- package/src/console/routing/pools/operations.ts +543 -543
- package/src/console/routing/pools/probe-verdict.ts +132 -132
- package/src/console/routing/pools/routes.ts +181 -181
- package/src/console/settings/contracts.ts +42 -40
- package/src/console/settings/store.ts +25 -25
- package/src/console/share/share-stats.ts +22 -4
- package/src/console/share/share-usage.ts +30 -4
- package/src/console/shared/cursor.ts +15 -0
- package/src/console/updates/routes.ts +12 -0
- package/src/main.ts +13 -5
- package/src/network/pool/resolver.ts +1104 -1104
- package/src/observability/client-name.ts +22 -0
- package/src/observability/event-log.ts +5 -29
- package/src/observability/event-query.ts +690 -0
- package/src/observability/log-ring.ts +26 -2
- package/src/observability/log-sanitize.ts +118 -0
- package/src/observability/logger.ts +17 -13
- package/src/observability/metrics.ts +35 -0
- package/src/observability/payload-capture.ts +114 -41
- package/src/observability/payload-store.ts +249 -24
- package/src/observability/raw-event-writer.ts +118 -0
- package/src/observability/runtime-metrics.ts +71 -1
- package/src/observability/telemetry-buffer.ts +100 -17
- package/src/observability/telemetry-reconciliation.ts +417 -0
- package/src/persistence/boot.ts +20 -9
- package/src/persistence/db-pg.ts +13 -0
- package/src/persistence/db-sqlite.ts +569 -497
- package/src/persistence/dialect.ts +3 -2
- package/src/persistence/migrate.ts +181 -181
- package/src/persistence/schema.ts +1496 -1406
- package/src/persistence/shared-cache.ts +5 -1
- package/src/persistence/stores/api-key-store.ts +581 -581
- package/src/persistence/stores/telemetry-store.ts +124 -63
- package/src/protocol/reasoning-text.ts +30 -0
- package/src/protocol/response/chat.ts +33 -7
- package/src/protocol/response/codex.ts +14 -2
- package/src/protocol/response/messages.ts +5 -2
- package/src/providers/authentication/oauth-flow-store.ts +3 -10
- package/src/providers/bundled-discovery-providers.ts +122 -122
- package/src/providers/default-registry.ts +10 -0
- package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
- package/src/providers/discovery/models-dev-catalog.ts +354 -354
- package/src/providers/discovery/probe-phases.ts +43 -7
- package/src/providers/discovery/probing-service.ts +70 -32
- package/src/providers/integrations/antigravity/antigravity.ts +9 -2
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
- package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
- package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
- package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
- package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
- package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
- package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
- package/src/providers/integrations/claude/claude-quota.ts +2 -2
- package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
- package/src/providers/integrations/claude/claude.ts +3 -4
- package/src/providers/integrations/cline/cline.ts +94 -8
- package/src/providers/integrations/codex/codex.ts +61 -37
- package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
- package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
- package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
- package/src/providers/integrations/cursor/cursor-request.ts +11 -0
- package/src/providers/integrations/cursor/cursor.ts +29 -17
- package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
- package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
- package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
- package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
- package/src/providers/integrations/grok/grok-quota.ts +279 -279
- package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
- package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
- package/src/providers/integrations/hermes.ts +7 -1
- package/src/providers/integrations/inferhub.ts +201 -73
- package/src/providers/integrations/kilo/kilo.ts +152 -152
- package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
- package/src/providers/integrations/openrouter/provider.ts +129 -129
- package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
- package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
- package/src/providers/integrations/trae/trae-oauth.ts +4 -26
- package/src/providers/integrations/trae/trae-quota.ts +389 -384
- package/src/providers/integrations/trae/trae-realm.ts +15 -2
- package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
- package/src/providers/integrations/zcode/oauth.ts +1 -1
- package/src/providers/operations/account-health-service.ts +605 -279
- package/src/providers/provider-metadata.ts +12 -1
- package/src/providers/provider-model-definition.ts +373 -373
- package/src/providers/quota/quota-contracts.ts +3 -7
- package/src/providers/search/search-provider.ts +2 -6
- package/src/security/admission/contracts.ts +10 -0
- package/src/security/admission/in-memory-store.ts +89 -14
- package/src/security/admission/lua.ts +191 -182
- package/src/security/admission/redis-store.ts +30 -0
- package/src/security/admission/service.ts +18 -0
- package/src/security/api-key-auth-cache.ts +96 -0
- package/src/security/api-key-auth.ts +11 -100
- package/src/security/client-router-fingerprint.ts +3 -7
- package/src/security/ip-boundary.ts +7 -3
- package/src/shared/coerce.ts +67 -0
- package/src/shared/hash.ts +6 -0
- package/src/shared/time.ts +7 -0
- package/src/transport/dispatch/attempt-finalize.ts +26 -138
- package/src/transport/dispatch/attempt-loop.ts +103 -81
- package/src/transport/dispatch/capture-policy.ts +78 -0
- package/src/transport/dispatch/proxy-request.ts +4 -2
- package/src/transport/dispatch/streaming/encoder.ts +111 -5
- package/src/transport/dispatch/streaming/stream.ts +19 -2
- package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
- package/src/transport/failure-policy.ts +27 -0
- package/src/transport/guards/abuse-guard.ts +2 -2
- package/src/transport/guards/auth-guard.ts +17 -0
- package/src/transport/guards/body-policy.ts +3 -2
- package/src/transport/guards/concurrency-guard.ts +89 -13
- package/src/transport/guards/early-rejection-capture.ts +43 -3
- package/src/transport/guards/error-lifecycle.ts +10 -2
- package/src/transport/guards/guard-errors.ts +23 -0
- package/src/transport/guards/headroom-guard.ts +41 -7
- package/src/transport/guards/pipeline.ts +24 -0
- package/src/transport/guards/request-context.ts +2 -1
- package/src/transport/routing/admission.ts +51 -9
- package/src/transport/routing/bounded-lru.ts +49 -0
- package/src/transport/routing/candidate-index.ts +88 -0
- package/src/transport/routing/reservations.ts +121 -7
- package/src/transport/routing/round-robin.ts +10 -22
- package/src/transport/routing/route-catalog.ts +26 -13
- package/src/transport/routing/route-model.ts +22 -2
- package/src/transport/routing/router.ts +53 -31
- package/src/transport/surface/chat/encode.ts +2 -3
- package/src/transport/surface/chat/parse.ts +2 -1
- package/src/transport/surface/dialects.ts +11 -3
- package/src/transport/surface/messages/encode.ts +2 -1
- package/src/transport/surface/messages/parse.ts +3 -1
- package/src/transport/surface/messages/stream.ts +2 -6
- package/src/workers/maintenance-lease.ts +200 -0
- package/src/workers/scheduler.ts +101 -1
- package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js +0 -1
- package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js +0 -1
- package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js +0 -1
- package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-CM92fgJf.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
- package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
- package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
- package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
- package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
- package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
- package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
- package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
- package/dist/dashboard/assets/entry-B-gvimrg.js +0 -1
- package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
- package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
- package/dist/dashboard/assets/entry-Dj0cHfEc.js +0 -4
- package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
- package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
- package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
- package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
- package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
- package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
- package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
- package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
- package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
- package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
- package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
- package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
- package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
- package/src/observability/event-compaction.ts +0 -317
- package/src/observability/event-day-index.ts +0 -439
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
* client-visible tracking → encode + enqueue) is preserved exactly.
|
|
11
11
|
*/
|
|
12
12
|
import type { CanonicalEvent, SourceSurface, UsageRecord } from "../../../protocol/canonical-model";
|
|
13
|
+
import { canonicalTerminal } from "../../../protocol/codecs";
|
|
14
|
+
import { reasoningTextOf } from "../../../protocol/reasoning-text";
|
|
13
15
|
import { MimoThinkSplitter } from "../../../providers/integrations/xiaomi-mimo/think-stream";
|
|
14
16
|
import { createDispatchStreamEncoder, type DispatchStreamEncoder } from "../stream-bridge";
|
|
17
|
+
import { THINKING_LOOP_MARKER, ThinkingLoopDetector } from "./thinking-loop";
|
|
15
18
|
|
|
16
19
|
/** The terminal event shape every stream settles (or fails to settle) on. */
|
|
17
20
|
export type StreamTerminalEvent = Extract<CanonicalEvent, { type: "terminal" }>;
|
|
@@ -78,20 +81,41 @@ export class StreamEventPipeline {
|
|
|
78
81
|
// One decoder per stream: `new TextDecoder()` per chunk would allocate
|
|
79
82
|
// on every upstream event for the telemetry transcript copy.
|
|
80
83
|
private readonly clientResponseDecoder = new TextDecoder();
|
|
84
|
+
/**
|
|
85
|
+
* Resolved tenant capture mode. `none`/`metadata` gate the diagnostics the
|
|
86
|
+
* tenant will never be able to see: the JSON-serialized event transcript
|
|
87
|
+
* copy and the client-response accumulation are skipped entirely, so a
|
|
88
|
+
* privacy-lean tenant's streams cost neither the per-event `JSON.stringify`
|
|
89
|
+
* nor the up-to-512 KiB buffered client-response copy. The fields the
|
|
90
|
+
* completion path always needs (terminal, usage, timing, reported model,
|
|
91
|
+
* event count, client-visible flag) are recorded regardless — the Responses
|
|
92
|
+
* prelude retry depends on the flag, and the request row depends on the rest.
|
|
93
|
+
*/
|
|
94
|
+
private readonly diagnosticsCaptureMode: "none" | "metadata" | "full";
|
|
81
95
|
private readonly streamEncoder: DispatchStreamEncoder;
|
|
82
96
|
// One splitter per stream: a thinking tag can straddle two provider chunks,
|
|
83
97
|
// and the text/idle bookkeeping below must see the split result, not the raw
|
|
84
98
|
// tagged chunk.
|
|
85
99
|
private readonly thinkSplitter = new MimoThinkSplitter();
|
|
100
|
+
/**
|
|
101
|
+
* Content-level loop guard. A model that degenerates into a repeated
|
|
102
|
+
* chain-of-thought keeps sending bytes, so the stall timeout — which fires
|
|
103
|
+
* only on silence — never trips and the runaway burns the whole output
|
|
104
|
+
* budget. This detector watches the reasoning text itself and terminates the
|
|
105
|
+
* stream early when a loop shape is recognized.
|
|
106
|
+
*/
|
|
107
|
+
private readonly loopDetector = new ThinkingLoopDetector();
|
|
86
108
|
private lastSequenceNumber = -1;
|
|
87
109
|
|
|
88
110
|
constructor(
|
|
89
111
|
surface: SourceSurface,
|
|
90
112
|
encoderOptions: Record<string, unknown>,
|
|
91
113
|
recoverableResponsesPrelude: boolean,
|
|
114
|
+
diagnostics: { captureMode: "none" | "metadata" | "full" } = { captureMode: "full" },
|
|
92
115
|
) {
|
|
93
116
|
this.streamEncoder = createDispatchStreamEncoder(surface, encoderOptions);
|
|
94
117
|
this.recoverableResponsesPrelude = recoverableResponsesPrelude;
|
|
118
|
+
this.diagnosticsCaptureMode = diagnostics.captureMode;
|
|
95
119
|
}
|
|
96
120
|
|
|
97
121
|
/** Number of canonical events processed, including those omitted from diagnostics. */
|
|
@@ -104,6 +128,13 @@ export class StreamEventPipeline {
|
|
|
104
128
|
return this.clientVisibleEvent;
|
|
105
129
|
}
|
|
106
130
|
|
|
131
|
+
/** Whether the loop guard has terminated this stream early. The pull loop
|
|
132
|
+
* stops consuming the upstream once it has: continuing would keep billing
|
|
133
|
+
* and emitting a runaway the client was already told is over. */
|
|
134
|
+
get loopDetected(): boolean {
|
|
135
|
+
return this.loopDetector.detected;
|
|
136
|
+
}
|
|
137
|
+
|
|
107
138
|
/** The client-facing response transcript (decoded SSE/JSON) — distinct from the provider-side canonical events. */
|
|
108
139
|
get clientResponseText(): string {
|
|
109
140
|
const text = this.clientResponseChunks.join("");
|
|
@@ -114,6 +145,9 @@ export class StreamEventPipeline {
|
|
|
114
145
|
get telemetryEventTranscript():
|
|
115
146
|
| readonly (CanonicalEvent & { timestamp: number })[]
|
|
116
147
|
| TruncatedEventTranscript {
|
|
148
|
+
// Gated modes recorded nothing, so the transcript is the plain empty
|
|
149
|
+
// array — no truncated-marker object for content that was never kept.
|
|
150
|
+
if (this.diagnosticsCaptureMode !== "full") return [];
|
|
117
151
|
const tail = this.eventTranscriptTail.slice(this.eventTranscriptTailStart).map((entry) => entry.event);
|
|
118
152
|
const events = [...this.eventTranscriptHead, ...tail];
|
|
119
153
|
const omittedEvents = this.totalEventCount - events.length;
|
|
@@ -140,6 +174,9 @@ export class StreamEventPipeline {
|
|
|
140
174
|
|
|
141
175
|
private recordTelemetryEvent(event: CanonicalEvent & { timestamp: number }): void {
|
|
142
176
|
this.totalEventCount += 1;
|
|
177
|
+
// Gated diagnostics: the transcript is only ever read by the terminal
|
|
178
|
+
// capture path in `full` mode, so `none`/`metadata` pay only the count.
|
|
179
|
+
if (this.diagnosticsCaptureMode !== "full") return;
|
|
143
180
|
// Single serialization per event: the byte size is stored with the entry
|
|
144
181
|
// so eviction subtracts without re-serializing the removed event.
|
|
145
182
|
const eventBytes = Buffer.byteLength(JSON.stringify(event), "utf8");
|
|
@@ -179,6 +216,9 @@ export class StreamEventPipeline {
|
|
|
179
216
|
}
|
|
180
217
|
|
|
181
218
|
private appendClientResponse(bytes: Uint8Array): void {
|
|
219
|
+
// Gated diagnostics: the client-response copy feeds only the terminal
|
|
220
|
+
// capture's client panel, which exists only in `full` mode.
|
|
221
|
+
if (this.diagnosticsCaptureMode !== "full") return;
|
|
182
222
|
if (this.clientResponseTruncated) return;
|
|
183
223
|
const chunk = this.clientResponseDecoder.decode(bytes, { stream: true });
|
|
184
224
|
if (this.clientResponseLength + chunk.length > CLIENT_RESPONSE_CAP) {
|
|
@@ -213,6 +253,12 @@ export class StreamEventPipeline {
|
|
|
213
253
|
event: CanonicalEvent,
|
|
214
254
|
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
215
255
|
): boolean {
|
|
256
|
+
// Once the guard has terminated the stream, the surface encoders reject
|
|
257
|
+
// any further event — content *and* a second terminal both throw
|
|
258
|
+
// (`canonical event arrived after terminal`). The pull loop stops on
|
|
259
|
+
// `loopDetected`, so this only matters for an event already in flight;
|
|
260
|
+
// dropping it is the alternative to throwing mid-stream.
|
|
261
|
+
if (this.loopDetector.detected) return false;
|
|
216
262
|
// The one place every surface shares. A provider that writes its reasoning
|
|
217
263
|
// into the answer as `<think>…</think>` had that text forwarded verbatim:
|
|
218
264
|
// the chat wire happened to split it in its own decoder, so chat looked
|
|
@@ -222,11 +268,12 @@ export class StreamEventPipeline {
|
|
|
222
268
|
const split = this.thinkSplitter.push(event.content.text);
|
|
223
269
|
let enqueued = false;
|
|
224
270
|
if (split.reasoning.length > 0) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
271
|
+
const reasoningEvent: CanonicalEvent = {
|
|
272
|
+
...event,
|
|
273
|
+
content: { kind: "reasoning", payload: null, summary: split.reasoning },
|
|
274
|
+
};
|
|
275
|
+
enqueued = this.enqueueOne(reasoningEvent, controller) || enqueued;
|
|
276
|
+
if (this.guardReasoning(split.reasoning, controller)) return enqueued;
|
|
230
277
|
}
|
|
231
278
|
if (split.text.length > 0) {
|
|
232
279
|
enqueued =
|
|
@@ -235,6 +282,14 @@ export class StreamEventPipeline {
|
|
|
235
282
|
}
|
|
236
283
|
return enqueued;
|
|
237
284
|
}
|
|
285
|
+
// Reasoning a provider already classified: feed it to the guard too, so a
|
|
286
|
+
// loop is caught whether the reasoning arrived pre-classified or was
|
|
287
|
+
// recovered from a `<think>` span in the answer.
|
|
288
|
+
if (event.type === "content_delta" && event.content.kind === "reasoning") {
|
|
289
|
+
const enqueued = this.enqueueOne(event, controller);
|
|
290
|
+
this.guardReasoning(reasoningTextOf(event.content) ?? "", controller);
|
|
291
|
+
return enqueued;
|
|
292
|
+
}
|
|
238
293
|
// Text held back for a tag that never completed must be released *before*
|
|
239
294
|
// the terminal: a content event after it is rejected by the surface
|
|
240
295
|
// encoders, and the tail of the answer would be lost instead.
|
|
@@ -264,6 +319,57 @@ export class StreamEventPipeline {
|
|
|
264
319
|
return this.enqueueOne(event, controller);
|
|
265
320
|
}
|
|
266
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Feeds one reasoning chunk to the loop guard and, on the first hit,
|
|
324
|
+
* terminates the stream early. Returns whether it terminated.
|
|
325
|
+
*
|
|
326
|
+
* The terminal is a real canonical terminal rather than an error frame: the
|
|
327
|
+
* client already received valid reasoning, and the runaway is cut off
|
|
328
|
+
* mid-generation — which is exactly what `stop_reason: "length"` means on
|
|
329
|
+
* every surface (`length` / `max_tokens`). `provider_stop_reason` carries the
|
|
330
|
+
* marker so the cut is distinguishable from hitting the real token cap.
|
|
331
|
+
*/
|
|
332
|
+
private guardReasoning(
|
|
333
|
+
text: string,
|
|
334
|
+
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
335
|
+
): boolean {
|
|
336
|
+
const reason = text.length > 0 ? this.loopDetector.push(text) : undefined;
|
|
337
|
+
if (reason === undefined) return false;
|
|
338
|
+
// Flush before the terminal: content after it is rejected by the surface
|
|
339
|
+
// encoders, and the held-back text would be lost.
|
|
340
|
+
const rest = this.thinkSplitter.flush();
|
|
341
|
+
if (rest.reasoning.length > 0) {
|
|
342
|
+
this.enqueueOne(
|
|
343
|
+
{
|
|
344
|
+
type: "content_delta",
|
|
345
|
+
sequence_number: this.lastSequenceNumber + 1,
|
|
346
|
+
content: { kind: "reasoning", payload: null, summary: rest.reasoning },
|
|
347
|
+
},
|
|
348
|
+
controller,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
if (rest.text.length > 0) {
|
|
352
|
+
this.enqueueOne(
|
|
353
|
+
{
|
|
354
|
+
type: "content_delta",
|
|
355
|
+
sequence_number: this.lastSequenceNumber + 1,
|
|
356
|
+
content: { kind: "text", text: rest.text },
|
|
357
|
+
},
|
|
358
|
+
controller,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
this.enqueueOne(
|
|
362
|
+
canonicalTerminal({
|
|
363
|
+
sequenceNumber: this.lastSequenceNumber + 1,
|
|
364
|
+
state: "complete",
|
|
365
|
+
stopReason: "length",
|
|
366
|
+
providerStopReason: THINKING_LOOP_MARKER,
|
|
367
|
+
}),
|
|
368
|
+
controller,
|
|
369
|
+
);
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
|
|
267
373
|
private enqueueOne(
|
|
268
374
|
event: CanonicalEvent,
|
|
269
375
|
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
@@ -43,12 +43,13 @@ import type { AdmissionLease } from "../../../security/admission/contracts";
|
|
|
43
43
|
import type { NetworkPoolSelector } from "../../../network/pool/selector";
|
|
44
44
|
import type { TelemetryBatchBuffer } from "../../../observability/telemetry-buffer";
|
|
45
45
|
import type { ProviderExchangeCapture } from "../attempt-finalize";
|
|
46
|
+
import { captureProviderExchange } from "../attempt-finalize";
|
|
46
47
|
import {
|
|
47
|
-
captureProviderExchange,
|
|
48
48
|
providerCaptureMaxBytes,
|
|
49
49
|
resolveCaptureDepthForRequest,
|
|
50
|
+
resolveCaptureModeForRequest,
|
|
50
51
|
shouldCaptureProviderBodyForRequest,
|
|
51
|
-
} from "../
|
|
52
|
+
} from "../capture-policy";
|
|
52
53
|
import { resolveTenantImageClampMaxBytes } from "../../request/tenant-preferences";
|
|
53
54
|
import { proxySuccessHeaders, buildUpstreamDispatchContext } from "../upstream";
|
|
54
55
|
import type { AttemptLeaseOwnership } from "../leases";
|
|
@@ -179,6 +180,14 @@ export async function dispatchStreamingAttempt(input: StreamingDispatchInput): P
|
|
|
179
180
|
prepared.authorization.snapshot.tenant_id,
|
|
180
181
|
state,
|
|
181
182
|
);
|
|
183
|
+
// Resolved once, before the pipeline exists: the pipeline's diagnostics are
|
|
184
|
+
// gated on it from construction, so `none`/`metadata` tenants never pay the
|
|
185
|
+
// transcript serialization or the client-response copy for any event.
|
|
186
|
+
const captureMode = await resolveCaptureModeForRequest(
|
|
187
|
+
deps.db,
|
|
188
|
+
prepared.authorization.snapshot.tenant_id,
|
|
189
|
+
state,
|
|
190
|
+
);
|
|
182
191
|
const wrappedOutboundFetch = outboundFetch
|
|
183
192
|
? captureProviderExchange(
|
|
184
193
|
outboundFetch,
|
|
@@ -276,6 +285,7 @@ export async function dispatchStreamingAttempt(input: StreamingDispatchInput): P
|
|
|
276
285
|
? { response_id: `msg-${crypto.randomUUID()}`, model: candidate.model_id }
|
|
277
286
|
: streamOptions,
|
|
278
287
|
recoverableResponsesPrelude,
|
|
288
|
+
{ captureMode },
|
|
279
289
|
);
|
|
280
290
|
const finalizer = new StreamFinalizer(
|
|
281
291
|
{
|
|
@@ -400,6 +410,13 @@ export async function dispatchStreamingAttempt(input: StreamingDispatchInput): P
|
|
|
400
410
|
}
|
|
401
411
|
sawUpstreamActivity = true;
|
|
402
412
|
if (pipeline.enqueueEvent(result.value, controller)) return;
|
|
413
|
+
// The guard terminated the stream early: stop consuming. Pulling
|
|
414
|
+
// on would keep the runaway upstream alive and bill tokens the
|
|
415
|
+
// client was already told are not coming.
|
|
416
|
+
if (pipeline.loopDetected) {
|
|
417
|
+
await finalizer.finalizeStream(controller);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
403
420
|
}
|
|
404
421
|
} catch (err) {
|
|
405
422
|
finalizer.clearStallWatchdog();
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thinking-loop guard for streamed reasoning.
|
|
3
|
+
*
|
|
4
|
+
* A model that falls into a degenerate reasoning loop re-emits the same
|
|
5
|
+
* paragraph — byte-identical or with cosmetic wording drift — until it burns
|
|
6
|
+
* the whole output budget without ever answering. The stream never goes silent,
|
|
7
|
+
* so the only protection this codebase had (`streamStallTimeoutMs`) cannot fire:
|
|
8
|
+
* it watches for *absence* of bytes, and a runaway emits them continuously.
|
|
9
|
+
*
|
|
10
|
+
* This is a narrow port of the donor `ThinkingLoopDetector`
|
|
11
|
+
* (`Public/oh-my-pi/packages/ai/src/utils/thinking-loop.ts`). Ported:
|
|
12
|
+
* 1. **Exact suffix cycles** — a byte-identical unit repeated back-to-back.
|
|
13
|
+
* Model-independent and calibration-free.
|
|
14
|
+
* 2. **Near-duplicate segments** — paragraphs that normalize to the same
|
|
15
|
+
* word-trigram fingerprint.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately NOT ported: the donor's progress-lexicon novelty detector (its
|
|
18
|
+
* constants were calibrated against a 536k-block corpus we do not have) and its
|
|
19
|
+
* Gemini-specific summary-header runaway. Both would be uncalibrated guesses
|
|
20
|
+
* here and are better added later, with their corpora, if the shapes are
|
|
21
|
+
* actually observed.
|
|
22
|
+
*
|
|
23
|
+
* The detector is fed one reasoning delta at a time and returns a
|
|
24
|
+
* human-readable reason the first time a loop shape is recognized; every later
|
|
25
|
+
* call returns the same reason so a caller that does not stop stays harmless.
|
|
26
|
+
* Ported constants are copied verbatim — they are calibration values, not
|
|
27
|
+
* design choices.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Stable lead phrase of the guard's reason; exported for tests and for the
|
|
31
|
+
* terminal event's `provider_stop_reason`. */
|
|
32
|
+
export const THINKING_LOOP_MARKER = "Thinking loop detected";
|
|
33
|
+
|
|
34
|
+
/** Rolling tail retained for exact suffix-cycle detection. */
|
|
35
|
+
const EXACT_TAIL_WINDOW = 4096;
|
|
36
|
+
/** Longest exact cycle length considered. */
|
|
37
|
+
const EXACT_MAX_UNIT = 1024;
|
|
38
|
+
/** New characters between scans. Large deltas are scanned immediately. */
|
|
39
|
+
const EXACT_CHECK_STRIDE = 128;
|
|
40
|
+
/** Short cycles need four repeats covering at least this many characters. */
|
|
41
|
+
const EXACT_SHORT_MAX_UNIT = 60;
|
|
42
|
+
const EXACT_SHORT_MIN_REPEATED_CHARS = 180;
|
|
43
|
+
/** Long cycles need at least three repeats covering at least this many chars. */
|
|
44
|
+
const EXACT_LONG_MIN_REPEATED_CHARS = 1024;
|
|
45
|
+
/** Repeated characters before a punctuation-only run counts as a loop. Matches
|
|
46
|
+
* {@link EXACT_LONG_MIN_REPEATED_CHARS}: a kilobyte of one punctuation cycle
|
|
47
|
+
* back-to-back is degenerate, while the short separators real output produces
|
|
48
|
+
* (table rules, dividers) never reach it. */
|
|
49
|
+
const EXACT_PUNCTUATION_MIN_REPEATED_CHARS = EXACT_LONG_MIN_REPEATED_CHARS;
|
|
50
|
+
|
|
51
|
+
/** Char cap for an unterminated segment; forces a flush so a wall-of-text loop
|
|
52
|
+
* (no blank lines / headings) still segments. */
|
|
53
|
+
const SEGMENT_CHAR_CAP = 700;
|
|
54
|
+
/** Normalized-length floor below which a segment is ignored (too short to be a
|
|
55
|
+
* meaningful paragraph; bare headings must not trip detection). */
|
|
56
|
+
const SEGMENT_MIN_NORM_CHARS = 60;
|
|
57
|
+
/** How many recent substantial segments are kept for similarity comparison. */
|
|
58
|
+
const SEGMENT_WINDOW = 16;
|
|
59
|
+
/** Word-trigram Jaccard at/above which two segments count as near-duplicates. */
|
|
60
|
+
const SEGMENT_SIMILARITY = 0.8;
|
|
61
|
+
/** Substantial segments required before detection may fire (warm-up). */
|
|
62
|
+
const SEGMENT_MIN_COUNT = 8;
|
|
63
|
+
/** Near-duplicate cluster size (current + matches) that trips the loop. */
|
|
64
|
+
const SEGMENT_MIN_CLUSTER = 4;
|
|
65
|
+
|
|
66
|
+
/** A whole line shaped like code rather than prose: a fence marker, an indented
|
|
67
|
+
* line that is not a nested list item, or a line ending in a block, statement,
|
|
68
|
+
* or markup delimiter. Classified per line rather than by fence state because
|
|
69
|
+
* thought summaries routinely end inside an unclosed fence; tracking fences
|
|
70
|
+
* would hide every later prose paragraph from the heuristics. */
|
|
71
|
+
const CODE_LINE = /^(?:[ \t]*```.*|(?: {2,}|\t)(?![ \t]*(?:[-*+]|\d+[.)])[ \t]).*|.*[{}[\](;,>][ \t]*)$/gm;
|
|
72
|
+
|
|
73
|
+
export interface ThinkingLoopConfig {
|
|
74
|
+
readonly enabled?: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Stateful detector fed the streamed reasoning deltas of one stream. `push`
|
|
79
|
+
* returns a human-readable reason the first time a loop shape is recognized and
|
|
80
|
+
* the same reason on every later call, so a caller that keeps feeding after the
|
|
81
|
+
* first hit cannot double-report.
|
|
82
|
+
*/
|
|
83
|
+
export class ThinkingLoopDetector {
|
|
84
|
+
/** Rolling char tail for exact suffix-cycle detection. */
|
|
85
|
+
#tail = "";
|
|
86
|
+
/** Total characters received when the exact detector last scanned. */
|
|
87
|
+
#exactScannedAt = 0;
|
|
88
|
+
/** Pending reasoning text not yet split into completed segments. */
|
|
89
|
+
#pending = "";
|
|
90
|
+
/** Fingerprints of the most recent substantial segments (≤ SEGMENT_WINDOW). */
|
|
91
|
+
#window: Set<string>[] = [];
|
|
92
|
+
/** Count of substantial segments seen so far (warm-up gate). */
|
|
93
|
+
#count = 0;
|
|
94
|
+
/** The reason produced by the first hit; latched for idempotent re-reporting. */
|
|
95
|
+
#reason: string | undefined;
|
|
96
|
+
|
|
97
|
+
constructor(private readonly config: ThinkingLoopConfig = {}) {}
|
|
98
|
+
|
|
99
|
+
/** Whether a loop has already been detected. */
|
|
100
|
+
get detected(): boolean {
|
|
101
|
+
return this.#reason !== undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Feeds one reasoning delta. Returns the reason on and after the first hit. */
|
|
105
|
+
push(delta: string): string | undefined {
|
|
106
|
+
if (this.#reason !== undefined) return this.#reason;
|
|
107
|
+
if (this.config.enabled === false) return undefined;
|
|
108
|
+
if (delta.length === 0) return undefined;
|
|
109
|
+
|
|
110
|
+
// 1. Exact suffix cycles. Scan at a bounded cadence rather than doing
|
|
111
|
+
// quadratic work for every token-sized delta.
|
|
112
|
+
this.#tail += delta;
|
|
113
|
+
if (this.#tail.length > EXACT_TAIL_WINDOW) this.#tail = this.#tail.slice(-EXACT_TAIL_WINDOW);
|
|
114
|
+
this.#exactScannedAt += delta.length;
|
|
115
|
+
if (this.#exactScannedAt >= EXACT_CHECK_STRIDE || delta.length >= EXACT_CHECK_STRIDE) {
|
|
116
|
+
this.#exactScannedAt = 0;
|
|
117
|
+
const exact = detectExactSuffixCycle(this.#tail);
|
|
118
|
+
if (exact) {
|
|
119
|
+
const [unit, times] = exact;
|
|
120
|
+
this.#reason = `repeated an exact ${unit.length}-character cycle ${times}× back-to-back`;
|
|
121
|
+
return this.#reason;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 2. Near-duplicate paragraph loop. Append, then drain completed segments.
|
|
126
|
+
this.#pending += delta;
|
|
127
|
+
for (;;) {
|
|
128
|
+
const boundary = /\n\s*\n/.exec(this.#pending);
|
|
129
|
+
let raw: string;
|
|
130
|
+
if (boundary) {
|
|
131
|
+
raw = this.#pending.slice(0, boundary.index);
|
|
132
|
+
this.#pending = this.#pending.slice(boundary.index + boundary[0].length);
|
|
133
|
+
} else if (this.#pending.length > SEGMENT_CHAR_CAP) {
|
|
134
|
+
// No boundary yet but the segment is runaway-long: force a flush.
|
|
135
|
+
raw = this.#pending.slice(0, SEGMENT_CHAR_CAP);
|
|
136
|
+
this.#pending = this.#pending.slice(SEGMENT_CHAR_CAP);
|
|
137
|
+
} else {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
// An over-long segment is chunked so each piece stays comparable.
|
|
141
|
+
for (let rest = raw; rest.length > 0; ) {
|
|
142
|
+
const chunk = rest.length > SEGMENT_CHAR_CAP ? rest.slice(0, SEGMENT_CHAR_CAP) : rest;
|
|
143
|
+
rest = rest.slice(chunk.length);
|
|
144
|
+
const hit = this.#consumeSegment(chunk);
|
|
145
|
+
if (hit) {
|
|
146
|
+
this.#reason = hit;
|
|
147
|
+
return this.#reason;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Process the buffered trailing paragraph (one with no blank-line / heading
|
|
154
|
+
* terminator). Called when the reasoning block ends so the final segment —
|
|
155
|
+
* which may be the one that completes a duplicate cluster — is not dropped. */
|
|
156
|
+
flush(): string | undefined {
|
|
157
|
+
if (this.#reason !== undefined) return this.#reason;
|
|
158
|
+
if (this.config.enabled === false) return undefined;
|
|
159
|
+
// A stream can end before the next cadence boundary: force one final exact
|
|
160
|
+
// check even when nothing is pending.
|
|
161
|
+
const exact = detectExactSuffixCycle(this.#tail);
|
|
162
|
+
if (exact) {
|
|
163
|
+
const [unit, times] = exact;
|
|
164
|
+
this.#reason = `repeated an exact ${unit.length}-character cycle ${times}× back-to-back`;
|
|
165
|
+
return this.#reason;
|
|
166
|
+
}
|
|
167
|
+
if (this.#pending.length === 0) return undefined;
|
|
168
|
+
let rest = this.#pending;
|
|
169
|
+
this.#pending = "";
|
|
170
|
+
while (rest.length > 0) {
|
|
171
|
+
const chunk = rest.length > SEGMENT_CHAR_CAP ? rest.slice(0, SEGMENT_CHAR_CAP) : rest;
|
|
172
|
+
rest = rest.slice(chunk.length);
|
|
173
|
+
const hit = this.#consumeSegment(chunk);
|
|
174
|
+
if (hit) {
|
|
175
|
+
this.#reason = hit;
|
|
176
|
+
return this.#reason;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#consumeSegment(raw: string): string | undefined {
|
|
183
|
+
// Reasoning-summarizer titles ("**Maintaining Momentum**", "## Heading")
|
|
184
|
+
// are per-thought formatting, not chain-of-thought; their ever-changing
|
|
185
|
+
// wording would otherwise mask a loop by inflating novelty. Strip them and
|
|
186
|
+
// code lines before analysis (a title- or code-only segment then falls below
|
|
187
|
+
// the length gate).
|
|
188
|
+
const segment = raw
|
|
189
|
+
.replace(CODE_LINE, "")
|
|
190
|
+
.replace(/^[ \t]*#{1,6}[ \t].*$/gm, "")
|
|
191
|
+
.replace(/^[ \t]*\*{2,3}.+?\*{2,3}[ \t]*$/gm, "");
|
|
192
|
+
const normalized = normalizeSegment(segment);
|
|
193
|
+
if (normalized.length < SEGMENT_MIN_NORM_CHARS) return undefined;
|
|
194
|
+
|
|
195
|
+
// Near-duplicate trigram cluster: the same paragraph reused with cosmetic
|
|
196
|
+
// wording drift (high word-trigram overlap).
|
|
197
|
+
const fingerprint = trigramShingles(normalized);
|
|
198
|
+
let cluster = 1;
|
|
199
|
+
for (const prev of this.#window) {
|
|
200
|
+
if (jaccard(fingerprint, prev) >= SEGMENT_SIMILARITY) cluster++;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
this.#window.push(fingerprint);
|
|
204
|
+
if (this.#window.length > SEGMENT_WINDOW) this.#window.shift();
|
|
205
|
+
this.#count++;
|
|
206
|
+
|
|
207
|
+
if (this.#count >= SEGMENT_MIN_COUNT && cluster >= SEGMENT_MIN_CLUSTER) {
|
|
208
|
+
return `${cluster} near-identical segments within the last ${SEGMENT_WINDOW}`;
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Reused Z-array scratch; scans are synchronous, so one buffer suffices. */
|
|
215
|
+
const exactZ = new Uint16Array(EXACT_MAX_UNIT + 1);
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Detect an exact cycle at the text suffix. A Z-array over the reversed tail
|
|
219
|
+
* finds every possible suffix period in linear time without substring churn.
|
|
220
|
+
* The reversal is virtual (indexed from the end) and only `z[1..maxUnit]` is
|
|
221
|
+
* computed — the period check reads nothing past it, and each `z[i]` reads
|
|
222
|
+
* only `z[i - left]` with `i - left < i`, which is already filled. Short
|
|
223
|
+
* cycles retain the original 180-character/four-repeat sensitivity; long
|
|
224
|
+
* cycles require at least three repeats and 1024 repeated characters.
|
|
225
|
+
*
|
|
226
|
+
* A unit carrying a letter or an emoji matches at those thresholds. A unit with
|
|
227
|
+
* neither, such as the `?!?!` run a stuck model emits, matches only once it has
|
|
228
|
+
* repeated for {@link EXACT_PUNCTUATION_MIN_REPEATED_CHARS} characters, and never
|
|
229
|
+
* when it holds only digits and whitespace: padding, indentation and zero-filled
|
|
230
|
+
* hexdumps or literals are ordinary output at any length.
|
|
231
|
+
*/
|
|
232
|
+
function detectExactSuffixCycle(text: string): [unit: string, count: number] | undefined {
|
|
233
|
+
const n = text.length;
|
|
234
|
+
if (n < EXACT_SHORT_MIN_REPEATED_CHARS) return undefined;
|
|
235
|
+
const last = n - 1;
|
|
236
|
+
const maxUnit = Math.min(EXACT_MAX_UNIT, Math.floor(n / 3));
|
|
237
|
+
const z = exactZ;
|
|
238
|
+
let left = 0;
|
|
239
|
+
let right = 0;
|
|
240
|
+
for (let i = 1; i <= maxUnit; i++) {
|
|
241
|
+
let zi = i <= right ? Math.min(right - i + 1, z[i - left]!) : 0;
|
|
242
|
+
// `z[i - left]` is always filled: `i - left < i` and `left >= 0`.
|
|
243
|
+
while (i + zi < n && text.charCodeAt(last - zi) === text.charCodeAt(last - i - zi)) zi++;
|
|
244
|
+
z[i] = zi;
|
|
245
|
+
if (i + zi - 1 > right) {
|
|
246
|
+
left = i;
|
|
247
|
+
right = i + zi - 1;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
for (let len = 2; len <= maxUnit; len++) {
|
|
252
|
+
// Every `z[len]` in this range was written by the loop above.
|
|
253
|
+
const count = 1 + Math.floor(z[len]! / len);
|
|
254
|
+
const minCount = len <= EXACT_SHORT_MAX_UNIT ? 4 : 3;
|
|
255
|
+
if (count < minCount) continue;
|
|
256
|
+
const unit = text.slice(-len);
|
|
257
|
+
if (/\p{L}|\p{Extended_Pictographic}/u.test(unit)) {
|
|
258
|
+
const minChars = len <= EXACT_SHORT_MAX_UNIT ? EXACT_SHORT_MIN_REPEATED_CHARS : EXACT_LONG_MIN_REPEATED_CHARS;
|
|
259
|
+
if (len * count >= minChars) return [unit, count];
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (/^[\p{N}\s]*$/u.test(unit)) continue;
|
|
263
|
+
if (len * count >= EXACT_PUNCTUATION_MIN_REPEATED_CHARS) return [unit, count];
|
|
264
|
+
}
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Lowercase and tokenize prose plus code/path payloads, dropping pure numbers. */
|
|
269
|
+
function normalizeSegment(segment: string): string {
|
|
270
|
+
return segment
|
|
271
|
+
.toLowerCase()
|
|
272
|
+
.replace(/`([^`]*)`/g, " $1 ")
|
|
273
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
274
|
+
.split(/\s+/)
|
|
275
|
+
.filter((token) => /[a-z]/.test(token))
|
|
276
|
+
.join(" ")
|
|
277
|
+
.trim();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Word-trigram shingle set of a normalized segment. */
|
|
281
|
+
function trigramShingles(normalized: string): Set<string> {
|
|
282
|
+
const words = normalized.split(" ").filter(Boolean);
|
|
283
|
+
if (words.length < 3) return new Set(words.length > 0 ? [words.join(" ")] : []);
|
|
284
|
+
const shingles = new Set<string>();
|
|
285
|
+
for (let i = 0; i + 3 <= words.length; i++) {
|
|
286
|
+
shingles.add(`${words[i]} ${words[i + 1]} ${words[i + 2]}`);
|
|
287
|
+
}
|
|
288
|
+
return shingles;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function jaccard(a: Set<string>, b: Set<string>): number {
|
|
292
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
293
|
+
const [small, large] = a.size < b.size ? [a, b] : [b, a];
|
|
294
|
+
let intersection = 0;
|
|
295
|
+
for (const x of small) {
|
|
296
|
+
if (large.has(x)) intersection++;
|
|
297
|
+
}
|
|
298
|
+
const union = a.size + b.size - intersection;
|
|
299
|
+
return union === 0 ? 0 : intersection / union;
|
|
300
|
+
}
|
|
@@ -582,6 +582,8 @@ const CONTEXT_LENGTH_CODES: ReadonlySet<string> = new Set([
|
|
|
582
582
|
"context_length_exceeded",
|
|
583
583
|
"context_too_large",
|
|
584
584
|
"model_context_window_exceeded",
|
|
585
|
+
// InferHub's spelling for the same condition.
|
|
586
|
+
"context_too_long",
|
|
585
587
|
]);
|
|
586
588
|
|
|
587
589
|
/**
|
|
@@ -733,6 +735,14 @@ const QUOTA_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
|
|
|
733
735
|
"usage_not_included",
|
|
734
736
|
"subscription:free-usage-exhausted",
|
|
735
737
|
"resource_exhausted",
|
|
738
|
+
// InferHub publishes its own vocabulary in `error.code`. The two 403s below
|
|
739
|
+
// are deliberate absences: `account_blocked` means the account was blocked
|
|
740
|
+
// (an auth/entitlement verdict, not a spend limit) and
|
|
741
|
+
// `model_not_allowed_for_key` is a per-key allowlist refusal — both are
|
|
742
|
+
// classified separately rather than cooled as quota.
|
|
743
|
+
"rate_limited",
|
|
744
|
+
"providers_out_of_quota",
|
|
745
|
+
"providers_at_capacity",
|
|
736
746
|
]);
|
|
737
747
|
const CAPACITY_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
|
|
738
748
|
"overloaded_error",
|
|
@@ -740,6 +750,9 @@ const CAPACITY_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
|
|
|
740
750
|
"model_at_capacity",
|
|
741
751
|
"server_is_overloaded",
|
|
742
752
|
"model_capacity",
|
|
753
|
+
// InferHub: no live provider could take the request right now.
|
|
754
|
+
"providers_busy",
|
|
755
|
+
"providers_overloaded",
|
|
743
756
|
]);
|
|
744
757
|
const AUTH_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
|
|
745
758
|
"authentication_error",
|
|
@@ -749,6 +762,12 @@ const AUTH_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
|
|
|
749
762
|
"unauthenticated",
|
|
750
763
|
"permission_denied",
|
|
751
764
|
"permissiondenied",
|
|
765
|
+
// InferHub: the account was blocked, and a key whose scope or allowlist does
|
|
766
|
+
// not cover the request. `credentialEvidence` is what cools the account
|
|
767
|
+
// rather than the provider.
|
|
768
|
+
"account_blocked",
|
|
769
|
+
"insufficient_key_scope",
|
|
770
|
+
"model_not_allowed_for_key",
|
|
752
771
|
]);
|
|
753
772
|
const PLATFORM_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
|
|
754
773
|
"server_error",
|
|
@@ -895,6 +914,14 @@ export function classifyUpstreamError(
|
|
|
895
914
|
};
|
|
896
915
|
if (normalized === "model_not_found")
|
|
897
916
|
return { code: "model_not_found", status: 404, origin: "upstream" };
|
|
917
|
+
// InferHub: no live publisher serves the requested model at all. That is a
|
|
918
|
+
// property of the model/upstream pair, not of the account or a spend limit,
|
|
919
|
+
// so it is not retried as quota and does not cool the account — the next
|
|
920
|
+
// attempt should pick a different route.
|
|
921
|
+
if (normalized === "no_provider")
|
|
922
|
+
return { code: "model_not_found", status: 404, origin: "upstream" };
|
|
923
|
+
if (normalized === "content_rejected")
|
|
924
|
+
return { code: "policy_rejected", status: 403, origin: "upstream" };
|
|
898
925
|
if (
|
|
899
926
|
normalized === "not_found_error" ||
|
|
900
927
|
normalized === "resource_not_found" ||
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// IP-abuse protection guard: counts every `/v1/*` attempt against the per-IP
|
|
2
2
|
// ceiling via the root `request` hook.
|
|
3
3
|
import { Elysia } from "elysia";
|
|
4
|
-
import { GatewayError } from "../../protocol/gateway-error";
|
|
5
4
|
import type { TrustedProxyBoundary } from "../../config/network";
|
|
6
5
|
import { resolveClientIdentity } from "../../security/ip-boundary";
|
|
7
6
|
import type { ProxyRequestStateStore } from "../request/state";
|
|
8
7
|
import { fastPathname } from "../pathname";
|
|
9
8
|
import type { IpAbuseProtectionService } from "../../security/abuse";
|
|
9
|
+
import { peerAddressUnavailableError } from "./guard-errors";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Counts every `/v1/*` attempt against the per-IP ceiling, including the ones
|
|
@@ -66,7 +66,7 @@ export function createIpAbuseProtectionMiddleware(deps: {
|
|
|
66
66
|
? deps.resolvePeerAddress(request)
|
|
67
67
|
: (server?.requestIP(request)?.address ?? null);
|
|
68
68
|
if (!peer)
|
|
69
|
-
throw
|
|
69
|
+
throw peerAddressUnavailableError();
|
|
70
70
|
const address = resolveClientIdentity(request, deps.trustedProxyBoundary, peer);
|
|
71
71
|
const state = deps.stateStore.get(request);
|
|
72
72
|
try {
|
|
@@ -28,6 +28,12 @@ export function createApiKeyAuthenticationMiddleware(deps: {
|
|
|
28
28
|
* console error, which is exactly the noise this layer exists to stop.
|
|
29
29
|
*/
|
|
30
30
|
readonly modelStrikes?: Pick<ModelStrikeService, "check">;
|
|
31
|
+
/**
|
|
32
|
+
* Tenant invoke kill switch. Checked after the key resolves, because the
|
|
33
|
+
* tenant is a property of the key: the same gateway serves several tenants
|
|
34
|
+
* and one switching itself off must not stop the others.
|
|
35
|
+
*/
|
|
36
|
+
readonly isInvokeDisabled?: (tenantId: string) => Promise<boolean | undefined>;
|
|
31
37
|
}): Elysia {
|
|
32
38
|
// Single policy for every `/v1/*` gateway route: authenticated, scoped to
|
|
33
39
|
// `routing:invoke`, and tenant-bound (the snapshot's tenant is the only
|
|
@@ -73,6 +79,17 @@ export function createApiKeyAuthenticationMiddleware(deps: {
|
|
|
73
79
|
});
|
|
74
80
|
if (!decision.scopes.includes("routing:invoke"))
|
|
75
81
|
throw new GatewayError("invalid_request", 403, "This API key is not allowed to invoke routing. Grant it the routing:invoke scope, or use a key that has it.");
|
|
82
|
+
// Kill switch: refuse before any upstream is touched. Distinct from the
|
|
83
|
+
// scope check above, which is a property of the key — this is the tenant
|
|
84
|
+
// having stopped all traffic, and it applies to every key the tenant owns.
|
|
85
|
+
// 503 rather than 403: nothing about the request is invalid, and the
|
|
86
|
+
// caller should retry later instead of "fixing" a request that is fine.
|
|
87
|
+
if ((await deps.isInvokeDisabled?.(authorization.tenantId)) === true)
|
|
88
|
+
throw new GatewayError(
|
|
89
|
+
"platform_unavailable",
|
|
90
|
+
503,
|
|
91
|
+
"This gateway has invoke switched off. Nothing upstream is being dialed while the switch is off; turn it back on from the dashboard overview to resume routing.",
|
|
92
|
+
);
|
|
76
93
|
// A key may refuse a downstream router it has been resold to. This is a
|
|
77
94
|
// policy check on the resolved key, so it belongs with the other
|
|
78
95
|
// authorization decisions rather than in routing: the request is refused
|