cartethyia 2.5.9 → 2.6.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 +15 -42
- package/README.md +11 -10
- package/dist/dashboard/assets/{BannedPage-Brvuj_Eb.js → BannedPage-l6XMnfr9.js} +1 -1
- package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-CqfXtI3y.js → CliToolDetailPage-DwOzAW45.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-GQeS91bg.js → CliToolsPage-BT3Up3QA.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BDRsTXBB.js → CombosPage-6kYIUpzR.js} +1 -1
- package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-zP8AFHX0.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-BmfrNZ14.js → CustomizationPage-C7m-H1Rz.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-DoreIgm8.js → LoginPage-CbphLvjg.js} +1 -1
- package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-C7pDb41R.js → ModelPicker-DVny4S5_.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
- package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-BnoPgA7w.js → ProvidersPage-Dfb2896L.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-Bq_TGgdb.js → ProxyPage-BaCIdAUt.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
- package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
- package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-Day3mCOn.js → SetupPage-D4g6vOGc.js} +1 -1
- package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-DQxllnB5.js → SortableList-C93i14BD.js} +1 -1
- package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-uMExoWu1.js → StudioPage-SgQpymjm.js} +1 -1
- package/dist/dashboard/assets/{StudioPage-uMExoWu1.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
- package/dist/dashboard/assets/{UsagePage-DXa1oR-4.js → UsagePage-BFME-eba.js} +2 -2
- package/dist/dashboard/assets/UsagePage-BFME-eba.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-BIt446yE.js → app-2KhtHCv9.js} +1 -1
- package/dist/dashboard/assets/{app-BIt446yE.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-CR72dasT.js → badge-CKsF123d.js} +1 -1
- package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
- package/dist/dashboard/assets/{banner-CI9_eSfO.js → banner-8j7uChA9.js} +1 -1
- package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
- package/dist/dashboard/assets/{card-DS7CQEsO.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-611pSsaX.js → clipboard-button-uL5aYLfW.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-k37exjNt.js → dialog-C07hr5_G.js} +1 -1
- package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-CX7N8ucR.js → drawer-BPO1TcKH.js} +1 -1
- package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CTP8AyiS.js → entry-Bz_CtDfC.js} +1 -1
- package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
- package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
- package/dist/dashboard/assets/entry-CwPRKk0d.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-sltk1qUi.js → github-badge-DmQreSDd.js} +1 -1
- package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
- package/dist/dashboard/assets/{index-z3zDOSMM.js → index-Bblu6erR.js} +2 -2
- package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
- package/dist/dashboard/assets/{inline-BhY2sSnO.js → inline-C9dCYaBZ.js} +1 -1
- package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
- package/dist/dashboard/assets/{input-V1umFiBk.js → input-CjCnXweJ.js} +1 -1
- package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
- package/dist/dashboard/assets/{layout-BBOYycyS.js → layout-V6y8DAkl.js} +1 -1
- package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-1zzZYM0B.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-DkfMR5Qs.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-C6MMiaio.js → select-C980c4cz.js} +1 -1
- package/dist/dashboard/assets/select-C980c4cz.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-BHTfXdz_.js → stack-_gfTdLVB.js} +1 -1
- package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
- package/dist/dashboard/assets/{state-mPDRlQkl.js → state-2c5V4zo7.js} +1 -1
- package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DOmJxg7H.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 -97
- package/scripts/cli/admin.ts +126 -125
- package/scripts/cli/autostart.ts +237 -237
- package/scripts/cli/background.ts +120 -110
- package/scripts/cli/bin/carte.cjs +8 -8
- package/scripts/cli/bin/cartethyia.cjs +167 -167
- package/scripts/cli/console-menu.ts +308 -275
- package/scripts/cli/controller.ts +145 -0
- package/scripts/cli/doctor.ts +1 -1
- package/scripts/cli/env.ts +99 -99
- package/scripts/cli/gateway.ts +264 -261
- package/scripts/{health.ts → cli/health.ts} +4 -4
- package/scripts/cli/instance.ts +234 -224
- package/scripts/cli/main.ts +188 -164
- package/scripts/cli/platform.ts +64 -64
- package/scripts/cli/prompt.ts +275 -107
- package/scripts/cli/services.ts +88 -88
- package/scripts/cli/setup.ts +445 -445
- package/scripts/cli/start.ts +85 -82
- package/scripts/cli/startup-signal.ts +23 -23
- package/scripts/cli/telemetry.ts +70 -0
- package/scripts/cli/tray/index.ts +263 -263
- package/scripts/cli/tray/windows.ps1 +107 -107
- package/scripts/cli/tray.ts +192 -208
- 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/package-paths.ts +24 -0
- 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/remote-support/service.ts +270 -114
- 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/routing/pools/store.ts +1 -1
- 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/repository-updates.ts +40 -39
- 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/registry.ts +2 -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 +14 -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/capabilities.ts +4 -0
- 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/github/github.ts +3 -1
- 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/opencode/provider.ts +2 -0
- 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-headers.ts +17 -48
- 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/trae/trae.ts +22 -24
- 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/provider-probe.ts +3 -0
- 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-Brvuj_Eb.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-CqfXtI3y.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-GQeS91bg.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BDRsTXBB.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-zP8AFHX0.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-BmfrNZ14.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DoreIgm8.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js +0 -1
- package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-BnoPgA7w.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-Bq_TGgdb.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-D9DHXvom.js +0 -1
- package/dist/dashboard/assets/QuotaPage-D9DHXvom.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js +0 -1
- package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-Day3mCOn.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DQxllnB5.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-DXa1oR-4.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-CR72dasT.js.gz +0 -0
- package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
- package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
- package/dist/dashboard/assets/entry-B9Xvtnfj.js +0 -1
- package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
- package/dist/dashboard/assets/entry-ByMGkR3C.js +0 -4
- package/dist/dashboard/assets/entry-ByMGkR3C.js.gz +0 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
- package/dist/dashboard/assets/index-z3zDOSMM.js.gz +0 -0
- package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
- package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
- package/dist/dashboard/assets/list-row-1zzZYM0B.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-DkfMR5Qs.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-C6MMiaio.js.gz +0 -0
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
- package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
- package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
- package/scripts/build/aot.ts +0 -143
- package/scripts/build/build.ts +0 -24
- package/scripts/build/compile.ts +0 -88
- package/scripts/build/restart.ts +0 -120
- package/scripts/build/start.ts +0 -25
- package/src/observability/event-compaction.ts +0 -317
- package/src/observability/event-day-index.ts +0 -440
|
@@ -206,6 +206,17 @@ export interface AccountFailureEvidence {
|
|
|
206
206
|
* count it reads itself.
|
|
207
207
|
*/
|
|
208
208
|
readonly modelBlockHits?: number;
|
|
209
|
+
/**
|
|
210
|
+
* The upstream's own error body, when the caller has it.
|
|
211
|
+
*
|
|
212
|
+
* Providers state a reset window in structured fields far more reliably than
|
|
213
|
+
* in prose — a body carrying `resets_at`, `resets_in_seconds` or
|
|
214
|
+
* `limit_window_minutes` is an exact statement, while the same body's
|
|
215
|
+
* `message` ("The usage limit has been reached") names no window at all.
|
|
216
|
+
* Without this the classifier falls back to a fixed 15-minute cooldown for a
|
|
217
|
+
* limit the provider said resets in 23 days.
|
|
218
|
+
*/
|
|
219
|
+
readonly upstreamBody?: unknown;
|
|
209
220
|
}
|
|
210
221
|
|
|
211
222
|
export interface AccountErrorClassification {
|
|
@@ -327,11 +338,157 @@ function accountDeadlineMs(
|
|
|
327
338
|
return latest > 0 ? new Date(latest) : null;
|
|
328
339
|
}
|
|
329
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Bounded to a day, matching `MAX_COOLDOWN_MS` in the failure policy: a stated
|
|
343
|
+
* window longer than that is a monthly or plan-level limit, and parking an
|
|
344
|
+
* account for a month on one upstream field is a decision for the operator,
|
|
345
|
+
* not an automatic one.
|
|
346
|
+
*/
|
|
347
|
+
const MAX_UPSTREAM_STATED_COOLDOWN_MS = 24 * 3600 * 1000;
|
|
330
348
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
349
|
+
/** Parses the stored upstream text as JSON. Returns `undefined` when it is not. */
|
|
350
|
+
function tryParseUpstreamBody(text: string): unknown {
|
|
351
|
+
try {
|
|
352
|
+
const parsed: unknown = JSON.parse(text);
|
|
353
|
+
return typeof parsed === "object" && parsed !== null ? parsed : undefined;
|
|
354
|
+
} catch {
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Reads a reset window the upstream stated in structured fields.
|
|
361
|
+
*
|
|
362
|
+
* A body like `{"error":{"type":"usage_limit_reached","resets_at":1795019113,
|
|
363
|
+
* "resets_in_seconds":2072028}}` states the window exactly, while its own
|
|
364
|
+
* `message` ("The usage limit has been reached") states none — so parsing only
|
|
365
|
+
* prose leaves a 23-day limit cooling for the 15-minute fallback, and the
|
|
366
|
+
* account re-enters rotation hours before it can serve.
|
|
367
|
+
*
|
|
368
|
+
* Both spellings are read because providers use both: an absolute epoch
|
|
369
|
+
* (`resets_at`, seconds or milliseconds) and a relative delay
|
|
370
|
+
* (`resets_in_seconds`). The later of the two wins, since they are the same
|
|
371
|
+
* claim computed at slightly different instants.
|
|
372
|
+
*/
|
|
373
|
+
function parseUpstreamBodyReset(body: unknown): number | null {
|
|
374
|
+
if (typeof body !== "object" || body === null) return null;
|
|
375
|
+
const record = body as Record<string, unknown>;
|
|
376
|
+
// Some providers nest under `error`, some return it at the top level.
|
|
377
|
+
const candidates = [record, (record["error"] as Record<string, unknown> | undefined) ?? undefined];
|
|
378
|
+
let best: number | null = null;
|
|
379
|
+
for (const candidate of candidates) {
|
|
380
|
+
if (typeof candidate !== "object" || candidate === null) continue;
|
|
381
|
+
const now = Date.now();
|
|
382
|
+
|
|
383
|
+
const absolute = candidate["resets_at"] ?? candidate["reset_at"];
|
|
384
|
+
if (typeof absolute === "number" && Number.isFinite(absolute) && absolute > 0) {
|
|
385
|
+
// Seconds vs milliseconds: a plausible epoch in seconds is ~10 digits, in
|
|
386
|
+
// milliseconds ~13, and the two are four orders of magnitude apart — so
|
|
387
|
+
// the smaller is scaled up rather than guessed from a threshold date.
|
|
388
|
+
const epochMs = absolute < 1e11 ? absolute * 1000 : absolute;
|
|
389
|
+
const delay = epochMs - now;
|
|
390
|
+
if (delay > 0) best = best === null ? delay : Math.max(best, delay);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const relative =
|
|
394
|
+
candidate["resets_in_seconds"] ??
|
|
395
|
+
candidate["reset_in_seconds"] ??
|
|
396
|
+
candidate["retry_after_seconds"];
|
|
397
|
+
if (typeof relative === "number" && Number.isFinite(relative) && relative > 0) {
|
|
398
|
+
const delay = relative * 1000;
|
|
399
|
+
best = best === null ? delay : Math.max(best, delay);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const minutes = candidate["limit_window_minutes"] ?? candidate["reset_in_minutes"];
|
|
403
|
+
if (typeof minutes === "number" && Number.isFinite(minutes) && minutes > 0) {
|
|
404
|
+
const delay = minutes * 60_000;
|
|
405
|
+
best = best === null ? delay : Math.max(best, delay);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return best === null ? null : Math.min(MAX_UPSTREAM_STATED_COOLDOWN_MS, Math.round(best));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
interface AccountErrorContext {
|
|
413
|
+
readonly message: string;
|
|
414
|
+
readonly lower: string;
|
|
415
|
+
readonly folded: string;
|
|
416
|
+
readonly statusCode: number | null;
|
|
417
|
+
readonly origin: AccountFailureOrigin;
|
|
418
|
+
readonly scope: AccountFailureScope;
|
|
419
|
+
readonly revocationNamed: boolean;
|
|
420
|
+
readonly accountEvidence: boolean;
|
|
421
|
+
readonly canMutate: boolean;
|
|
422
|
+
readonly providerCode: string;
|
|
423
|
+
readonly statedCooldownMs: number | null;
|
|
424
|
+
readonly reason: (prefix: string) => string;
|
|
425
|
+
readonly result: (
|
|
426
|
+
category: AccountErrorCategory,
|
|
427
|
+
status: AccountErrorClassification["status"],
|
|
428
|
+
cooldownMs: number,
|
|
429
|
+
retryAt: Date | null,
|
|
430
|
+
explanation: string,
|
|
431
|
+
mutatesAccount?: boolean,
|
|
432
|
+
modelUnsupported?: boolean,
|
|
433
|
+
) => AccountErrorClassification;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* One arm of the classifier: an ordered `(match, decide)` pair.
|
|
438
|
+
*
|
|
439
|
+
* The arms are **not** independent predicates. Later arms are deliberately
|
|
440
|
+
* broader than earlier ones — `quotaSignal` matches every price refusal, and
|
|
441
|
+
* `authSignal` matches a superset of the request-scoped codes — so a match
|
|
442
|
+
* only wins because it is tried first. That is why they live in one ordered
|
|
443
|
+
* list rather than behind a dispatch map or a set of independent `if`s: the
|
|
444
|
+
* list is the precedence, and reading it top to bottom is reading the
|
|
445
|
+
* decision.
|
|
446
|
+
*
|
|
447
|
+
* `match` is a pure function of the shared signals (no closure over the
|
|
448
|
+
* classifier's locals, no allocation per classification): the table is built
|
|
449
|
+
* once per call from values already computed for every arm.
|
|
450
|
+
*/
|
|
451
|
+
interface AccountErrorRule {
|
|
452
|
+
readonly name: string;
|
|
453
|
+
readonly match: (signals: AccountErrorSignals) => boolean;
|
|
454
|
+
readonly decide: (signals: AccountErrorSignals) => AccountErrorClassification;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The shared signals every arm reads, computed once per classification. */
|
|
458
|
+
interface AccountErrorSignals {
|
|
459
|
+
readonly message: string;
|
|
460
|
+
readonly lower: string;
|
|
461
|
+
readonly folded: string;
|
|
462
|
+
readonly statusCode: number | null;
|
|
463
|
+
readonly origin: AccountFailureOrigin;
|
|
464
|
+
readonly scope: AccountFailureScope;
|
|
465
|
+
readonly providerCode: string;
|
|
466
|
+
readonly statedCooldownMs: number | null;
|
|
467
|
+
readonly reason: (prefix: string) => string;
|
|
468
|
+
readonly result: AccountErrorContext["result"];
|
|
469
|
+
readonly canMutate: boolean;
|
|
470
|
+
readonly accountEvidence: boolean;
|
|
471
|
+
readonly revocationNamed: boolean;
|
|
472
|
+
/** Echo of the caller's evidence; read only by the arms that need it. */
|
|
473
|
+
readonly modelId: string | undefined;
|
|
474
|
+
readonly modelBlockHits: number;
|
|
475
|
+
readonly staticToken: boolean;
|
|
476
|
+
readonly credentialKind: string | undefined;
|
|
477
|
+
/** Buddy-family provider: the only family with provider-specific codes below. */
|
|
478
|
+
readonly buddy: boolean;
|
|
479
|
+
readonly priceRefusal: boolean;
|
|
480
|
+
readonly deploymentInfraError: boolean;
|
|
481
|
+
readonly quotaSignal: boolean;
|
|
482
|
+
readonly policyRejection: boolean;
|
|
483
|
+
readonly requestScoped: boolean;
|
|
484
|
+
readonly channelRejection: boolean;
|
|
485
|
+
readonly hostedToolFailure: boolean;
|
|
486
|
+
readonly modelEntitlementFailure: boolean;
|
|
487
|
+
readonly queuedUpstream: boolean;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Build the request-independent evidence consumed by the ordered classifier. */
|
|
491
|
+
function accountErrorContext(error: unknown, options?: AccountFailureEvidence): AccountErrorContext {
|
|
335
492
|
const errorRecord = isRecord(error) ? error : undefined;
|
|
336
493
|
const message =
|
|
337
494
|
error instanceof Error
|
|
@@ -380,15 +537,33 @@ export function classifyAccountError(
|
|
|
380
537
|
options?.retryAfterMs ??
|
|
381
538
|
parseUpstreamBackoff({ get: getHeader });
|
|
382
539
|
const messageCooldown = parseProviderResetDuration(message);
|
|
383
|
-
// Whether the provider itself named a window. Decided once, from the
|
|
384
|
-
// evidence, so every branch of this classification agrees about it.
|
|
385
|
-
const providerStated = headerCooldown !== null || messageCooldown !== null;
|
|
386
540
|
const rawUpstreamResponse =
|
|
387
541
|
origin === "upstream" &&
|
|
388
542
|
error instanceof GatewayError &&
|
|
389
543
|
typeof error.details.raw === "string"
|
|
390
544
|
? error.details.raw
|
|
391
545
|
: undefined;
|
|
546
|
+
// The body's own fields, when they can be reached. Checked alongside the
|
|
547
|
+
// header and the prose rather than instead of them: a provider may state the
|
|
548
|
+
// window in any of the three, and the longest honest statement wins.
|
|
549
|
+
//
|
|
550
|
+
// `details.raw` is tried even when no caller passed a parsed body, because
|
|
551
|
+
// that is where the upstream's JSON actually reaches this function — the
|
|
552
|
+
// dispatch path stores the response text there. A structured window read from
|
|
553
|
+
// it is the difference between a 15-minute fallback and the 23 days the
|
|
554
|
+
// provider stated.
|
|
555
|
+
const bodyCooldown =
|
|
556
|
+
parseUpstreamBodyReset(options?.upstreamBody) ??
|
|
557
|
+
(rawUpstreamResponse === undefined ? null : parseUpstreamBodyReset(tryParseUpstreamBody(rawUpstreamResponse)));
|
|
558
|
+
// Whether the provider itself named a window. Decided once, from the
|
|
559
|
+
// evidence, so every branch of this classification agrees about it.
|
|
560
|
+
const providerStated =
|
|
561
|
+
headerCooldown !== null || messageCooldown !== null || bodyCooldown !== null;
|
|
562
|
+
const statedCooldownMs =
|
|
563
|
+
[headerCooldown, messageCooldown, bodyCooldown].reduce<number | null>(
|
|
564
|
+
(best, value) => (value === null ? best : best === null ? value : Math.max(best, value)),
|
|
565
|
+
null,
|
|
566
|
+
);
|
|
392
567
|
const reason = (prefix: string): string => `${prefix}: ${message.slice(0, 160)}`;
|
|
393
568
|
const result = (
|
|
394
569
|
category: AccountErrorCategory,
|
|
@@ -414,6 +589,49 @@ export function classifyAccountError(
|
|
|
414
589
|
modelUnsupported,
|
|
415
590
|
});
|
|
416
591
|
|
|
592
|
+
return {
|
|
593
|
+
message,
|
|
594
|
+
lower,
|
|
595
|
+
folded,
|
|
596
|
+
statusCode,
|
|
597
|
+
origin,
|
|
598
|
+
scope,
|
|
599
|
+
revocationNamed,
|
|
600
|
+
accountEvidence,
|
|
601
|
+
canMutate,
|
|
602
|
+
providerCode,
|
|
603
|
+
statedCooldownMs,
|
|
604
|
+
reason,
|
|
605
|
+
result,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export function classifyAccountError(
|
|
610
|
+
error: unknown,
|
|
611
|
+
options?: AccountFailureEvidence,
|
|
612
|
+
): AccountErrorClassification {
|
|
613
|
+
const {
|
|
614
|
+
message,
|
|
615
|
+
lower,
|
|
616
|
+
folded,
|
|
617
|
+
statusCode,
|
|
618
|
+
origin,
|
|
619
|
+
scope,
|
|
620
|
+
revocationNamed,
|
|
621
|
+
accountEvidence,
|
|
622
|
+
canMutate,
|
|
623
|
+
providerCode,
|
|
624
|
+
|
|
625
|
+
statedCooldownMs,
|
|
626
|
+
reason,
|
|
627
|
+
result,
|
|
628
|
+
} = accountErrorContext(error, options);
|
|
629
|
+
const modelId = options?.modelId;
|
|
630
|
+
const modelBlockHits = Math.max(1, Math.floor(options?.modelBlockHits ?? 1));
|
|
631
|
+
const staticToken = options?.staticToken === true;
|
|
632
|
+
const credentialKind = options?.credentialKind;
|
|
633
|
+
const buddy = BUDDY_PROVIDER_IDS.has(options?.providerId?.toLowerCase() ?? "");
|
|
634
|
+
|
|
417
635
|
// A price refusal is a verdict on the *request*, not on the account. inferhub
|
|
418
636
|
// answers 402 with `no provider's ask matches your max-per-mtok bid`: the
|
|
419
637
|
// caller's own price ceiling was below every upstream ask, which says nothing
|
|
@@ -458,7 +676,7 @@ export function classifyAccountError(
|
|
|
458
676
|
const modelFrequencyWindow =
|
|
459
677
|
lower.includes("frequency limit") && lower.includes("switch to the other models");
|
|
460
678
|
if (modelFrequencyWindow) {
|
|
461
|
-
const cooldownMs =
|
|
679
|
+
const cooldownMs = statedCooldownMs ?? DEFAULT_QUOTA_COOLDOWN_MS();
|
|
462
680
|
return result(
|
|
463
681
|
"rate_limit_transient",
|
|
464
682
|
"cooldown",
|
|
@@ -669,278 +887,39 @@ export function classifyAccountError(
|
|
|
669
887
|
lower.includes("queuecount") ||
|
|
670
888
|
lower.includes("queuetype") ||
|
|
671
889
|
(lower.includes("queue") && lower.includes("modelkey"));
|
|
672
|
-
const authSignal =
|
|
673
|
-
!quotaSignal &&
|
|
674
|
-
!policyRejection &&
|
|
675
|
-
!hostedToolFailure &&
|
|
676
|
-
!modelEntitlementFailure &&
|
|
677
|
-
!queuedUpstream &&
|
|
678
|
-
(providerCode === "authentication_failed" ||
|
|
679
|
-
folded.includes("invalid api key") ||
|
|
680
|
-
folded.includes("incorrect api key") ||
|
|
681
|
-
folded.includes("invalid token") ||
|
|
682
|
-
folded.includes("token revoked") ||
|
|
683
|
-
folded.includes("invalidated oauth token") ||
|
|
684
|
-
folded.includes("reauthorization required") ||
|
|
685
|
-
folded.includes("account deactivated") ||
|
|
686
|
-
((statusCode === 401 || statusCode === 403) && accountEvidence));
|
|
687
|
-
if (authSignal) {
|
|
688
|
-
// A static token has no refresh to run, so an auth rejection must not park
|
|
689
|
-
// the account: disabling it throws away a token that may still work (a
|
|
690
|
-
// single upstream 401 can be transient). A cooldown lets the next attempt
|
|
691
|
-
// retry the same token without losing the account.
|
|
692
|
-
const staticTokenRecovery = options?.staticToken === true;
|
|
693
|
-
const oauthRecovery =
|
|
694
|
-
!staticTokenRecovery &&
|
|
695
|
-
options?.credentialKind === "oauth" &&
|
|
696
|
-
(lower.includes("expired") || lower.includes("refresh"));
|
|
697
|
-
// A *named* revocation is the one auth outcome that still parks the account.
|
|
698
|
-
// The upstream said the credential itself is dead — not that it expired, not
|
|
699
|
-
// that this request was refused — so there is no window to wait out and no
|
|
700
|
-
// refresh to run, and a cooldown would only return it to rotation to fail
|
|
701
|
-
// again. Every other auth rejection is weak evidence (clock skew, a key
|
|
702
|
-
// rotated a moment ago, an edge answering before the credential loads) and
|
|
703
|
-
// cools down instead.
|
|
704
|
-
//
|
|
705
|
-
// This distinction is the whole fix. Reaching `disabled` from the generic
|
|
706
|
-
// `401`/`403` branch would be unrecoverable by any sweep, so one transient
|
|
707
|
-
// rejection would remove the account from rotation until an operator
|
|
708
|
-
// noticed — the exact outcome the comment above says a static token must
|
|
709
|
-
// not suffer.
|
|
710
|
-
const revoked = revocationNamed;
|
|
711
|
-
const recoverable = staticTokenRecovery || oauthRecovery || !revoked;
|
|
712
|
-
// An automatic disable is **not** terminal any more, but the deadline is what
|
|
713
|
-
// makes that safe: `sweepExpiredCooldowns` recovers a disabled row only when
|
|
714
|
-
// its `cooldownUntil` has elapsed, and an operator-disabled row keeps that
|
|
715
|
-
// column null. So the machine can undo its own verdict while an operator's
|
|
716
|
-
// decision still outranks it — see `resolveAccountDisabledRecoveryMs`.
|
|
717
|
-
const disabledRecoveryMs = resolveAccountDisabledRecoveryMs();
|
|
718
|
-
const disabledRetryAt =
|
|
719
|
-
disabledRecoveryMs > 0 ? new Date(Date.now() + disabledRecoveryMs) : null;
|
|
720
|
-
const cooldownMs = recoverable ? resolveAccountAuthRejectedCooldownMs() : disabledRecoveryMs;
|
|
721
|
-
return result(
|
|
722
|
-
"auth_invalidated",
|
|
723
|
-
recoverable ? "cooldown" : "disabled",
|
|
724
|
-
cooldownMs,
|
|
725
|
-
recoverable ? new Date(Date.now() + cooldownMs) : disabledRetryAt,
|
|
726
|
-
reason(
|
|
727
|
-
!revoked
|
|
728
|
-
? "Provider credential rejected; retrying after a backoff"
|
|
729
|
-
: staticTokenRecovery
|
|
730
|
-
? "Static token rejected; retrying without a refresh"
|
|
731
|
-
: oauthRecovery
|
|
732
|
-
? "OAuth credential requires refresh"
|
|
733
|
-
: "Provider credential revoked",
|
|
734
|
-
),
|
|
735
|
-
canMutate,
|
|
736
|
-
);
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
// A price refusal says the caller's bid was below every upstream ask, so the
|
|
740
|
-
// account is not at fault and must not be mutated: `mutatesAccount` is false
|
|
741
|
-
// and the deadline is left null. The category is `quota_exhausted` because the
|
|
742
|
-
// request could not be afforded — not because the account ran out — and the
|
|
743
|
-
// caller can fix it by raising its own ceiling.
|
|
744
|
-
if (priceRefusal) {
|
|
745
|
-
return result(
|
|
746
|
-
"quota_exhausted",
|
|
747
|
-
"cooldown",
|
|
748
|
-
0,
|
|
749
|
-
null,
|
|
750
|
-
reason("Request price refused by the provider"),
|
|
751
|
-
false,
|
|
752
|
-
);
|
|
753
|
-
}
|
|
754
890
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
const grokFreeTier =
|
|
785
|
-
providerCode === "subscription:free-usage-exhausted" ||
|
|
786
|
-
providerCode.startsWith("subscription:") ||
|
|
787
|
-
lower.includes("free-usage") ||
|
|
788
|
-
lower.includes("free usage") ||
|
|
789
|
-
lower.includes("included free usage") ||
|
|
790
|
-
lower.includes("rolling 24-hour") ||
|
|
791
|
-
lower.includes("rolling 24 hour");
|
|
792
|
-
const statedCooldown = messageCooldown ?? headerCooldown;
|
|
793
|
-
const buddyCreditsExhausted = providerCode === "14018" || lower.includes("credits exhausted");
|
|
794
|
-
const cooldownMs = grokFreeTier
|
|
795
|
-
? (statedCooldown ?? GROK_QUOTA_COOLDOWN_MS)
|
|
796
|
-
: buddyCreditsExhausted
|
|
797
|
-
? (statedCooldown ?? 12 * 60 * 60 * 1000)
|
|
798
|
-
: (statedCooldown ?? DEFAULT_QUOTA_COOLDOWN_MS());
|
|
799
|
-
// The caller records against the concrete account that just failed
|
|
800
|
-
// upstream, so a provider-scoped quota error still cools THAT account
|
|
801
|
-
// down (enables fallback to the next account). Local-origin quota must
|
|
802
|
-
// never mutate an upstream account.
|
|
803
|
-
return result(
|
|
804
|
-
"quota_exhausted",
|
|
805
|
-
"cooldown",
|
|
806
|
-
cooldownMs,
|
|
807
|
-
new Date(Date.now() + cooldownMs),
|
|
808
|
-
reason("Provider quota exhausted"),
|
|
809
|
-
origin === "upstream",
|
|
810
|
-
);
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
const rateSignal =
|
|
814
|
-
statusCode === 429 ||
|
|
815
|
-
providerCode === "rate_limit_exceeded" ||
|
|
816
|
-
lower.includes("rate_limit") ||
|
|
817
|
-
lower.includes("rate limit") ||
|
|
818
|
-
lower.includes("too many requests") ||
|
|
819
|
-
lower.includes("requests per minute") ||
|
|
820
|
-
lower.includes("tokens per minute") ||
|
|
821
|
-
lower.includes("tpm limit") ||
|
|
822
|
-
lower.includes("rpm limit");
|
|
823
|
-
if (rateSignal) {
|
|
824
|
-
const cooldownMs = headerCooldown ?? messageCooldown ?? DEFAULT_RATE_LIMIT_COOLDOWN_MS();
|
|
825
|
-
// Same per-account reasoning as quota above: the failing account (or
|
|
826
|
-
// its model, when modelId is present) backs off so routing tries the
|
|
827
|
-
// next candidate instead of hammering the same key.
|
|
828
|
-
return result(
|
|
829
|
-
"rate_limit_transient",
|
|
830
|
-
"cooldown",
|
|
831
|
-
cooldownMs,
|
|
832
|
-
new Date(Date.now() + cooldownMs),
|
|
833
|
-
reason("Provider rate limit"),
|
|
834
|
-
origin === "upstream",
|
|
835
|
-
);
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
const capacitySignal =
|
|
839
|
-
statusCode === 503 ||
|
|
840
|
-
statusCode === 529 ||
|
|
841
|
-
providerCode === "model_capacity" ||
|
|
842
|
-
queuedUpstream ||
|
|
843
|
-
lower.includes("model overloaded") ||
|
|
844
|
-
lower.includes("capacity exceeded") ||
|
|
845
|
-
lower.includes("temporarily unavailable");
|
|
846
|
-
if (capacitySignal) {
|
|
847
|
-
const cooldownMs = headerCooldown ?? messageCooldown ?? MODEL_CAPACITY_COOLDOWN_MS();
|
|
848
|
-
const modelScopedMutation = origin === "upstream" && options?.modelId !== undefined;
|
|
849
|
-
return result(
|
|
850
|
-
"model_capacity",
|
|
851
|
-
"cooldown",
|
|
852
|
-
cooldownMs,
|
|
853
|
-
new Date(Date.now() + cooldownMs),
|
|
854
|
-
reason("Provider model capacity"),
|
|
855
|
-
modelScopedMutation,
|
|
856
|
-
);
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
// A model the upstream does not serve at all, detected before
|
|
860
|
-
// `model_unreachable`: the buddy family answers `400 {"code":"11102","msg":
|
|
861
|
-
// "service info not found"}` for an id its backend has never heard of, and
|
|
862
|
-
// the reference gateways treat that as a durable verdict on the (account,
|
|
863
|
-
// model) pair rather than as a request bug. Same category as
|
|
864
|
-
// `model_unreachable`, so the writer already does the right thing — a
|
|
865
|
-
// model-scoped cooldown that leaves the account's status alone — and what
|
|
866
|
-
// differs is the duration, which escalates per hit.
|
|
867
|
-
//
|
|
868
|
-
// The hit count the caller supplies is best-effort (absent on a pair's first
|
|
869
|
-
// report), so the classifier states the base window and the writer recomputes
|
|
870
|
-
// it from the authoritative count it reads.
|
|
871
|
-
if (isUnsupportedModelEvidence(statusCode, providerCode, folded)) {
|
|
872
|
-
const hits = Math.max(1, Math.floor(options?.modelBlockHits ?? 1));
|
|
873
|
-
const cooldownMs = unsupportedModelCooldownMs(hits);
|
|
874
|
-
return result(
|
|
875
|
-
"model_unreachable",
|
|
876
|
-
"cooldown",
|
|
877
|
-
cooldownMs,
|
|
878
|
-
new Date(Date.now() + cooldownMs),
|
|
879
|
-
reason("Model not supported by this upstream"),
|
|
880
|
-
origin === "upstream" && options?.modelId !== undefined,
|
|
881
|
-
true,
|
|
882
|
-
);
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
// A model the upstream says does not exist for this account is a verdict on
|
|
886
|
-
// the (account, model) pair, not on the credential. Cursor answers Connect
|
|
887
|
-
// `not_found` for a model the plan does not reach (`claude-opus-5` on a plan
|
|
888
|
-
// without it) while every other model on that same account serves normally,
|
|
889
|
-
// so parking the account would take healthy models down with it. Detected
|
|
890
|
-
// before the 5xx branch: the adapter surfaces it as a 404 carrying
|
|
891
|
-
// `providerCode: "not_found"`, and nothing else in the 5xx band is
|
|
892
|
-
// model-scoped.
|
|
893
|
-
// Keyed on the provider's own code rather than on the 404: a 404 also means
|
|
894
|
-
// an upstream *route* that does not exist (`upstream_not_found`), which says
|
|
895
|
-
// nothing about any model and must not cool one.
|
|
896
|
-
const modelUnreachable = providerCode === "not_found" || modelEntitlementFailure;
|
|
897
|
-
if (modelUnreachable) {
|
|
898
|
-
const cooldownMs = MODEL_UNREACHABLE_COOLDOWN_MS;
|
|
899
|
-
// Not credential evidence, so `classifyUpstreamFailure` scopes this to the
|
|
900
|
-
// provider and the `scope === "account"` gate the throttles use would
|
|
901
|
-
// forbid the mutation outright — leaving the probe reporting a failure that
|
|
902
|
-
// cooled nothing. The verdict is still about one (account, model) pair:
|
|
903
|
-
// `recordAccountFailure` writes `modelCooldowns[modelId]` and leaves the
|
|
904
|
-
// account's own status untouched, so all that is required is a model to
|
|
905
|
-
// write against.
|
|
906
|
-
const modelScopedMutation = origin === "upstream" && options?.modelId !== undefined;
|
|
907
|
-
return result(
|
|
908
|
-
"model_unreachable",
|
|
909
|
-
"cooldown",
|
|
910
|
-
cooldownMs,
|
|
911
|
-
new Date(Date.now() + cooldownMs),
|
|
912
|
-
reason("Model unreachable for this account"),
|
|
913
|
-
modelScopedMutation,
|
|
914
|
-
);
|
|
915
|
-
}
|
|
891
|
+
const signals: AccountErrorSignals = {
|
|
892
|
+
message,
|
|
893
|
+
lower,
|
|
894
|
+
folded,
|
|
895
|
+
statusCode,
|
|
896
|
+
origin,
|
|
897
|
+
scope,
|
|
898
|
+
providerCode,
|
|
899
|
+
statedCooldownMs,
|
|
900
|
+
reason,
|
|
901
|
+
result,
|
|
902
|
+
canMutate,
|
|
903
|
+
accountEvidence,
|
|
904
|
+
revocationNamed,
|
|
905
|
+
modelId,
|
|
906
|
+
modelBlockHits,
|
|
907
|
+
staticToken,
|
|
908
|
+
credentialKind,
|
|
909
|
+
buddy,
|
|
910
|
+
priceRefusal,
|
|
911
|
+
deploymentInfraError,
|
|
912
|
+
quotaSignal,
|
|
913
|
+
policyRejection,
|
|
914
|
+
requestScoped,
|
|
915
|
+
channelRejection,
|
|
916
|
+
hostedToolFailure,
|
|
917
|
+
modelEntitlementFailure,
|
|
918
|
+
queuedUpstream,
|
|
919
|
+
};
|
|
916
920
|
|
|
917
|
-
const
|
|
918
|
-
(
|
|
919
|
-
lower.includes("timeout") ||
|
|
920
|
-
lower.includes("abort") ||
|
|
921
|
-
lower.includes("econnrefused") ||
|
|
922
|
-
lower.includes("fetch failed");
|
|
923
|
-
if (serverSignal) {
|
|
924
|
-
// A transport-shaped fault clears on its own, so it is a cooldown with a
|
|
925
|
-
// deadline rather than a state needing an operator. The deadline matters:
|
|
926
|
-
// `sweepExpiredCooldowns` selects on `cooldownUntil IS NOT NULL`, so a row
|
|
927
|
-
// parked without one is never recovered and stays out of rotation.
|
|
928
|
-
//
|
|
929
|
-
// `canMutate` rather than a flat `false`: a 5xx whose failure the caller
|
|
930
|
-
// scoped to the account (an edge/WAF challenge answered to one account's
|
|
931
|
-
// requests while its siblings on the same egress served fine) must cool
|
|
932
|
-
// that account down, or it stays `active` and is re-dialled for as long as
|
|
933
|
-
// the block lasts. Provider-scoped 5xx does not: the upstream is
|
|
934
|
-
// indiscriminately failing and no single account is at fault.
|
|
935
|
-
const transientMs = TRANSIENT_ERROR_COOLDOWN_MS();
|
|
936
|
-
return result(
|
|
937
|
-
lower.includes("timeout") ? "timeout" : "server_error",
|
|
938
|
-
"cooldown",
|
|
939
|
-
transientMs,
|
|
940
|
-
new Date(Date.now() + transientMs),
|
|
941
|
-
reason(`Provider ${statusCode ?? "network"} failure`),
|
|
942
|
-
canMutate,
|
|
943
|
-
);
|
|
921
|
+
for (const rule of ACCOUNT_ERROR_RULES) {
|
|
922
|
+
if (rule.match(signals)) return rule.decide(signals);
|
|
944
923
|
}
|
|
945
924
|
|
|
946
925
|
// Same reasoning as above, with the shortest backoff: an unmapped failure
|
|
@@ -956,6 +935,326 @@ export function classifyAccountError(
|
|
|
956
935
|
);
|
|
957
936
|
}
|
|
958
937
|
|
|
938
|
+
/**
|
|
939
|
+
* The ordered arms of {@link classifyAccountError}, tried top to bottom; the
|
|
940
|
+
* first match decides. Order is behavior, not presentation — see
|
|
941
|
+
* {@link AccountErrorRule}.
|
|
942
|
+
*/
|
|
943
|
+
const ACCOUNT_ERROR_RULES: readonly AccountErrorRule[] = [
|
|
944
|
+
{
|
|
945
|
+
name: "deployment-infrastructure",
|
|
946
|
+
match: (s) => s.deploymentInfraError,
|
|
947
|
+
decide: (s) =>
|
|
948
|
+
s.result(
|
|
949
|
+
"server_error",
|
|
950
|
+
"cooldown",
|
|
951
|
+
60_000,
|
|
952
|
+
null,
|
|
953
|
+
`Proxy deployment error: ${s.message.slice(0, 160)}`,
|
|
954
|
+
false,
|
|
955
|
+
),
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
name: "model-frequency-window",
|
|
959
|
+
match: (s) => s.lower.includes("frequency limit") && s.lower.includes("switch to the other models"),
|
|
960
|
+
decide: (s) => {
|
|
961
|
+
const cooldownMs = s.statedCooldownMs ?? DEFAULT_QUOTA_COOLDOWN_MS();
|
|
962
|
+
return s.result(
|
|
963
|
+
"rate_limit_transient",
|
|
964
|
+
"cooldown",
|
|
965
|
+
cooldownMs,
|
|
966
|
+
new Date(Date.now() + cooldownMs),
|
|
967
|
+
s.reason("Provider frequency limit for this model"),
|
|
968
|
+
// Provider-scoped evidence still has to reach the writer: a stream frame
|
|
969
|
+
// carries no credential detail, so `canMutate` is false and without this
|
|
970
|
+
// the pair would be retried for the whole window it was closed for.
|
|
971
|
+
s.origin === "upstream" && s.scope === "provider",
|
|
972
|
+
);
|
|
973
|
+
},
|
|
974
|
+
},
|
|
975
|
+
{
|
|
976
|
+
name: "content-policy-block",
|
|
977
|
+
match: (s) => s.policyRejection && s.buddy,
|
|
978
|
+
decide: (s) => {
|
|
979
|
+
const policyBlockMs = resolveAccountPolicyBlockCooldownMs();
|
|
980
|
+
return s.result(
|
|
981
|
+
"policy_blocked",
|
|
982
|
+
"cooldown",
|
|
983
|
+
policyBlockMs,
|
|
984
|
+
new Date(Date.now() + policyBlockMs),
|
|
985
|
+
s.reason("Provider content-policy block"),
|
|
986
|
+
s.origin === "upstream",
|
|
987
|
+
);
|
|
988
|
+
},
|
|
989
|
+
},
|
|
990
|
+
{
|
|
991
|
+
name: "request-scoped-rejection",
|
|
992
|
+
match: (s) => s.requestScoped && s.buddy,
|
|
993
|
+
decide: (s) =>
|
|
994
|
+
s.result("unknown", "cooldown", 0, null, s.reason("Request-scoped upstream rejection"), false),
|
|
995
|
+
},
|
|
996
|
+
{
|
|
997
|
+
name: "channel-rejection",
|
|
998
|
+
match: (s) => s.channelRejection && s.buddy,
|
|
999
|
+
decide: (s) => {
|
|
1000
|
+
const channelBlockMs = resolveAccountPolicyBlockCooldownMs();
|
|
1001
|
+
return s.result(
|
|
1002
|
+
"policy_blocked",
|
|
1003
|
+
"cooldown",
|
|
1004
|
+
channelBlockMs,
|
|
1005
|
+
new Date(Date.now() + channelBlockMs),
|
|
1006
|
+
s.reason("Provider channel rejection"),
|
|
1007
|
+
s.origin === "upstream",
|
|
1008
|
+
);
|
|
1009
|
+
},
|
|
1010
|
+
},
|
|
1011
|
+
{
|
|
1012
|
+
name: "credential-rejected",
|
|
1013
|
+
match: (s) =>
|
|
1014
|
+
!s.quotaSignal &&
|
|
1015
|
+
!s.policyRejection &&
|
|
1016
|
+
!s.hostedToolFailure &&
|
|
1017
|
+
!s.modelEntitlementFailure &&
|
|
1018
|
+
!s.queuedUpstream &&
|
|
1019
|
+
(s.providerCode === "authentication_failed" ||
|
|
1020
|
+
s.folded.includes("invalid api key") ||
|
|
1021
|
+
s.folded.includes("incorrect api key") ||
|
|
1022
|
+
s.folded.includes("invalid token") ||
|
|
1023
|
+
s.folded.includes("token revoked") ||
|
|
1024
|
+
s.folded.includes("invalidated oauth token") ||
|
|
1025
|
+
s.folded.includes("reauthorization required") ||
|
|
1026
|
+
s.folded.includes("account deactivated") ||
|
|
1027
|
+
((s.statusCode === 401 || s.statusCode === 403) && s.accountEvidence)),
|
|
1028
|
+
decide: (s) => {
|
|
1029
|
+
// A static token has no refresh to run, so an auth rejection must not park
|
|
1030
|
+
// the account: disabling it throws away a token that may still work (a
|
|
1031
|
+
// single upstream 401 can be transient). A cooldown lets the next attempt
|
|
1032
|
+
// retry the same token without losing the account.
|
|
1033
|
+
const oauthRecovery =
|
|
1034
|
+
!s.staticToken &&
|
|
1035
|
+
s.credentialKind === "oauth" &&
|
|
1036
|
+
(s.lower.includes("expired") || s.lower.includes("refresh"));
|
|
1037
|
+
// A *named* revocation is the one auth outcome that still parks the account.
|
|
1038
|
+
// The upstream said the credential itself is dead — not that it expired, not
|
|
1039
|
+
// that this request was refused — so there is no window to wait out and no
|
|
1040
|
+
// refresh to run, and a cooldown would only return it to rotation to fail
|
|
1041
|
+
// again. Every other auth rejection is weak evidence (clock skew, a key
|
|
1042
|
+
// rotated a moment ago, an edge answering before the credential loads) and
|
|
1043
|
+
// cools down instead.
|
|
1044
|
+
//
|
|
1045
|
+
// This distinction is the whole fix. Reaching `disabled` from the generic
|
|
1046
|
+
// `401`/`403` branch would be unrecoverable by any sweep, so one transient
|
|
1047
|
+
// rejection would remove the account from rotation until an operator
|
|
1048
|
+
// noticed — the exact outcome the comment above says a static token must
|
|
1049
|
+
// not suffer.
|
|
1050
|
+
const revoked = s.revocationNamed;
|
|
1051
|
+
const recoverable = s.staticToken || oauthRecovery || !revoked;
|
|
1052
|
+
// An automatic disable is **not** terminal any more, but the deadline is what
|
|
1053
|
+
// makes that safe: `sweepExpiredCooldowns` recovers a disabled row only when
|
|
1054
|
+
// its `cooldownUntil` has elapsed, and an operator-disabled row keeps that
|
|
1055
|
+
// column null. So the machine can undo its own verdict while an operator's
|
|
1056
|
+
// decision still outranks it — see `resolveAccountDisabledRecoveryMs`.
|
|
1057
|
+
const disabledRecoveryMs = resolveAccountDisabledRecoveryMs();
|
|
1058
|
+
const disabledRetryAt =
|
|
1059
|
+
disabledRecoveryMs > 0 ? new Date(Date.now() + disabledRecoveryMs) : null;
|
|
1060
|
+
const cooldownMs = recoverable ? resolveAccountAuthRejectedCooldownMs() : disabledRecoveryMs;
|
|
1061
|
+
return s.result(
|
|
1062
|
+
"auth_invalidated",
|
|
1063
|
+
recoverable ? "cooldown" : "disabled",
|
|
1064
|
+
cooldownMs,
|
|
1065
|
+
recoverable ? new Date(Date.now() + cooldownMs) : disabledRetryAt,
|
|
1066
|
+
s.reason(
|
|
1067
|
+
!revoked
|
|
1068
|
+
? "Provider credential rejected; retrying after a backoff"
|
|
1069
|
+
: s.staticToken
|
|
1070
|
+
? "Static token rejected; retrying without a refresh"
|
|
1071
|
+
: oauthRecovery
|
|
1072
|
+
? "OAuth credential requires refresh"
|
|
1073
|
+
: "Provider credential revoked",
|
|
1074
|
+
),
|
|
1075
|
+
s.canMutate,
|
|
1076
|
+
);
|
|
1077
|
+
},
|
|
1078
|
+
},
|
|
1079
|
+
{
|
|
1080
|
+
name: "price-refusal",
|
|
1081
|
+
match: (s) => s.priceRefusal,
|
|
1082
|
+
decide: (s) =>
|
|
1083
|
+
s.result("quota_exhausted", "cooldown", 0, null, s.reason("Request price refused by the provider"), false),
|
|
1084
|
+
},
|
|
1085
|
+
{
|
|
1086
|
+
name: "model-plan-gated",
|
|
1087
|
+
match: (s) => s.quotaSignal && s.providerCode === "112",
|
|
1088
|
+
decide: (s) => {
|
|
1089
|
+
const cooldownMs = unsupportedModelCooldownMs(s.modelBlockHits);
|
|
1090
|
+
return s.result(
|
|
1091
|
+
"model_unreachable",
|
|
1092
|
+
"cooldown",
|
|
1093
|
+
cooldownMs,
|
|
1094
|
+
new Date(Date.now() + cooldownMs),
|
|
1095
|
+
s.reason("Model not included in this account's plan"),
|
|
1096
|
+
s.origin === "upstream" && s.modelId !== undefined,
|
|
1097
|
+
true,
|
|
1098
|
+
);
|
|
1099
|
+
},
|
|
1100
|
+
},
|
|
1101
|
+
{
|
|
1102
|
+
name: "quota-exhausted",
|
|
1103
|
+
match: (s) => s.quotaSignal,
|
|
1104
|
+
decide: (s) => {
|
|
1105
|
+
// xAI Grok Build's free tier resets on a rolling window. Use a duration
|
|
1106
|
+
// stated by the provider when present; otherwise park for the conservative
|
|
1107
|
+
// 24-hour fallback.
|
|
1108
|
+
const grokFreeTier =
|
|
1109
|
+
s.providerCode === "subscription:free-usage-exhausted" ||
|
|
1110
|
+
s.providerCode.startsWith("subscription:") ||
|
|
1111
|
+
s.lower.includes("free-usage") ||
|
|
1112
|
+
s.lower.includes("free usage") ||
|
|
1113
|
+
s.lower.includes("included free usage") ||
|
|
1114
|
+
s.lower.includes("rolling 24-hour") ||
|
|
1115
|
+
s.lower.includes("rolling 24 hour");
|
|
1116
|
+
const buddyCreditsExhausted =
|
|
1117
|
+
s.providerCode === "14018" || s.lower.includes("credits exhausted");
|
|
1118
|
+
const cooldownMs = grokFreeTier
|
|
1119
|
+
? (s.statedCooldownMs ?? GROK_QUOTA_COOLDOWN_MS)
|
|
1120
|
+
: buddyCreditsExhausted
|
|
1121
|
+
? (s.statedCooldownMs ?? 12 * 60 * 60 * 1000)
|
|
1122
|
+
: (s.statedCooldownMs ?? DEFAULT_QUOTA_COOLDOWN_MS());
|
|
1123
|
+
// The caller records against the concrete account that just failed
|
|
1124
|
+
// upstream, so a provider-scoped quota error still cools THAT account
|
|
1125
|
+
// down (enables fallback to the next account). Local-origin quota must
|
|
1126
|
+
// never mutate an upstream account.
|
|
1127
|
+
return s.result(
|
|
1128
|
+
"quota_exhausted",
|
|
1129
|
+
"cooldown",
|
|
1130
|
+
cooldownMs,
|
|
1131
|
+
new Date(Date.now() + cooldownMs),
|
|
1132
|
+
s.reason("Provider quota exhausted"),
|
|
1133
|
+
s.origin === "upstream",
|
|
1134
|
+
);
|
|
1135
|
+
},
|
|
1136
|
+
},
|
|
1137
|
+
{
|
|
1138
|
+
name: "rate-limit",
|
|
1139
|
+
match: (s) =>
|
|
1140
|
+
s.statusCode === 429 ||
|
|
1141
|
+
s.providerCode === "rate_limit_exceeded" ||
|
|
1142
|
+
s.lower.includes("rate_limit") ||
|
|
1143
|
+
s.lower.includes("rate limit") ||
|
|
1144
|
+
s.lower.includes("too many requests") ||
|
|
1145
|
+
s.lower.includes("requests per minute") ||
|
|
1146
|
+
s.lower.includes("tokens per minute") ||
|
|
1147
|
+
s.lower.includes("tpm limit") ||
|
|
1148
|
+
s.lower.includes("rpm limit"),
|
|
1149
|
+
decide: (s) => {
|
|
1150
|
+
const cooldownMs = s.statedCooldownMs ?? DEFAULT_RATE_LIMIT_COOLDOWN_MS();
|
|
1151
|
+
// Same per-account reasoning as quota above: the failing account (or
|
|
1152
|
+
// its model, when modelId is present) backs off so routing tries the
|
|
1153
|
+
// next candidate instead of hammering the same key.
|
|
1154
|
+
return s.result(
|
|
1155
|
+
"rate_limit_transient",
|
|
1156
|
+
"cooldown",
|
|
1157
|
+
cooldownMs,
|
|
1158
|
+
new Date(Date.now() + cooldownMs),
|
|
1159
|
+
s.reason("Provider rate limit"),
|
|
1160
|
+
s.origin === "upstream",
|
|
1161
|
+
);
|
|
1162
|
+
},
|
|
1163
|
+
},
|
|
1164
|
+
{
|
|
1165
|
+
name: "model-capacity",
|
|
1166
|
+
match: (s) =>
|
|
1167
|
+
s.statusCode === 503 ||
|
|
1168
|
+
s.statusCode === 529 ||
|
|
1169
|
+
s.providerCode === "model_capacity" ||
|
|
1170
|
+
s.queuedUpstream ||
|
|
1171
|
+
s.lower.includes("model overloaded") ||
|
|
1172
|
+
s.lower.includes("capacity exceeded") ||
|
|
1173
|
+
s.lower.includes("temporarily unavailable"),
|
|
1174
|
+
decide: (s) => {
|
|
1175
|
+
const cooldownMs = s.statedCooldownMs ?? MODEL_CAPACITY_COOLDOWN_MS();
|
|
1176
|
+
const modelScopedMutation = s.origin === "upstream" && s.modelId !== undefined;
|
|
1177
|
+
return s.result(
|
|
1178
|
+
"model_capacity",
|
|
1179
|
+
"cooldown",
|
|
1180
|
+
cooldownMs,
|
|
1181
|
+
new Date(Date.now() + cooldownMs),
|
|
1182
|
+
s.reason("Provider model capacity"),
|
|
1183
|
+
modelScopedMutation,
|
|
1184
|
+
);
|
|
1185
|
+
},
|
|
1186
|
+
},
|
|
1187
|
+
{
|
|
1188
|
+
name: "model-unsupported",
|
|
1189
|
+
match: (s) => isUnsupportedModelEvidence(s.statusCode, s.providerCode, s.folded),
|
|
1190
|
+
decide: (s) => {
|
|
1191
|
+
const cooldownMs = unsupportedModelCooldownMs(s.modelBlockHits);
|
|
1192
|
+
return s.result(
|
|
1193
|
+
"model_unreachable",
|
|
1194
|
+
"cooldown",
|
|
1195
|
+
cooldownMs,
|
|
1196
|
+
new Date(Date.now() + cooldownMs),
|
|
1197
|
+
s.reason("Model not supported by this upstream"),
|
|
1198
|
+
s.origin === "upstream" && s.modelId !== undefined,
|
|
1199
|
+
true,
|
|
1200
|
+
);
|
|
1201
|
+
},
|
|
1202
|
+
},
|
|
1203
|
+
{
|
|
1204
|
+
name: "model-unreachable",
|
|
1205
|
+
match: (s) => s.providerCode === "not_found" || s.modelEntitlementFailure,
|
|
1206
|
+
decide: (s) => {
|
|
1207
|
+
const cooldownMs = MODEL_UNREACHABLE_COOLDOWN_MS;
|
|
1208
|
+
// Not credential evidence, so `classifyUpstreamFailure` scopes this to the
|
|
1209
|
+
// provider and the `scope === "account"` gate the throttles use would
|
|
1210
|
+
// forbid the mutation outright — leaving the probe reporting a failure that
|
|
1211
|
+
// cooled nothing. The verdict is still about one (account, model) pair:
|
|
1212
|
+
// `recordAccountFailure` writes `modelCooldowns[modelId]` and leaves the
|
|
1213
|
+
// account's own status untouched, so all that is required is a model to
|
|
1214
|
+
// write against.
|
|
1215
|
+
const modelScopedMutation = s.origin === "upstream" && s.modelId !== undefined;
|
|
1216
|
+
return s.result(
|
|
1217
|
+
"model_unreachable",
|
|
1218
|
+
"cooldown",
|
|
1219
|
+
cooldownMs,
|
|
1220
|
+
new Date(Date.now() + cooldownMs),
|
|
1221
|
+
s.reason("Model unreachable for this account"),
|
|
1222
|
+
modelScopedMutation,
|
|
1223
|
+
);
|
|
1224
|
+
},
|
|
1225
|
+
},
|
|
1226
|
+
{
|
|
1227
|
+
name: "transport-fault",
|
|
1228
|
+
match: (s) =>
|
|
1229
|
+
(s.statusCode !== null && s.statusCode >= 500 && s.statusCode <= 504) ||
|
|
1230
|
+
s.lower.includes("timeout") ||
|
|
1231
|
+
s.lower.includes("abort") ||
|
|
1232
|
+
s.lower.includes("econnrefused") ||
|
|
1233
|
+
s.lower.includes("fetch failed"),
|
|
1234
|
+
decide: (s) => {
|
|
1235
|
+
// A transport-shaped fault clears on its own, so it is a cooldown with a
|
|
1236
|
+
// deadline rather than a state needing an operator. The deadline matters:
|
|
1237
|
+
// `sweepExpiredCooldowns` selects on `cooldownUntil IS NOT NULL`, so a row
|
|
1238
|
+
// parked without one is never recovered and stays out of rotation.
|
|
1239
|
+
//
|
|
1240
|
+
// `canMutate` rather than a flat `false`: a 5xx whose failure the caller
|
|
1241
|
+
// scoped to the account (an edge/WAF challenge answered to one account's
|
|
1242
|
+
// requests while its siblings on the same egress served fine) must cool
|
|
1243
|
+
// that account down, or it stays `active` and is re-dialled for as long as
|
|
1244
|
+
// the block lasts. Provider-scoped 5xx does not: the upstream is
|
|
1245
|
+
// indiscriminately failing and no single account is at fault.
|
|
1246
|
+
const transientMs = TRANSIENT_ERROR_COOLDOWN_MS();
|
|
1247
|
+
return s.result(
|
|
1248
|
+
s.lower.includes("timeout") ? "timeout" : "server_error",
|
|
1249
|
+
"cooldown",
|
|
1250
|
+
transientMs,
|
|
1251
|
+
new Date(Date.now() + transientMs),
|
|
1252
|
+
s.reason(`Provider ${s.statusCode ?? "network"} failure`),
|
|
1253
|
+
s.canMutate,
|
|
1254
|
+
);
|
|
1255
|
+
},
|
|
1256
|
+
},
|
|
1257
|
+
];
|
|
959
1258
|
|
|
960
1259
|
/** Persists one classified failure to the account row and appends a health event. */
|
|
961
1260
|
export async function recordAccountFailure(
|
|
@@ -965,7 +1264,34 @@ export async function recordAccountFailure(
|
|
|
965
1264
|
options?: AccountFailureEvidence,
|
|
966
1265
|
): Promise<AccountErrorClassification | null> {
|
|
967
1266
|
const classification = classifyAccountError(error, options);
|
|
968
|
-
|
|
1267
|
+
/**
|
|
1268
|
+
* Logged even when the classification does not mutate the account.
|
|
1269
|
+
*
|
|
1270
|
+
* `rate_limit_transient` seen on a stream frame carries no credential
|
|
1271
|
+
* detail, so it classifies at provider scope and `mutatesAccount` is false
|
|
1272
|
+
* — nothing is cooled and the account stays `active`. That part is correct:
|
|
1273
|
+
* the upstream's own remedy ("switch to the other models") only works if the
|
|
1274
|
+
* account keeps serving. But this returned without writing anything, so a
|
|
1275
|
+
* rate-limited request showed up in the request log while the account's
|
|
1276
|
+
* health log stayed empty — a symptom with no recorded cause.
|
|
1277
|
+
*
|
|
1278
|
+
* The row is the whole point of this branch: it records the failure without
|
|
1279
|
+
* moving the account. Both status sides are the classification's own, so the
|
|
1280
|
+
* dialog names the cause without claiming a transition that never happened.
|
|
1281
|
+
*/
|
|
1282
|
+
if (!classification.mutatesAccount) {
|
|
1283
|
+
void writeHealthEvent(db, {
|
|
1284
|
+
entityKind: "account",
|
|
1285
|
+
accountId,
|
|
1286
|
+
fromStatus: classification.status,
|
|
1287
|
+
toStatus: classification.status,
|
|
1288
|
+
reason: classification.reason,
|
|
1289
|
+
errorCategory: classification.category,
|
|
1290
|
+
modelId: options?.modelId ?? null,
|
|
1291
|
+
createdAt: new Date(),
|
|
1292
|
+
}).catch(() => undefined);
|
|
1293
|
+
return classification;
|
|
1294
|
+
}
|
|
969
1295
|
|
|
970
1296
|
const persist = async (client: CartethyiaDatabase): Promise<AccountErrorClassification | null> => {
|
|
971
1297
|
// Projected, not a bare `select()`: this runs on *every* attempt outcome,
|