cartethyia 2.5.3 → 2.5.6
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 +13 -4
- package/README.md +2 -2
- package/dist/dashboard/assets/{BannedPage-hfCzi-Ux.js → BannedPage-CADDq4IU.js} +1 -1
- package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
- package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DEDyKBlC.js → CliToolsPage-DuO1Z7dq.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
- package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-bsLuZmYH.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-_UWhwziJ.js → CustomizationPage-BR5HHvbi.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-DDVIfZGW.js → LoginPage-V5faV9V2.js} +1 -1
- package/dist/dashboard/assets/LoginPage-V5faV9V2.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js +1 -0
- package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
- package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
- package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
- package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-D6pmQ86L.js → SetupPage-UB690SE2.js} +1 -1
- package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-DWvKiuSl.js → SortableList-BLujdX7N.js} +1 -1
- package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CmkUrgdj.js → StudioPage-D5C2raAc.js} +3 -3
- package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
- package/dist/dashboard/assets/UsagePage-8uIcxa1W.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
- package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-C9FD_JwJ.js} +1 -1
- package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
- package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-CR72dasT.js} +1 -1
- package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
- package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-CI9_eSfO.js} +1 -1
- package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
- package/dist/dashboard/assets/{card--AtFedAq.js → card-DS7CQEsO.js} +1 -1
- package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-CNqzrfdb.js} +1 -1
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-611pSsaX.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
- package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
- package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
- package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
- package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
- package/dist/dashboard/assets/entry-B9Xvtnfj.js +1 -0
- package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
- package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
- package/dist/dashboard/assets/entry-DxBHnauy.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-sltk1qUi.js} +1 -1
- package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
- package/dist/dashboard/assets/{index-BydyKaDz.js → index-CmxZr14F.js} +2 -2
- package/dist/dashboard/assets/index-CmxZr14F.js.gz +0 -0
- package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-BhY2sSnO.js} +1 -1
- package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
- package/dist/dashboard/assets/{input-BG41et9G.js → input-V1umFiBk.js} +1 -1
- package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-1zzZYM0B.js} +1 -1
- package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
- package/dist/dashboard/assets/{live-BJGYRBnR.js → live-BivGfuUs.js} +1 -1
- package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CmffjLuG.js +1 -0
- package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-46P0oWMe.js} +1 -1
- package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-BFhGpwR1.js} +1 -1
- package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
- package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-CAb4F37R.js} +1 -1
- package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
- package/dist/dashboard/assets/{select-BO6h8yNv.js → select-BYR_5K2c.js} +2 -2
- package/dist/dashboard/assets/select-BYR_5K2c.js.gz +0 -0
- package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-DNX9sydi.js} +1 -1
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-BHTfXdz_.js} +1 -1
- package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
- package/dist/dashboard/assets/{state-DYdQmdAl.js → state-mPDRlQkl.js} +1 -1
- package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
- package/dist/dashboard/assets/switch-DOmJxg7H.js +1 -0
- package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
- package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
- package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
- package/dist/dashboard/assets/tokens-COzario9.js +1 -0
- package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-Dem0lXQj.js} +1 -1
- package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
- package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
- package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
- package/dist/dashboard/donation/qris-risun-marke.png +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +57 -57
- package/migrations/README.md +52 -52
- package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
- package/package.json +96 -95
- package/scripts/cli/admin.ts +125 -0
- package/scripts/cli/autostart.ts +237 -0
- package/scripts/cli/{ui/background.ts → background.ts} +1 -1
- package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -248
- package/scripts/cli/doctor.ts +37 -0
- package/scripts/cli/{support/env.ts → env.ts} +99 -99
- package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
- package/scripts/cli/instance.ts +224 -0
- package/scripts/cli/main.ts +88 -18
- package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
- package/scripts/cli/port.ts +100 -0
- package/scripts/cli/{support/services.ts → services.ts} +88 -88
- package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
- package/scripts/cli/{command/start.ts → start.ts} +10 -5
- package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
- package/scripts/cli/tray/index.ts +62 -12
- package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
- package/scripts/cli/update.ts +40 -0
- package/scripts/cli/version.ts +67 -0
- package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
- package/src/boot/app.ts +11 -7
- package/src/boot/dependencies.ts +50 -19
- package/src/boot/timeout.ts +2 -13
- package/src/config/cooldowns.ts +186 -138
- package/src/config/network.ts +8 -14
- package/src/config/providers.ts +7 -10
- package/src/config/runtime.ts +76 -76
- package/src/config/security.ts +15 -0
- package/src/config/spec.ts +52 -7
- package/src/config/telemetry.ts +26 -0
- package/src/config/timeouts.ts +0 -6
- package/src/console/admin/model-abuse/contracts.ts +28 -6
- package/src/console/admin/model-abuse/routes.ts +56 -3
- package/src/console/api-keys/bucket-spend.ts +15 -4
- package/src/console/api-keys/contracts.ts +410 -414
- package/src/console/api-keys/routes.ts +881 -880
- package/src/console/auth/credentials.ts +3 -3
- package/src/console/auth/setup-service.ts +2 -2
- package/src/console/backup/contracts.ts +15 -2
- package/src/console/backup/export-endpoint.ts +6 -2
- package/src/console/backup/nine-router.ts +2 -5
- package/src/console/backup/service.ts +4 -0
- package/src/console/backup/store.ts +192 -29
- package/src/console/backup/validate.ts +3 -45
- package/src/console/cli-tools/fs-ops.ts +487 -487
- package/src/console/console-router-guards.ts +1 -1
- package/src/console/console-router.ts +8 -0
- package/src/console/data-management/contracts.ts +100 -0
- package/src/console/data-management/operations.ts +234 -0
- package/src/console/data-management/routes.ts +103 -0
- package/src/console/domain-registration.ts +45 -1
- package/src/console/observability/contracts.ts +524 -485
- package/src/console/observability/live.ts +22 -0
- package/src/console/observability/store.ts +1138 -1018
- package/src/console/observability/usage-dimensions.ts +4 -4
- package/src/console/providers/catalog/catalog-projections.ts +6 -7
- package/src/console/providers/catalog/contracts.ts +6 -0
- package/src/console/providers/catalog/model-operations.ts +5 -2
- package/src/console/providers/catalog/provider-operations.ts +24 -4
- package/src/console/providers/catalog/public-model-store.ts +8 -11
- package/src/console/providers/catalog/routes.ts +7 -0
- package/src/console/providers/catalog/store.ts +1553 -1532
- package/src/console/providers/oauth/routes.ts +8 -12
- package/src/console/quota/account-quota-shared.ts +52 -39
- package/src/console/quota/account-quota-tenant.ts +6 -6
- package/src/console/quota/quota-view.ts +1 -1
- package/src/console/quota/refresh.ts +441 -429
- package/src/console/remote-support/contracts.ts +61 -0
- package/src/console/remote-support/routes.ts +65 -0
- package/src/console/remote-support/service.ts +207 -0
- package/src/console/response-compression.ts +0 -3
- package/src/console/routing/pools/import-parse.ts +206 -0
- package/src/console/routing/pools/operations.ts +543 -501
- package/src/console/routing/pools/probe-result.ts +3 -2
- package/src/console/routing/pools/probe-verdict.ts +132 -132
- package/src/console/routing/pools/routes.ts +181 -164
- package/src/console/routing/pools/store.ts +8 -8
- package/src/console/settings/contracts.ts +95 -2
- package/src/console/settings/store.ts +81 -3
- package/src/console/shared/cursor.ts +8 -8
- package/src/console/shared/errors.ts +1 -3
- package/src/console/updates/routes.ts +16 -0
- package/src/main.ts +10 -0
- package/src/network/http2-bidi.ts +2 -4
- package/src/network/pool/agent.ts +478 -478
- package/src/network/pool/selector.ts +5 -4
- package/src/network/retry.ts +1 -2
- package/src/network/ssrf.ts +5 -4
- package/src/observability/event-compaction.ts +317 -0
- package/src/observability/event-day-index.ts +440 -0
- package/src/observability/event-log.ts +155 -6
- package/src/observability/logger.ts +6 -6
- package/src/observability/payload-capture.ts +158 -33
- package/src/observability/payload-store.ts +82 -61
- package/src/observability/redaction.ts +5 -6
- package/src/observability/request-notify.ts +43 -0
- package/src/observability/telemetry-status.ts +8 -9
- package/src/observability/token-speed.ts +5 -4
- package/src/persistence/db-mode.ts +7 -7
- package/src/persistence/health-event-flush.ts +36 -0
- package/src/persistence/readiness.ts +1 -1
- package/src/persistence/schema.ts +75 -29
- package/src/persistence/shared-cache.ts +1 -2
- package/src/persistence/stores/api-key-store.ts +18 -6
- package/src/persistence/stores/share-store.ts +4 -3
- package/src/persistence/stores/telemetry-store.ts +33 -11
- package/src/protocol/canonical-model.ts +11 -3
- package/src/protocol/codecs.ts +139 -6
- package/src/protocol/gateway-error.ts +6 -4
- package/src/protocol/image-clamp.ts +19 -1
- package/src/protocol/registry.ts +8 -0
- package/src/protocol/request/chat.ts +21 -1
- package/src/protocol/request/codex.ts +10 -1
- package/src/protocol/request/gemini.ts +9 -2
- package/src/protocol/request/messages.ts +18 -7
- package/src/protocol/request/responses.ts +9 -1
- package/src/protocol/response/chat.ts +34 -4
- package/src/protocol/response/codex.ts +7 -4
- package/src/protocol/response/dsml.ts +140 -28
- package/src/protocol/response/responses.ts +11 -5
- package/src/protocol/stream-error-frames.ts +4 -5
- package/src/providers/adapters/compatible-adapter.ts +3 -0
- package/src/providers/authentication/jwt-validator.ts +9 -10
- package/src/providers/authentication/oauth-refresh-service.ts +9 -11
- package/src/providers/bundled-discovery-providers.ts +122 -119
- package/src/providers/configured-openai-providers.ts +4 -4
- package/src/providers/default-registry.ts +5 -4
- package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
- package/src/providers/discovery/catalog/catalog-format.ts +3 -6
- package/src/providers/discovery/models-dev-catalog.ts +354 -354
- package/src/providers/discovery/openai-model-discovery.ts +5 -0
- package/src/providers/discovery/openrouter-free-roster.ts +15 -17
- package/src/providers/discovery/probe-phases.ts +4 -4
- package/src/providers/discovery/probe-wire.ts +6 -13
- package/src/providers/discovery/probing-service.ts +1117 -1045
- package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
- package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
- package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
- package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
- package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
- package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
- package/src/providers/integrations/buddy/buddy-media.ts +2 -2
- package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
- package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
- package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
- package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
- package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
- package/src/providers/integrations/claude/claude.ts +608 -608
- package/src/providers/integrations/cline/cline-quota.ts +27 -20
- package/src/providers/integrations/cline/cline.ts +537 -539
- package/src/providers/integrations/codex/codex-headers.ts +25 -39
- package/src/providers/integrations/codex/codex-oauth.ts +1 -1
- package/src/providers/integrations/codex/codex.ts +2 -29
- package/src/providers/integrations/commandcode/commandcode.ts +674 -675
- package/src/providers/integrations/commandcode/discovery.ts +117 -116
- package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
- package/src/providers/integrations/cursor/cursor-quota.ts +309 -0
- package/src/providers/integrations/cursor/cursor-request.ts +616 -471
- package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
- package/src/providers/integrations/cursor/cursor.ts +1333 -960
- package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
- package/src/providers/integrations/factory/factory-models.ts +3 -5
- package/src/providers/integrations/factory/factory.ts +8 -1
- package/src/providers/integrations/gemini.ts +6 -1
- package/src/providers/integrations/grok/grok-install-id.ts +2 -3
- package/src/providers/integrations/grok/grok-quota.ts +279 -177
- package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
- package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
- package/src/providers/integrations/grok/grok.ts +6 -7
- package/src/providers/integrations/hermes.ts +14 -18
- package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
- package/src/providers/integrations/kiro/kiro.ts +1 -1
- package/src/providers/integrations/mistral.ts +6 -6
- package/src/providers/integrations/opencode/fingerprint.ts +3 -3
- package/src/providers/integrations/opencode/provider.ts +19 -20
- package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
- package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
- package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
- package/src/providers/integrations/qoder/qoder.ts +884 -865
- package/src/providers/integrations/shared/connect.ts +6 -6
- package/src/providers/integrations/trae/trae-catalog.ts +1 -2
- package/src/providers/integrations/trae/trae-quota.ts +384 -0
- package/src/providers/integrations/trae/trae.ts +2 -2
- package/src/providers/integrations/zcode/oauth.ts +6 -1
- package/src/providers/operations/account-health-service.ts +75 -27
- package/src/providers/operations/account-reset-service.ts +1 -1
- package/src/providers/operations/client-versions.ts +6 -6
- package/src/providers/operations/custom-cli-headers.ts +3 -4
- package/src/providers/operations/provider-catalog-seeder.ts +95 -95
- package/src/providers/operations/provider-catalog-service.ts +3 -4
- package/src/providers/provider-contracts.ts +7 -0
- package/src/providers/provider-metadata.ts +10 -8
- package/src/providers/provider-registry.ts +6 -7
- package/src/providers/provider-usage.ts +1 -1
- package/src/providers/quota/quota-support.ts +8 -0
- package/src/security/abuse.ts +15 -10
- package/src/security/access-control.ts +144 -146
- package/src/security/admission/contracts.ts +11 -0
- package/src/security/admission/in-memory-store.ts +4 -3
- package/src/security/admission/lease-sweep.ts +6 -6
- package/src/security/admission/reasons.ts +5 -1
- package/src/security/admission/redis-store.ts +211 -210
- package/src/security/admission/service.ts +4 -0
- package/src/security/api-key-auth.ts +27 -17
- package/src/security/ip-boundary.ts +155 -15
- package/src/security/model-abuse.ts +366 -94
- package/src/security/outbound-headers.ts +3 -4
- package/src/shared/sleep.ts +4 -0
- package/src/transport/dispatch/attempt-finalize.ts +47 -5
- package/src/transport/dispatch/attempt-loop.ts +531 -530
- package/src/transport/dispatch/generation.ts +6 -0
- package/src/transport/dispatch/proxy-request.ts +24 -1
- package/src/transport/dispatch/ratelimit-capture.ts +37 -0
- package/src/transport/dispatch/stream-bridge.ts +1 -3
- package/src/transport/dispatch/streaming/finalize.ts +1 -1
- package/src/transport/dispatch/streaming/stream.ts +19 -6
- package/src/transport/dispatch/upstream.ts +9 -0
- package/src/transport/failure-policy.ts +162 -8
- package/src/transport/guards/auth-guard.ts +7 -4
- package/src/transport/guards/concurrency-guard.ts +0 -5
- package/src/transport/guards/error-lifecycle.ts +13 -6
- package/src/transport/guards/pipeline.ts +15 -3
- package/src/transport/guards/request-context.ts +8 -2
- package/src/transport/request/inflight.ts +5 -6
- package/src/transport/request/preparer.ts +9 -8
- package/src/transport/request/state.ts +21 -8
- package/src/transport/request/tenant-preferences.ts +60 -0
- package/src/transport/routing/admission.ts +4 -0
- package/src/transport/routing/route-catalog.ts +65 -10
- package/src/transport/routing/route-model.ts +46 -14
- package/src/transport/routing/router.ts +21 -14
- package/src/transport/surface/completion.ts +2 -3
- package/src/transport/surface/dialects.ts +5 -6
- package/src/transport/surface/responses/encode.ts +38 -0
- package/src/transport/surface/responses/parse.ts +4 -0
- package/src/transport/translation/capabilities.ts +2 -3
- package/src/transport/translation/quirks.ts +3 -3
- package/src/workers/buddy-growth-tasks.ts +5 -10
- package/src/workers/daily-checkin.ts +17 -23
- package/src/workers/quota-refresh-sweep.ts +26 -26
- package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js +0 -1
- package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js +0 -1
- package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js +0 -1
- package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-B53YYMng.js +0 -1
- package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js +0 -7
- package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BpOQCnun.js +0 -1
- package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +0 -1
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-B47uOydr.js +0 -56
- package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-uNywYlfv.js +0 -1
- package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
- package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
- package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
- package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
- package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
- package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
- package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BtpsJU98.js +0 -1
- package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
- package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
- package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
- package/dist/dashboard/assets/drawer-WwwtzihF.js +0 -1
- package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
- package/dist/dashboard/assets/entry-CkryrIea.js +0 -4
- package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
- package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
- package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
- package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
- package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
- package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
- package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
- package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
- package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
- package/dist/dashboard/assets/layout-C8mer_Ak.js +0 -1
- package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
- package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
- package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
- package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
- package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
- package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
- package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
- package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
- package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
- package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
- package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
- package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
- package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
- package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
- package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
- package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
- package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
- package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
- package/scripts/cli/command/admin.ts +0 -65
- package/scripts/cli/command/doctor.ts +0 -257
- package/scripts/cli/command/update.ts +0 -23
- package/scripts/cli/gateway/drain-token.ts +0 -78
- package/scripts/cli/gateway/instance-lock.ts +0 -105
- package/scripts/cli/gateway/stop.ts +0 -51
- package/scripts/cli/support/bun.ts +0 -27
- package/scripts/cli/support/version.ts +0 -37
- /package/dist/dashboard/assets/{app-CafWtVXA.js → app-BIt446yE.js} +0 -0
- /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-BIt446yE.js.gz} +0 -0
- /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
|
@@ -34,6 +34,7 @@ import { ProxyRequestStateStore } from "../request/state";
|
|
|
34
34
|
import { ProxyRequestPreparer } from "../request/preparer";
|
|
35
35
|
import { completeAttempt, estimatedUsage } from "./attempt-finalize";
|
|
36
36
|
import { runAttemptLoop } from "./attempt-loop";
|
|
37
|
+
import { captureProviderRateLimit } from "./ratelimit-capture";
|
|
37
38
|
|
|
38
39
|
export interface GenerationHandlerDeps {
|
|
39
40
|
readonly db: CartethyiaDatabase;
|
|
@@ -125,6 +126,11 @@ async function passThrough(
|
|
|
125
126
|
},
|
|
126
127
|
): Promise<Response> {
|
|
127
128
|
const usage = estimatedUsage(0, 0);
|
|
129
|
+
// The CLI bucket comes back on every upstream response, so this is the free
|
|
130
|
+
// place to learn it: the quota surface then reads the stored value instead of
|
|
131
|
+
// spending a request of its own. Both the manual account test and automatic
|
|
132
|
+
// routing land here, so a captured reading follows real traffic.
|
|
133
|
+
captureProviderRateLimit(args.candidate.provider_id, args.candidate.provider_account_id, upstream.headers);
|
|
128
134
|
await completeAttempt(state, {
|
|
129
135
|
status: "completed",
|
|
130
136
|
providerId: args.candidate.provider_id,
|
|
@@ -15,7 +15,11 @@ import { messagesAdapter } from "../surface/messages/adapter";
|
|
|
15
15
|
import { completionAdapter } from "../surface/completion";
|
|
16
16
|
import { forwardedRequestHeaders, proxySuccessHeaders, buildUpstreamDispatchContext, reportedModelFrom } from "./upstream";
|
|
17
17
|
import { resolvePromptCacheKey } from "../../providers/operations/session-resolution";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
applyTenantPreferences,
|
|
20
|
+
applyTenantDeadlineOverride,
|
|
21
|
+
resolveTenantImageClampMaxBytes,
|
|
22
|
+
} from "../request/tenant-preferences";
|
|
19
23
|
import { metrics } from "../../observability/metrics";
|
|
20
24
|
import type { NetworkPoolSelector } from "../../network/pool/selector";
|
|
21
25
|
import type { TelemetryBatchBuffer } from "../../observability/telemetry-buffer";
|
|
@@ -128,6 +132,22 @@ export async function handleProviderProxyRequest(
|
|
|
128
132
|
if (!prepared)
|
|
129
133
|
throw new GatewayError("admission_unavailable", 503, "The request was not prepared, so no route can be dialed. Resend the request.");
|
|
130
134
|
const canonicalRequest = await applyTenantPreferences(prepared, deps.db);
|
|
135
|
+
// Per-tenant timeout, applied as soon as the tenant is known. The deadline
|
|
136
|
+
// was armed at admission with the deployment default; this re-arms it.
|
|
137
|
+
await applyTenantDeadlineOverride(
|
|
138
|
+
prepared.authorization.snapshot.tenant_id,
|
|
139
|
+
deps.db,
|
|
140
|
+
(ms) => state.extendDeadline(ms),
|
|
141
|
+
);
|
|
142
|
+
// Memoized on the request: the ceiling is a tenant setting, so it cannot
|
|
143
|
+
// change between failover attempts, and each wire encoder that clamps an
|
|
144
|
+
// image reads it from here instead of paying for a preference read per
|
|
145
|
+
// attempt.
|
|
146
|
+
const imageClampMaxBytes = await resolveTenantImageClampMaxBytes(
|
|
147
|
+
deps.db,
|
|
148
|
+
prepared.authorization.snapshot.tenant_id,
|
|
149
|
+
);
|
|
150
|
+
if (imageClampMaxBytes !== undefined) state.imageClampMaxBytes = imageClampMaxBytes;
|
|
131
151
|
const candidates =
|
|
132
152
|
prepared.eligibleRouteCandidates.length > 0 ? prepared.eligibleRouteCandidates : [prepared.candidate];
|
|
133
153
|
const attemptCandidates = candidates;
|
|
@@ -226,6 +246,9 @@ export async function handleProviderProxyRequest(
|
|
|
226
246
|
headers: inboundHeaders,
|
|
227
247
|
requestId: state.requestId,
|
|
228
248
|
requestIdentity: state,
|
|
249
|
+
...(state.imageClampMaxBytes === undefined
|
|
250
|
+
? {}
|
|
251
|
+
: { imageClampMaxBytes: state.imageClampMaxBytes }),
|
|
229
252
|
...(conversationAffinity ? { conversationAffinity } : {}),
|
|
230
253
|
...(candidate.user_agent === undefined ? {} : { userAgent: candidate.user_agent }),
|
|
231
254
|
...(deps.networkBindingFactory
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Files a provider's rate-limit headers against the account that earned them.
|
|
2
|
+
//
|
|
3
|
+
// Providers answer real traffic with the bucket they are metering — Grok's CLI
|
|
4
|
+
// plane returns `x-ratelimit-*` on every response — and that is a reading the
|
|
5
|
+
// gateway already paid for. Capturing it here is what lets the quota surface
|
|
6
|
+
// show a balance without spending a request of its own.
|
|
7
|
+
//
|
|
8
|
+
// Kept provider-scoped and fail-open: a provider with no bucket parser is
|
|
9
|
+
// skipped, and any throw is swallowed, because a bookkeeping miss must never
|
|
10
|
+
// take down a request that already succeeded.
|
|
11
|
+
|
|
12
|
+
import { rememberGrokRateLimit } from "../../providers/integrations/grok/grok-ratelimit-store";
|
|
13
|
+
import { readGrokRateLimitHeaders } from "../../providers/integrations/grok/grok-ratelimit";
|
|
14
|
+
|
|
15
|
+
/** Providers whose response headers carry a readable bucket. */
|
|
16
|
+
const GROK_PROVIDER_IDS: readonly string[] = ["grok"];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Stores whatever bucket the response carried, keyed by account.
|
|
20
|
+
*
|
|
21
|
+
* No account id means no home for the reading: a bucket filed under the wrong
|
|
22
|
+
* account is worse than none, since it would report another account's balance.
|
|
23
|
+
*/
|
|
24
|
+
export function captureProviderRateLimit(
|
|
25
|
+
providerId: string,
|
|
26
|
+
accountId: string | undefined,
|
|
27
|
+
headers: { get(name: string): string | null },
|
|
28
|
+
): void {
|
|
29
|
+
if (!accountId) return;
|
|
30
|
+
const id = providerId.toLowerCase();
|
|
31
|
+
if (!GROK_PROVIDER_IDS.includes(id)) return;
|
|
32
|
+
try {
|
|
33
|
+
rememberGrokRateLimit(accountId, readGrokRateLimitHeaders(headers));
|
|
34
|
+
} catch {
|
|
35
|
+
// Swallowed: advisory bookkeeping on a request that already succeeded.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -44,9 +44,7 @@ export function createDispatchStreamEncoder(
|
|
|
44
44
|
return {
|
|
45
45
|
push: (event) => encoder.push(event).map((wire) => formatSseEvent(wire.type, wire)),
|
|
46
46
|
finish: () => encoder.finish().map((wire) => formatSseEvent(wire.type, wire)),
|
|
47
|
-
encodeError: (
|
|
48
|
-
formatSseEvent("response.error", { type: "response.error", origin, code, message, details }),
|
|
49
|
-
],
|
|
47
|
+
encodeError: (input) => encoder.encodeError(input).map((wire) => formatSseEvent(wire.type, wire)),
|
|
50
48
|
};
|
|
51
49
|
}
|
|
52
50
|
if (surface === "messages") {
|
|
@@ -154,7 +154,7 @@ export class StreamFinalizer {
|
|
|
154
154
|
*
|
|
155
155
|
* Once the upstream has produced anything — even surface-invisible
|
|
156
156
|
* reasoning deltas — it is alive: the stall bound governs and the tighter
|
|
157
|
-
* first-chunk bound
|
|
157
|
+
* first-chunk bound does not apply.
|
|
158
158
|
*
|
|
159
159
|
* The request deadline is a fixed ceiling measured from ingress, and a
|
|
160
160
|
* stream that keeps sending past it is not stalled. A live WorkBuddy stream
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
resolveCaptureDepthForRequest,
|
|
50
50
|
shouldCaptureProviderBodyForRequest,
|
|
51
51
|
} from "../attempt-finalize";
|
|
52
|
+
import { resolveTenantImageClampMaxBytes } from "../../request/tenant-preferences";
|
|
52
53
|
import { proxySuccessHeaders, buildUpstreamDispatchContext } from "../upstream";
|
|
53
54
|
import type { AttemptLeaseOwnership } from "../leases";
|
|
54
55
|
import type { ProviderContinuationScope } from "../../../providers/provider-contracts";
|
|
@@ -186,6 +187,16 @@ export async function dispatchStreamingAttempt(input: StreamingDispatchInput): P
|
|
|
186
187
|
providerCaptureMaxBytes(captureDepth),
|
|
187
188
|
)
|
|
188
189
|
: undefined;
|
|
190
|
+
// Same memoized ceiling the non-streaming path resolves, read before the
|
|
191
|
+
// dispatch context is built so a stream gets the same limit as a normal
|
|
192
|
+
// request rather than silently falling back to the deployment default.
|
|
193
|
+
if (state.imageClampMaxBytes === undefined) {
|
|
194
|
+
const clampMaxBytes = await resolveTenantImageClampMaxBytes(
|
|
195
|
+
deps.db,
|
|
196
|
+
prepared.authorization.snapshot.tenant_id,
|
|
197
|
+
);
|
|
198
|
+
if (clampMaxBytes !== undefined) state.imageClampMaxBytes = clampMaxBytes;
|
|
199
|
+
}
|
|
189
200
|
const dispatchContext = buildUpstreamDispatchContext({
|
|
190
201
|
credential,
|
|
191
202
|
deadline: state.deadlineMs,
|
|
@@ -193,6 +204,9 @@ export async function dispatchStreamingAttempt(input: StreamingDispatchInput): P
|
|
|
193
204
|
headers: inboundHeaders,
|
|
194
205
|
requestId: state.requestId,
|
|
195
206
|
requestIdentity: state,
|
|
207
|
+
...(state.imageClampMaxBytes === undefined
|
|
208
|
+
? {}
|
|
209
|
+
: { imageClampMaxBytes: state.imageClampMaxBytes }),
|
|
196
210
|
...(conversationAffinity ? { conversationAffinity } : {}),
|
|
197
211
|
...(candidate.user_agent === undefined ? {} : { userAgent: candidate.user_agent }),
|
|
198
212
|
...(wrappedOutboundFetch ? { outboundFetch: wrappedOutboundFetch } : {}),
|
|
@@ -218,10 +232,9 @@ export async function dispatchStreamingAttempt(input: StreamingDispatchInput): P
|
|
|
218
232
|
});
|
|
219
233
|
// `ProviderAdapter.dispatch` is declared to return a non-nullable
|
|
220
234
|
// `AsyncIterable`, so an adapter that returned `undefined` would be a
|
|
221
|
-
// type violation rather than a state this code could handle
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
// have. Enforcing it belongs at the adapter boundary, not here.
|
|
235
|
+
// type violation rather than a state this code could handle — no null check
|
|
236
|
+
// is needed here, and adding one would advertise a contract the interface
|
|
237
|
+
// does not have. Enforcing it belongs at the adapter boundary.
|
|
225
238
|
const createUpstreamIterable = () =>
|
|
226
239
|
adapter.dispatch(
|
|
227
240
|
dispatchRequest,
|
|
@@ -361,8 +374,8 @@ export async function dispatchStreamingAttempt(input: StreamingDispatchInput): P
|
|
|
361
374
|
// reasoning item when the client is Chat Completions). Keep
|
|
362
375
|
// pulling until at least one wire chunk is enqueued; returning
|
|
363
376
|
// from pull with an empty queue can leave Web Streams waiting
|
|
364
|
-
// forever
|
|
365
|
-
//
|
|
377
|
+
// forever — a client then sees one assistant header and then a
|
|
378
|
+
// 60s socket reset.
|
|
366
379
|
for (;;) {
|
|
367
380
|
// Re-arm on every upstream read: the watchdog fires only
|
|
368
381
|
// when the provider stays silent longer than the bound.
|
|
@@ -56,6 +56,12 @@ export function buildUpstreamDispatchContext(input: {
|
|
|
56
56
|
readonly continuationScope?: ProviderContinuationScope;
|
|
57
57
|
/** Stable per-conversation cache affinity (preferred over random ids). */
|
|
58
58
|
readonly conversationAffinity?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Per-tenant inline-attachment ceiling in bytes, resolved once per request.
|
|
61
|
+
* Carried through dispatch because wire encoders clamp images but hold no
|
|
62
|
+
* database handle.
|
|
63
|
+
*/
|
|
64
|
+
readonly imageClampMaxBytes?: number;
|
|
59
65
|
/** Opaque identity shared by retries of one logical gateway request. */
|
|
60
66
|
readonly requestIdentity?: object;
|
|
61
67
|
}): Record<string, unknown> {
|
|
@@ -94,6 +100,9 @@ export function buildUpstreamDispatchContext(input: {
|
|
|
94
100
|
...(input.leaseOwnership ? { lease_ownership: input.leaseOwnership } : {}),
|
|
95
101
|
...(input.continuationScope ? { continuation_scope: input.continuationScope } : {}),
|
|
96
102
|
...(input.conversationAffinity ? { conversation_affinity: input.conversationAffinity } : {}),
|
|
103
|
+
...(input.imageClampMaxBytes === undefined
|
|
104
|
+
? {}
|
|
105
|
+
: { image_clamp_max_bytes: input.imageClampMaxBytes }),
|
|
97
106
|
...(input.requestIdentity ? { request_identity: input.requestIdentity } : {}),
|
|
98
107
|
} as Record<string, unknown>;
|
|
99
108
|
}
|
|
@@ -229,6 +229,35 @@ export function extractUpstreamMessage(body: unknown): string {
|
|
|
229
229
|
* Reading only a nested string dropped the code for the second family
|
|
230
230
|
* entirely, so the operator saw a bare message with no provider code.
|
|
231
231
|
*/
|
|
232
|
+
/**
|
|
233
|
+
* The provider code hidden inside one string value, or `undefined`.
|
|
234
|
+
*
|
|
235
|
+
* Recognizes both a nested JSON envelope written as an escaped string
|
|
236
|
+
* (`"{\"code\":\"11-128\"}"`) and the bare escaped form (`"\"code\\\":\\\"11-128\""`).
|
|
237
|
+
* Cheap by construction: the scan is a substring test, and only a string that
|
|
238
|
+
* looks like it carries a code is handed to `JSON.parse`, so a large message
|
|
239
|
+
* body costs one `includes` rather than a parse.
|
|
240
|
+
*/
|
|
241
|
+
function parseEmbeddedCode(value: string): string | undefined {
|
|
242
|
+
if (!value.includes("code")) return undefined;
|
|
243
|
+
// An escaped envelope: try it as JSON first, then with its escapes collapsed.
|
|
244
|
+
for (const candidate of [value, value.replace(/\\+"/g, '"')]) {
|
|
245
|
+
const trimmed = candidate.trim();
|
|
246
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith('"')) continue;
|
|
247
|
+
let parsed: unknown;
|
|
248
|
+
try {
|
|
249
|
+
parsed = JSON.parse(trimmed);
|
|
250
|
+
} catch {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const code = upstreamProviderCode(parsed);
|
|
254
|
+
if (code !== undefined) return code;
|
|
255
|
+
}
|
|
256
|
+
// A bare `"code": "11-128"` fragment inside a longer message.
|
|
257
|
+
const match = value.match(/\\*["']?code\\*["']?\s*:\s*\\*["']?([A-Za-z0-9_.:-]{3,32})/i);
|
|
258
|
+
return match?.[1];
|
|
259
|
+
}
|
|
260
|
+
|
|
232
261
|
export function upstreamProviderCode(body: unknown): string | undefined {
|
|
233
262
|
if (!isRecord(body)) return undefined;
|
|
234
263
|
const nested = isRecord(body.error) ? body.error : undefined;
|
|
@@ -236,6 +265,20 @@ export function upstreamProviderCode(body: unknown): string | undefined {
|
|
|
236
265
|
for (const candidate of [ext?.code, nested?.code, body.code]) {
|
|
237
266
|
if (typeof candidate === "string" && candidate.length > 0) return candidate;
|
|
238
267
|
}
|
|
268
|
+
// A provider may embed its error envelope as an *escaped string* rather than a
|
|
269
|
+
// nested object — `"error": "{\"code\":\"11-128\",...}"` — so none of the
|
|
270
|
+
// candidates above is a record and the code is invisible. That is the shape
|
|
271
|
+
// the reference gateways detect, and it matters because the channel code is
|
|
272
|
+
// what decides whether an account is cooled: missing it, a blocked account
|
|
273
|
+
// stays in rotation failing every request.
|
|
274
|
+
for (const candidate of [ext, nested, body]) {
|
|
275
|
+
if (!isRecord(candidate)) continue;
|
|
276
|
+
for (const value of Object.values(candidate)) {
|
|
277
|
+
if (typeof value !== "string") continue;
|
|
278
|
+
const embedded = parseEmbeddedCode(value);
|
|
279
|
+
if (embedded !== undefined) return embedded;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
239
282
|
// A numeric code is still the provider's identifier; keep it as text so an
|
|
240
283
|
// operator can search for it. The nested string above wins when both exist.
|
|
241
284
|
const numeric = ext?.code ?? nested?.code ?? body.code;
|
|
@@ -364,6 +407,14 @@ export function classifyUpstreamFailure(error: unknown): UpstreamFailurePolicy {
|
|
|
364
407
|
error.code === "model_not_found" ||
|
|
365
408
|
error.code === "admission_unavailable" ||
|
|
366
409
|
error.code === "capacity_exhausted" ||
|
|
410
|
+
// A provider max-inflight rejection: the plan's next sibling account may
|
|
411
|
+
// have a free slot, exactly as with an API-key concurrency rejection.
|
|
412
|
+
error.code === "provider_capacity_exhausted" ||
|
|
413
|
+
// The upstream accepted the request and is holding it in a queue. Another
|
|
414
|
+
// account of the same provider is very likely not queued, so failover is
|
|
415
|
+
// the faster answer — and if every account is queued, the caller still has
|
|
416
|
+
// the stated interval to wait for.
|
|
417
|
+
error.code === "upstream_queued" ||
|
|
367
418
|
error.code === "accounts_unavailable" ||
|
|
368
419
|
error.code === "accounts_rate_limited" ||
|
|
369
420
|
error.code === "quota_exceeded" ||
|
|
@@ -499,8 +550,8 @@ export interface TerminalOutcome {
|
|
|
499
550
|
* Derives a failed attempt's status, error category, and error origin **from
|
|
500
551
|
* one decision**, so the three can never contradict each other.
|
|
501
552
|
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
553
|
+
* Computing them independently at a dispatch call site lets them
|
|
554
|
+
* drift: a request that failed on an upstream condition could be recorded
|
|
504
555
|
* `cancelled` (499) while its category said `transport_unavailable` and its
|
|
505
556
|
* http status said 502 — a row no operator could trust. Bundling them here
|
|
506
557
|
* makes the inconsistency unrepresentable: a cancel always carries
|
|
@@ -591,6 +642,88 @@ export function isContextLengthFailure(body: unknown): boolean {
|
|
|
591
642
|
}
|
|
592
643
|
return false;
|
|
593
644
|
}
|
|
645
|
+
/**
|
|
646
|
+
* How long an upstream asked the caller to wait before retrying, in
|
|
647
|
+
* milliseconds, or `undefined` when the body states no duration.
|
|
648
|
+
*
|
|
649
|
+
* `retryAfterSeconds` is the field the queue-shaped refusals carry. It is read
|
|
650
|
+
* from a few plausible spellings because the upstreams that queue a request do
|
|
651
|
+
* not agree on one, and a queue with no duration still has to be reported as a
|
|
652
|
+
* queue — the caller's wait is then bounded by the caller, not by us.
|
|
653
|
+
*/
|
|
654
|
+
export function queueRetryAfterMs(body: unknown): number | undefined {
|
|
655
|
+
if (!isRecord(body)) return undefined;
|
|
656
|
+
const envelope = isRecord(body.error) ? body.error : body;
|
|
657
|
+
for (const source of [envelope, body]) {
|
|
658
|
+
for (const key of ["retryAfterSeconds", "retry_after_seconds", "retryAfterMs", "retry_after"]) {
|
|
659
|
+
const raw = source[key];
|
|
660
|
+
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
|
|
661
|
+
return key.toLowerCase().endsWith("ms") ? Math.round(raw) : Math.round(raw * 1000);
|
|
662
|
+
}
|
|
663
|
+
if (typeof raw === "string") {
|
|
664
|
+
const value = Number(raw.trim());
|
|
665
|
+
if (Number.isFinite(value) && value > 0) {
|
|
666
|
+
return key.toLowerCase().endsWith("ms") ? Math.round(value) : Math.round(value * 1000);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return undefined;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* True when an upstream error body says the request is **queued**, not refused.
|
|
676
|
+
*
|
|
677
|
+
* Qoder answers `403` with `{"code":"10605", "isQueued":true,
|
|
678
|
+
* "retryAfterSeconds":N}`. That is not a rejection — the request is accepted and
|
|
679
|
+
* will be served once capacity frees up — but it arrives on a status the
|
|
680
|
+
* gateway treats as a failure, so without this it surfaced as a 403 the client
|
|
681
|
+
* read as "you may not do this" and the turn ended. Reporting it as a queue
|
|
682
|
+
* lets the caller wait for the stated interval instead.
|
|
683
|
+
*
|
|
684
|
+
* Recognized conservatively: an explicit `isQueued` flag, the `10605` code, or
|
|
685
|
+
* a message naming a queue. A bare word like "queue" appearing anywhere in a
|
|
686
|
+
* long body is not enough on its own, which is why the message test requires
|
|
687
|
+
* the queue to be what the message is *about* rather than a word it contains.
|
|
688
|
+
*/
|
|
689
|
+
export function isUpstreamQueued(body: unknown): boolean {
|
|
690
|
+
if (!isRecord(body)) return false;
|
|
691
|
+
const envelope = isRecord(body.error) ? body.error : body;
|
|
692
|
+
if (envelope.isQueued === true || body.isQueued === true) return true;
|
|
693
|
+
if (envelope.queued === true || body.queued === true) return true;
|
|
694
|
+
for (const candidate of [envelope.code, body.code]) {
|
|
695
|
+
if (typeof candidate === "string" && candidate.trim() === "10605") return true;
|
|
696
|
+
}
|
|
697
|
+
for (const candidate of [envelope.message, envelope.msg, body.message, body.msg]) {
|
|
698
|
+
if (typeof candidate !== "string") continue;
|
|
699
|
+
const lower = candidate.toLowerCase();
|
|
700
|
+
// "is queued" / "queued, retry in Ns" — the queue as the message's subject.
|
|
701
|
+
if (/\bqueued\b|\bis queue\b|resource_limit/.test(lower)) return true;
|
|
702
|
+
}
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** The error a queued upstream request becomes: retryable, with its wait. */
|
|
707
|
+
export function upstreamQueuedError(
|
|
708
|
+
providerId: string,
|
|
709
|
+
retryAfterMs: number | undefined,
|
|
710
|
+
detail: string,
|
|
711
|
+
): GatewayError {
|
|
712
|
+
return new GatewayError(
|
|
713
|
+
"upstream_queued",
|
|
714
|
+
503,
|
|
715
|
+
detail.length > 0
|
|
716
|
+
? detail
|
|
717
|
+
: "The upstream has queued this request. Retry after the stated interval.",
|
|
718
|
+
{
|
|
719
|
+
providerId,
|
|
720
|
+
reason: "upstream-queued",
|
|
721
|
+
...(retryAfterMs === undefined ? {} : { retryAfterMs, cooldownMs: retryAfterMs }),
|
|
722
|
+
},
|
|
723
|
+
"upstream",
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
594
727
|
const QUOTA_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
|
|
595
728
|
"rate_limit_exceeded",
|
|
596
729
|
"rate_limit_error",
|
|
@@ -815,12 +948,31 @@ export async function mapUpstreamHttpError(response: Response, providerId: strin
|
|
|
815
948
|
parsedBody = undefined;
|
|
816
949
|
}
|
|
817
950
|
const identifier = upstreamErrorIdentifier(parsedBody) ?? providerCode;
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
951
|
+
// A queued request is checked *before* the status table: the upstream accepted
|
|
952
|
+
// the request and will serve it, but says so on a failure status (Qoder's 403
|
|
953
|
+
// + 10605). Letting the table classify it first turned "wait, then retry"
|
|
954
|
+
// into a refusal the client could not distinguish from a permissions error.
|
|
955
|
+
const upstreamQueued = isUpstreamQueued(parsedBody);
|
|
956
|
+
// The body's stated interval wins; the header is only a fallback, because an
|
|
957
|
+
// upstream that queues a request usually states the wait in the body and
|
|
958
|
+
// leaves `Retry-After` off entirely.
|
|
959
|
+
const queuedWaitMs = upstreamQueued
|
|
960
|
+
? (queueRetryAfterMs(parsedBody) ??
|
|
961
|
+
(parseUpstreamBackoff(response.headers) ?? undefined))
|
|
962
|
+
: undefined;
|
|
963
|
+
const classification = upstreamQueued
|
|
964
|
+
? { code: "upstream_queued" as const, status: 503, origin: "upstream" as const }
|
|
965
|
+
: isContextLengthFailure(parsedBody)
|
|
966
|
+
? { code: "context_length_exceeded" as const, status: 413, origin: "upstream" as const }
|
|
967
|
+
: classifyUpstreamError(response.status, identifier);
|
|
821
968
|
const extracted = extractUpstreamMessage(parsedBody ?? text);
|
|
822
969
|
const message = (extracted.length > 0 ? extracted : text.trim() || `Provider returned HTTP ${response.status}.`).slice(0, MAX_UPSTREAM_ERROR_BYTES).replace(/[\r\n]+/g, " ");
|
|
823
|
-
const
|
|
970
|
+
const headerWaitMs = parseUpstreamBackoff(response.headers);
|
|
971
|
+
// A queue carries its own interval, which is the one the caller must honour:
|
|
972
|
+
// it is what the upstream promised, while `Retry-After` may be absent or may
|
|
973
|
+
// describe a different window. `cooldownMs` rides along so the account-health
|
|
974
|
+
// layer can also read it.
|
|
975
|
+
const retryAfterMs = queuedWaitMs ?? headerWaitMs;
|
|
824
976
|
const requestId = upstreamRequestId(response.headers);
|
|
825
977
|
const reportedProviderCode = providerCode ?? identifier;
|
|
826
978
|
throw new GatewayError(classification.code, classification.status, message, {
|
|
@@ -830,6 +982,9 @@ export async function mapUpstreamHttpError(response: Response, providerId: strin
|
|
|
830
982
|
...(requestId ? { upstreamRequestId: requestId } : {}),
|
|
831
983
|
raw: text.slice(0, MAX_UPSTREAM_ERROR_BYTES),
|
|
832
984
|
...(retryAfterMs === null || retryAfterMs === undefined ? {} : { retryAfterMs }),
|
|
985
|
+
...(queuedWaitMs !== undefined
|
|
986
|
+
? { reason: "upstream-queued", cooldownMs: queuedWaitMs }
|
|
987
|
+
: {}),
|
|
833
988
|
...(classification.rateLimitScope
|
|
834
989
|
? { rateLimitScope: classification.rateLimitScope }
|
|
835
990
|
: {}),
|
|
@@ -895,8 +1050,7 @@ export function isRetryableFailure(error: unknown): boolean {
|
|
|
895
1050
|
* timeout.
|
|
896
1051
|
*
|
|
897
1052
|
* Owned here so the attempt loop, the empty-stream replay, and the Codex
|
|
898
|
-
* retry budget cannot drift apart
|
|
899
|
-
* the constant.
|
|
1053
|
+
* retry budget cannot drift apart.
|
|
900
1054
|
*/
|
|
901
1055
|
export const MIN_RETRY_BUDGET_MS = 15_000;
|
|
902
1056
|
|
|
@@ -90,14 +90,17 @@ export function createApiKeyAuthenticationMiddleware(deps: {
|
|
|
90
90
|
);
|
|
91
91
|
// Abuse ban: refuse before parse/prepare and before authorization is
|
|
92
92
|
// recorded, so a banned caller cannot keep producing failed rows. The ban
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
// is keyed on the client address *and* the API key: one key can be shared
|
|
94
|
+
// by every recipient of a share link, so refusing the address alone would
|
|
95
|
+
// punish callers that did nothing, while refusing the key alone would let
|
|
96
|
+
// the caller that probed mint a fresh one.
|
|
96
97
|
if (deps.modelStrikes) {
|
|
97
98
|
const state = deps.stateStore.get(request);
|
|
98
99
|
const ip = state?.clientIdentity?.address;
|
|
99
100
|
if (ip !== undefined) {
|
|
100
|
-
const banned = await deps.modelStrikes
|
|
101
|
+
const banned = await deps.modelStrikes
|
|
102
|
+
.check({ ip, apiKeyId: authorization.id })
|
|
103
|
+
.catch(() => false);
|
|
101
104
|
if (banned) throw modelAbuseBannedError();
|
|
102
105
|
}
|
|
103
106
|
}
|
|
@@ -22,11 +22,6 @@ const PROCESS_CONCURRENCY_RETRY_MS = 3_000;
|
|
|
22
22
|
|
|
23
23
|
let inFlight = 0;
|
|
24
24
|
|
|
25
|
-
/** The current in-flight count. Exposed for tests; not a control surface. */
|
|
26
|
-
export function processInFlight(): number {
|
|
27
|
-
return inFlight;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
25
|
export function createProcessConcurrencyMiddleware(): Elysia {
|
|
31
26
|
return new Elysia()
|
|
32
27
|
.beforeHandle(({ request }) => {
|
|
@@ -12,6 +12,7 @@ import { computeTokensPerSec } from "../../observability/token-speed";
|
|
|
12
12
|
import { pushStructuredConsoleLog } from "../../observability/log-ring";
|
|
13
13
|
import type { ConsoleLogLevel, ConsoleLogMetadata } from "../../observability/log-ring";
|
|
14
14
|
import { isProxyDispatchRoute } from "./body-policy";
|
|
15
|
+
import { notifyRequestCompleted } from "../../observability/request-notify";
|
|
15
16
|
|
|
16
17
|
interface ElysiaBuiltinError {
|
|
17
18
|
readonly status?: number;
|
|
@@ -217,12 +218,12 @@ export function finalizeRequestTelemetry(
|
|
|
217
218
|
return;
|
|
218
219
|
}
|
|
219
220
|
|
|
220
|
-
// An early rejection never reaches `completeAttempt`,
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
221
|
+
// An early rejection never reaches `completeAttempt`, the caller that writes
|
|
222
|
+
// a payload frame — so a 400 from ingress, auth, parse, or the preparer would
|
|
223
|
+
// produce a request row whose four capture panels were all empty, and the one
|
|
224
|
+
// case an operator most wants to read (what did the client actually send?)
|
|
225
|
+
// would have nothing to read. Capture the ingress body and the error the
|
|
226
|
+
// client saw, keyed to the same request id as the row.
|
|
226
227
|
if (requestData === undefined && state.ingressBody !== undefined) {
|
|
227
228
|
void captureEarlyRejectionPayload(state, authorization);
|
|
228
229
|
}
|
|
@@ -235,6 +236,12 @@ export function finalizeRequestTelemetry(
|
|
|
235
236
|
tokensPerSec,
|
|
236
237
|
}),
|
|
237
238
|
);
|
|
239
|
+
notifyRequestCompleted({
|
|
240
|
+
tenantId: authorization.tenantId,
|
|
241
|
+
requestId: state.requestId,
|
|
242
|
+
status,
|
|
243
|
+
httpStatus: requestStatus,
|
|
244
|
+
});
|
|
238
245
|
}
|
|
239
246
|
|
|
240
247
|
/** Console-log severity for a terminal request status. */
|
|
@@ -28,7 +28,12 @@ import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
|
28
28
|
import type { RedisClient } from "../../persistence/redis";
|
|
29
29
|
import type { TelemetryBatchBuffer } from "../../observability/telemetry-buffer";
|
|
30
30
|
import type { ModelStrikeService } from "../../security/model-abuse";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
TelemetryPayloadCapture,
|
|
33
|
+
payloadCaptureKeepsBodies,
|
|
34
|
+
payloadRetentionMs,
|
|
35
|
+
resolvePayloadCapture,
|
|
36
|
+
} from "../../observability/payload-capture";
|
|
32
37
|
|
|
33
38
|
/** Shared state/context carried by every ordered transport stage. */
|
|
34
39
|
export interface TransportPipelineContext {
|
|
@@ -167,8 +172,15 @@ export function createTransportPipeline(context: TransportPipelineContext): Tran
|
|
|
167
172
|
stateStore: context.stateStore,
|
|
168
173
|
telemetryBuffer: context.telemetry,
|
|
169
174
|
// One capture for the process, shared with every request state so an
|
|
170
|
-
// early rejection can record its payload from the finalize hook.
|
|
171
|
-
|
|
175
|
+
// early rejection can record its payload from the finalize hook. It
|
|
176
|
+
// resolves the tenant's retention itself because an early rejection has
|
|
177
|
+
// no preference memo on its state — nothing resolved one before the
|
|
178
|
+
// request was refused.
|
|
179
|
+
payloadCapture: new TelemetryPayloadCapture(context.db, (tenantId) =>
|
|
180
|
+
resolvePayloadCapture(context.db, tenantId).then(
|
|
181
|
+
(resolved) => (resolved.ok ? resolved.retentionMs : payloadRetentionMs()),
|
|
182
|
+
),
|
|
183
|
+
),
|
|
172
184
|
});
|
|
173
185
|
} else {
|
|
174
186
|
registerRequestCleanup(rootApp, { stateStore: context.stateStore });
|
|
@@ -269,7 +269,13 @@ export function createProxyRoutePreparationMiddleware(deps: {
|
|
|
269
269
|
throw new GatewayError("admission_unavailable", 503, "The request was not authenticated and parsed, so this route cannot be served. Resend the request.");
|
|
270
270
|
const strikes = deps.modelStrikes;
|
|
271
271
|
const ip = state.clientIdentity?.address;
|
|
272
|
-
|
|
272
|
+
// The key rides along so a strike is charged to the caller that made it.
|
|
273
|
+
// Without it, one recipient of a shared link could escalate a ban onto
|
|
274
|
+
// every other caller behind the same address.
|
|
275
|
+
const strikeIdentity =
|
|
276
|
+
strikes && ip !== undefined
|
|
277
|
+
? { strikes, ip, apiKeyId: state.authorization?.id ?? null }
|
|
278
|
+
: undefined;
|
|
273
279
|
try {
|
|
274
280
|
state.preparedRequest = await deps.preparer.prepare({
|
|
275
281
|
canonicalRequest: state.canonicalRequest,
|
|
@@ -286,7 +292,7 @@ export function createProxyRoutePreparationMiddleware(deps: {
|
|
|
286
292
|
// request, or any other failure is not the caller probing for models.
|
|
287
293
|
if (strikeIdentity && isModelRejection(error)) {
|
|
288
294
|
const outcome = await strikeIdentity.strikes
|
|
289
|
-
.noteInvalid({ ip: strikeIdentity.ip })
|
|
295
|
+
.noteInvalid({ ip: strikeIdentity.ip, apiKeyId: strikeIdentity.apiKeyId })
|
|
290
296
|
.catch(() => null);
|
|
291
297
|
if (outcome?.banned === true) throw modelAbuseBannedError();
|
|
292
298
|
if (outcome) {
|
|
@@ -4,12 +4,11 @@
|
|
|
4
4
|
* provider-dispatch leases, keyed by `requestId`, carrying the client IP
|
|
5
5
|
* captured at ingress and the hard deadline the flight must not outlive.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* one place that evicts a request, so a flight can no longer outlive its state.
|
|
7
|
+
* The registry is *owned* by the `ProxyRequestStateStore` (`state.ts`), which
|
|
8
|
+
* is also the one place that evicts a request, so a flight cannot outlive its
|
|
9
|
+
* state. A separate process-global counter alongside the store's
|
|
10
|
+
* `liveControllers` map would be a second registry able to disagree with it: a
|
|
11
|
+
* flight the store had already torn down would stay counted there forever.
|
|
13
12
|
*
|
|
14
13
|
* The store hands each entry an `expiresAtMs` (the request's hard deadline). A
|
|
15
14
|
* periodic backstop sweep reads `overdue()` and force-releases any flight whose
|
|
@@ -85,12 +85,13 @@ export function resolveContinuationAffinity(
|
|
|
85
85
|
// id replayed from another key or tenant resolves nothing.
|
|
86
86
|
const record = lookupPendingCall(callId, scope);
|
|
87
87
|
if (record === undefined) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
88
|
+
// A replayed conversation carries every earlier tool result, and results
|
|
89
|
+
// previous legs already answered resolve nothing here — they are history,
|
|
90
|
+
// not the flight being continued. The request is a continuation only
|
|
91
|
+
// through the ids that still resolve; when none do, the ordinary routing
|
|
92
|
+
// path decides (a Cursor adapter may rebase the full history into a
|
|
93
|
+
// fresh Run).
|
|
94
|
+
continue;
|
|
94
95
|
}
|
|
95
96
|
if (affinity !== undefined && affinity.providerAccountId !== record.providerAccountId) {
|
|
96
97
|
throw new GatewayError(
|
|
@@ -240,8 +241,8 @@ export class ProxyRequestPreparer {
|
|
|
240
241
|
// than dispatched: a provider rejects the whole request for one corrupt
|
|
241
242
|
// attachment, and the buddy family reports it with no field named, so the
|
|
242
243
|
// caller gets a 400 it cannot act on. Running this before capability
|
|
243
|
-
// derivation also means a request whose only image was defective
|
|
244
|
-
//
|
|
244
|
+
// derivation also means a request whose only image was defective does not
|
|
245
|
+
// require the `image` capability — it plans like the text request it has
|
|
245
246
|
// become, instead of being routed to a vision route for nothing.
|
|
246
247
|
const requestWithAttachments = dropCorruptAttachments(request).request;
|
|
247
248
|
// Continuation affinity is resolved before anything else: a request
|