cartethyia 2.7.1 → 2.7.3
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 +2 -0
- package/dist/dashboard/assets/{BannedPage-D-J75hBM.js → BannedPage-2TJD37Hb.js} +1 -1
- package/dist/dashboard/assets/BannedPage-2TJD37Hb.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js +1 -0
- package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js +1 -0
- package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-QoJ6foxo.js → CombosPage-JEbYJFxw.js} +1 -1
- package/dist/dashboard/assets/CombosPage-JEbYJFxw.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-DpNRay4C.js → ConsoleLogPage-C3xx_inM.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-C3xx_inM.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js +1 -0
- package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-DubdQcvX.js → LoginPage-C4w7vea-.js} +1 -1
- package/dist/dashboard/assets/LoginPage-C4w7vea-.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-BH8Tehdi.js → ModelPicker-BtPcAfot.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-BtPcAfot.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js +1 -0
- package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js +4 -0
- package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-DdPc8slb.js +9 -0
- package/dist/dashboard/assets/ProxyPage-DdPc8slb.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-ATpaxrbl.js → QuotaPage-Ci5O4BnH.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-Ci5O4BnH.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-CETophpx.js → SettingsPage-CDZjTt87.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-CDZjTt87.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js +1 -0
- package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-CAK-EN9z.js → SortableList-BAL5npBC.js} +1 -1
- package/dist/dashboard/assets/SortableList-BAL5npBC.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-B7W2_RhT.js → StudioPage-TP7voAQ0.js} +3 -3
- package/dist/dashboard/assets/StudioPage-TP7voAQ0.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-6T4EFDjW.js → UsagePage-Cj_fEzhg.js} +13 -13
- package/dist/dashboard/assets/UsagePage-Cj_fEzhg.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-CUi6UXsR.js → api-keys-MbI7e7rt.js} +1 -1
- package/dist/dashboard/assets/api-keys-MbI7e7rt.js.gz +0 -0
- package/dist/dashboard/assets/app-B2vyPoII.css +2 -0
- package/dist/dashboard/assets/app-B2vyPoII.css.gz +0 -0
- package/dist/dashboard/assets/arrow-up-59ms1iiT.js +1 -0
- package/dist/dashboard/assets/arrow-up-59ms1iiT.js.gz +0 -0
- package/dist/dashboard/assets/{assert-67xXH5rf.js → assert-C0YYuZJ4.js} +1 -1
- package/dist/dashboard/assets/assert-C0YYuZJ4.js.gz +0 -0
- package/dist/dashboard/assets/{badge-DC2L43Mj.js → badge-B_DMpMJN.js} +1 -1
- package/dist/dashboard/assets/badge-B_DMpMJN.js.gz +0 -0
- package/dist/dashboard/assets/{banner-BBVcb0J3.js → banner-DMrMPmth.js} +1 -1
- package/dist/dashboard/assets/banner-DMrMPmth.js.gz +0 -0
- package/dist/dashboard/assets/{card-Cca1XC6G.js → card-Be5Uzamx.js} +1 -1
- package/dist/dashboard/assets/card-Be5Uzamx.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-KvsWa1Jl.js → cli-tools-muFRphDu.js} +1 -1
- package/dist/dashboard/assets/cli-tools-muFRphDu.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-DUzEbynq.js → clipboard-button-DOfrVmsi.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DOfrVmsi.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-DFvWhwTa.js → dialog-C9omdjQt.js} +1 -1
- package/dist/dashboard/assets/dialog-C9omdjQt.js.gz +0 -0
- package/dist/dashboard/assets/drawer-Bm8WKaGY.js +1 -0
- package/dist/dashboard/assets/drawer-Bm8WKaGY.js.gz +0 -0
- package/dist/dashboard/assets/{entry-_xHo5x6r.js → entry-DS90gUks.js} +1 -1
- package/dist/dashboard/assets/entry-DS90gUks.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BLBLxX0h.js → entry-j40LJotO.js} +3 -3
- package/dist/dashboard/assets/entry-j40LJotO.js.gz +0 -0
- package/dist/dashboard/assets/{entry-Bqy8RFvM.js → entry-q5HwZ8nN.js} +1 -1
- package/dist/dashboard/assets/entry-q5HwZ8nN.js.gz +0 -0
- package/dist/dashboard/assets/{concurrency-BDF0ok7y.js → fingerprint-pattern-BWuHtbW4.js} +1 -1
- package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
- package/dist/dashboard/assets/{formatters-mpBPqAQ8.js → formatters-BTScrQpe.js} +1 -1
- package/dist/dashboard/assets/formatters-BTScrQpe.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-C9e8Ts7e.js → github-badge-Orjuzemr.js} +1 -1
- package/dist/dashboard/assets/github-badge-Orjuzemr.js.gz +0 -0
- package/dist/dashboard/assets/{index-D6QzEC_n.js → index-cPrX5vn6.js} +2 -2
- package/dist/dashboard/assets/index-cPrX5vn6.js.gz +0 -0
- package/dist/dashboard/assets/{inline-BFUcvllj.js → inline-Dvux8w-0.js} +1 -1
- package/dist/dashboard/assets/inline-Dvux8w-0.js.gz +0 -0
- package/dist/dashboard/assets/{input-C_ILAvBb.js → input-CU1Rgf3p.js} +1 -1
- package/dist/dashboard/assets/input-CU1Rgf3p.js.gz +0 -0
- package/dist/dashboard/assets/{layout-CBIyT6Rt.js → layout-C_7A6H55.js} +1 -1
- package/dist/dashboard/assets/layout-C_7A6H55.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-ebJ_-Nsb.js → list-row-CzFvogbj.js} +1 -1
- package/dist/dashboard/assets/list-row-CzFvogbj.js.gz +0 -0
- package/dist/dashboard/assets/{live-D0mm--ME.js → live-KaG15HLQ.js} +1 -1
- package/dist/dashboard/assets/live-KaG15HLQ.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-sMD965oO.js → mutation-JJGQm8TD.js} +1 -1
- package/dist/dashboard/assets/mutation-JJGQm8TD.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-BO9dKaFr.js → numbers-BNDZnsUE.js} +1 -1
- package/dist/dashboard/assets/numbers-BNDZnsUE.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-CfL_Zdoh.js → query-keys-CLaNz8Xz.js} +1 -1
- package/dist/dashboard/assets/query-keys-CLaNz8Xz.js.gz +0 -0
- package/dist/dashboard/assets/{routing-BnIpi1IJ.js → routing-BEH32QOC.js} +1 -1
- package/dist/dashboard/assets/routing-BEH32QOC.js.gz +0 -0
- package/dist/dashboard/assets/{select-Cj5Nw43g.js → select-B3iTetHN.js} +1 -1
- package/dist/dashboard/assets/select-B3iTetHN.js.gz +0 -0
- package/dist/dashboard/assets/settings-2-Ds9DQp0t.js +1 -0
- package/dist/dashboard/assets/settings-2-Ds9DQp0t.js.gz +0 -0
- package/dist/dashboard/assets/{settings-BAFhDgX8.js → settings-Dt3Eqbnq.js} +1 -1
- package/dist/dashboard/assets/settings-Dt3Eqbnq.js.gz +0 -0
- package/dist/dashboard/assets/{stack-EMrOMt4I.js → stack-KrOrm7JZ.js} +1 -1
- package/dist/dashboard/assets/stack-KrOrm7JZ.js.gz +0 -0
- package/dist/dashboard/assets/{state-CWoo7ty5.js → state-DLS7IDMy.js} +1 -1
- package/dist/dashboard/assets/state-DLS7IDMy.js.gz +0 -0
- package/dist/dashboard/assets/{switch-CCjT6IkF.js → switch-42ItvbAs.js} +1 -1
- package/dist/dashboard/assets/switch-42ItvbAs.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-DjkLHUcE.js → useMutation-Dmnfh1Ue.js} +1 -1
- package/dist/dashboard/assets/useMutation-Dmnfh1Ue.js.gz +0 -0
- package/dist/dashboard/index.html +3 -2
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +117 -115
- package/migrations/full-postgres/0026_add_qoder_native_identity.sql +20 -0
- package/migrations/full-postgres/0027_qoder_identity_genuine_seed.sql +22 -0
- package/migrations/full-postgres/0028_add_family_template_key_telemetry.sql +40 -0
- package/migrations/full-postgres/0029_null_share_child_policy_columns.sql +38 -0
- package/migrations/full-postgres/0030_add_api_key_max_children.sql +10 -0
- package/migrations/full-postgres/0031_tenant_system_prompts.sql +16 -0
- package/migrations/lite-sqlite3/0026_add_qoder_native_identity.sql +15 -0
- package/migrations/lite-sqlite3/0027_qoder_identity_genuine_seed.sql +22 -0
- package/migrations/lite-sqlite3/0028_add_family_template_key_telemetry.sql +40 -0
- package/migrations/lite-sqlite3/0029_null_share_child_policy_columns.sql +38 -0
- package/migrations/lite-sqlite3/0030_add_api_key_max_children.sql +10 -0
- package/migrations/lite-sqlite3/0031_tenant_system_prompts.sql +10 -0
- package/package.json +1 -1
- package/src/config/network.ts +1 -1
- package/src/console/api-keys/bucket-spend.ts +7 -7
- package/src/console/api-keys/contracts.ts +423 -410
- package/src/console/api-keys/routes.ts +118 -19
- package/src/console/backup/export-endpoint.ts +8 -11
- package/src/console/backup/routes.ts +40 -8
- package/src/console/backup/service.ts +11 -6
- package/src/console/backup/store.ts +384 -88
- package/src/console/cli-tools/contracts.ts +10 -4
- package/src/console/dashboard-assets.ts +16 -1
- package/src/console/domain-registration.ts +2 -0
- package/src/console/quota/account-quota-tenant.ts +8 -1
- package/src/console/quota/account-quota.ts +4 -1
- package/src/console/quota/native-identity.ts +189 -0
- package/src/console/remote-support/contracts.ts +12 -6
- package/src/console/remote-support/service.ts +2 -2
- package/src/console/share/share-router.ts +39 -6
- package/src/console/share/share-stats.ts +64 -25
- package/src/console/share/share-usage.ts +24 -7
- package/src/console/system-prompt/contracts.ts +102 -0
- package/src/console/system-prompt/routes.ts +165 -0
- package/src/main.ts +11 -0
- package/src/network/outbound-fetch.ts +281 -226
- package/src/network/pool/agent.ts +7 -0
- package/src/observability/event-query.ts +118 -7
- package/src/persistence/db-pg.ts +42 -0
- package/src/persistence/schema.ts +102 -0
- package/src/persistence/stores/api-key-store.ts +753 -581
- package/src/persistence/stores/share-store.ts +65 -13
- package/src/persistence/stores/telemetry-store.ts +57 -1
- package/src/persistence/system-prompt-store.ts +104 -0
- package/src/protocol/reasoning.ts +65 -1
- package/src/protocol/request/chat.ts +1 -0
- package/src/providers/default-registry.ts +1 -0
- package/src/providers/integrations/antigravity/antigravity.ts +20 -23
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +30 -2
- package/src/providers/integrations/buddy/buddy-media.ts +5 -1
- package/src/providers/integrations/buddy/codebuddy/provider.ts +5 -1
- package/src/providers/integrations/buddy/workbuddy/provider.ts +5 -1
- package/src/providers/integrations/cline/cline.ts +7 -0
- package/src/providers/integrations/github/github.ts +20 -1
- package/src/providers/integrations/hermes.ts +5 -1
- package/src/providers/integrations/kilo/kilo.ts +4 -0
- package/src/providers/integrations/ollamacloud-models.ts +50 -0
- package/src/providers/integrations/openrouter/provider.ts +4 -0
- package/src/providers/integrations/qoder/qoder-checkin.ts +224 -51
- package/src/providers/integrations/qoder/qoder-native-identity-store.ts +156 -0
- package/src/providers/integrations/qoder/qoder-oauth.ts +24 -4
- package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
- package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
- package/src/providers/integrations/qoder/qoder-umid.ts +288 -0
- package/src/providers/integrations/qoder/qoder.ts +1375 -1641
- package/src/providers/operations/account-error-classification.ts +1682 -1660
- package/src/providers/operations/account-health-service.ts +619 -621
- package/src/providers/operations/client-versions.ts +37 -22
- package/src/providers/operations/provider-catalog-service.ts +11 -1
- package/src/providers/provider-contracts.ts +15 -2
- package/src/security/access-control.ts +19 -0
- package/src/security/api-key-auth.ts +14 -1
- package/src/security/cli-client-fingerprint.ts +9 -2
- package/src/transport/dispatch/empty-stream.ts +224 -224
- package/src/transport/dispatch/proxy-request.ts +31 -2
- package/src/transport/dispatch/streaming/stream.ts +16 -1
- package/src/transport/dispatch/upstream.ts +10 -0
- package/src/transport/failure-policy.ts +33 -0
- package/src/transport/request/preparer.ts +17 -1
- package/src/transport/request/system-prompt-injection.ts +89 -0
- package/src/transport/request/tenant-preferences.ts +43 -0
- package/src/transport/routing/admission.ts +13 -3
- package/src/transport/routing/route-catalog.ts +41 -12
- package/src/transport/routing/route-model.ts +53 -0
- package/src/transport/routing/router.ts +306 -228
- package/src/transport/surface/responses/encode.ts +2 -3
- package/src/transport/surface/responses/parse.ts +49 -7
- package/src/types/undici.d.ts +33 -0
- package/src/workers/daily-checkin.ts +2 -0
- package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js +0 -1
- package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js +0 -1
- package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js +0 -1
- package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js +0 -1
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +0 -4
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js +0 -9
- package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js +0 -1
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
- package/dist/dashboard/assets/app-KvsUJUMF.css +0 -2
- package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
- package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
- package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
- package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
- package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
- package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
- package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CFM2sMX1.js +0 -1
- package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
- package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
- package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
- package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
- package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
- package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
- package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
- package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
- package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
- package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
- package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
- package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
- package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
- package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
- package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
- package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
- package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
- package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
- package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
- /package/dist/dashboard/assets/{app-BaWd0puM.js → app-wvwCi4Eh.js} +0 -0
- /package/dist/dashboard/assets/{app-BaWd0puM.js.gz → app-wvwCi4Eh.js.gz} +0 -0
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
accountsUnavailableError,
|
|
7
7
|
ambiguousModelError,
|
|
8
8
|
capacityExhaustedError,
|
|
9
|
-
capabilityUnsupportedError,
|
|
10
9
|
modelNotFoundError,
|
|
11
10
|
type AdmissionController,
|
|
12
11
|
type ComboDefinition,
|
|
@@ -17,6 +16,8 @@ import {
|
|
|
17
16
|
type EligibilityDecision,
|
|
18
17
|
type RouteContinuationAffinity,
|
|
19
18
|
type RouteSnapshot,
|
|
19
|
+
type RoutingDecision,
|
|
20
|
+
ROUTING_RANK,
|
|
20
21
|
} from "./route-model";
|
|
21
22
|
import {
|
|
22
23
|
candidateSupportsRequest,
|
|
@@ -45,9 +46,9 @@ import { sharedClaudeSlowLanes } from "../../providers/integrations/claude/claud
|
|
|
45
46
|
function belowUsageFloor(state: {
|
|
46
47
|
readonly usage_floor?: number;
|
|
47
48
|
readonly usage_floor_unit?: "credit" | "percent";
|
|
48
|
-
readonly quota_windows?: readonly
|
|
49
|
+
readonly quota_windows?: readonly unknown[] | undefined;
|
|
49
50
|
}): boolean {
|
|
50
|
-
const windows = state.quota_windows;
|
|
51
|
+
const windows = state.quota_windows as readonly ProviderQuotaWindow[] | undefined;
|
|
51
52
|
if (windows === undefined || windows.length === 0) return false;
|
|
52
53
|
const credit = totalRemainingCredit(windows);
|
|
53
54
|
const percent = totalRemainingPercent(windows);
|
|
@@ -254,7 +255,9 @@ function earliestWindowResetMs(
|
|
|
254
255
|
* account comes back, so quoting the slowest would hold it off longer than it
|
|
255
256
|
* has to wait.
|
|
256
257
|
*/
|
|
257
|
-
function earliestResetMs(
|
|
258
|
+
function earliestResetMs(
|
|
259
|
+
decisions: readonly { readonly cooldown_until?: number }[],
|
|
260
|
+
): number | undefined {
|
|
258
261
|
let earliest: number | undefined;
|
|
259
262
|
for (const decision of decisions) {
|
|
260
263
|
const until = decision.cooldown_until;
|
|
@@ -264,30 +267,6 @@ function earliestResetMs(decisions: readonly EligibilityDecision[]): number | un
|
|
|
264
267
|
return earliest;
|
|
265
268
|
}
|
|
266
269
|
|
|
267
|
-
/**
|
|
268
|
-
* Stamps the slow lane onto candidates whose account is currently lane-active.
|
|
269
|
-
*
|
|
270
|
-
* Read at plan time from the live lane registry, exactly as `applyUsageFloor`
|
|
271
|
-
* reads live quota: lane state changes per response, so baking it into the
|
|
272
|
-
* snapshot would route on a stale observation. An account with no lane state
|
|
273
|
-
* is untouched, which keeps every non-Claude provider byte-identical here.
|
|
274
|
-
*/
|
|
275
|
-
function applySlowLane(
|
|
276
|
-
candidates: readonly RouteCandidate[],
|
|
277
|
-
): readonly RouteCandidate[] {
|
|
278
|
-
let stamped = false;
|
|
279
|
-
const out = candidates.map((candidate) => {
|
|
280
|
-
const accountId = (
|
|
281
|
-
candidate as RouteCandidate & { readonly provider_account_id?: string }
|
|
282
|
-
).provider_account_id;
|
|
283
|
-
if (accountId === undefined || !sharedClaudeSlowLanes.isActive(accountId))
|
|
284
|
-
return candidate;
|
|
285
|
-
stamped = true;
|
|
286
|
-
return { ...candidate, lane: "slow" as const };
|
|
287
|
-
});
|
|
288
|
-
return stamped ? out : candidates;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
270
|
export class EligibilityEvaluator {
|
|
292
271
|
evaluate(candidate: RouteCandidate): EligibilityDecision {
|
|
293
272
|
const state = candidate as RouteCandidate & {
|
|
@@ -467,29 +446,151 @@ export class RoutingEngine {
|
|
|
467
446
|
return [...run.slice(idx), ...run.slice(0, idx)];
|
|
468
447
|
}
|
|
469
448
|
|
|
470
|
-
|
|
449
|
+
/**
|
|
450
|
+
* Stamps one candidate with its provider's usage floor + quota windows (the
|
|
451
|
+
* per-candidate half of the old `applyUsageFloor`).
|
|
452
|
+
*/
|
|
453
|
+
private async applyUsageFloorToCandidate(
|
|
454
|
+
candidate: RouteCandidate,
|
|
455
|
+
snapshot: RouteSnapshot,
|
|
456
|
+
tenantId: string | null,
|
|
457
|
+
): Promise<RouteCandidate> {
|
|
458
|
+
const accountId = candidate.provider_account_id;
|
|
459
|
+
if (accountId === undefined) return candidate;
|
|
460
|
+
const setting = this.resolveProviderRouting(
|
|
461
|
+
snapshot,
|
|
462
|
+
candidate.provider_id,
|
|
463
|
+
candidate.tenant_id ?? tenantId,
|
|
464
|
+
);
|
|
465
|
+
const cached = await getCachedQuotaEntry(
|
|
466
|
+
quotaLens(candidate.tenant_id ?? tenantId),
|
|
467
|
+
accountId,
|
|
468
|
+
this.redis,
|
|
469
|
+
).catch(() => undefined);
|
|
470
|
+
const quotaWindows = cached?.quota.windows ?? [];
|
|
471
|
+
return {
|
|
472
|
+
...candidate,
|
|
473
|
+
...(setting?.usageFloor == null
|
|
474
|
+
? {}
|
|
475
|
+
: { usage_floor: setting.usageFloor, usage_floor_unit: setting.usageFloorUnit }),
|
|
476
|
+
quota_windows: quotaWindows,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* One merged evaluation of a candidate: health/cooldown eligibility, slow
|
|
482
|
+
* lane, usage floor, plan gate, and capability support. Returns the candidate
|
|
483
|
+
* and the single `RoutingDecision` every downstream layer reads.
|
|
484
|
+
*/
|
|
485
|
+
private evaluateCandidate(
|
|
486
|
+
candidate: RouteCandidate,
|
|
487
|
+
_snapshot: RouteSnapshot,
|
|
488
|
+
_tenantId: string | null,
|
|
489
|
+
requiredCapabilities: readonly RequiredCapability[] | undefined,
|
|
490
|
+
): { candidate: RouteCandidate; decision: RoutingDecision } {
|
|
491
|
+
const eligibility = this.eligibility.evaluate(candidate);
|
|
492
|
+
if (!eligibility.eligible) {
|
|
493
|
+
const reason = eligibility.reason;
|
|
494
|
+
const rank =
|
|
495
|
+
reason === "model_cooldown" || reason === "cooldown_hard"
|
|
496
|
+
? ROUTING_RANK.COOLING
|
|
497
|
+
: ROUTING_RANK.SATURATED;
|
|
498
|
+
return {
|
|
499
|
+
candidate,
|
|
500
|
+
decision: {
|
|
501
|
+
eligible: false,
|
|
502
|
+
rank,
|
|
503
|
+
reason,
|
|
504
|
+
...(eligibility.cooldown_until !== undefined
|
|
505
|
+
? { cooldown_until: eligibility.cooldown_until }
|
|
506
|
+
: {}),
|
|
507
|
+
floor_blocked: reason === "quota_exhausted",
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
// Slow lane.
|
|
512
|
+
const accountId = (candidate as RouteCandidate & { readonly provider_account_id?: string })
|
|
513
|
+
.provider_account_id;
|
|
514
|
+
if (accountId !== undefined && sharedClaudeSlowLanes.isActive(accountId)) {
|
|
515
|
+
return {
|
|
516
|
+
candidate,
|
|
517
|
+
decision: {
|
|
518
|
+
eligible: true,
|
|
519
|
+
rank: ROUTING_RANK.SLOW,
|
|
520
|
+
reason: "slow",
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
// Plan gate (free plan).
|
|
525
|
+
if (!planAllowsCandidate(candidate)) {
|
|
526
|
+
return {
|
|
527
|
+
candidate,
|
|
528
|
+
decision: {
|
|
529
|
+
eligible: false,
|
|
530
|
+
rank: ROUTING_RANK.SATURATED,
|
|
531
|
+
reason: "plan_gate",
|
|
532
|
+
plan_gate_blocked: true,
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
// Capability.
|
|
537
|
+
if (requiredCapabilities !== undefined && requiredCapabilities.length > 0) {
|
|
538
|
+
if (!candidateSupportsRequest(candidate, requiredCapabilities)) {
|
|
539
|
+
return {
|
|
540
|
+
candidate,
|
|
541
|
+
decision: {
|
|
542
|
+
eligible: false,
|
|
543
|
+
rank: ROUTING_RANK.SATURATED,
|
|
544
|
+
reason: "capability_unsupported",
|
|
545
|
+
capability_blocked: true,
|
|
546
|
+
},
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
candidate,
|
|
552
|
+
decision: {
|
|
553
|
+
eligible: true,
|
|
554
|
+
rank: ROUTING_RANK.FRESH,
|
|
555
|
+
reason: "healthy",
|
|
556
|
+
},
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Rotates accounts within the same rank group only. The rank is the primary
|
|
562
|
+
* sort key, so rotation can never float a cooling/saturated account ahead of
|
|
563
|
+
* a fresh one — the old double-sort is gone by construction.
|
|
564
|
+
*/
|
|
565
|
+
private applyProviderRoutingSorted(
|
|
471
566
|
eligible: readonly RouteCandidate[],
|
|
567
|
+
decisions: ReadonlyMap<string, RoutingDecision>,
|
|
472
568
|
snapshot: RouteSnapshot,
|
|
473
569
|
tid: string | null,
|
|
474
570
|
): RouteCandidate[] {
|
|
475
571
|
if (eligible.length <= 1) return [...eligible];
|
|
476
|
-
if (!snapshot.providerRouting) return [...eligible];
|
|
477
572
|
const result: RouteCandidate[] = [];
|
|
478
573
|
let i = 0;
|
|
479
574
|
while (i < eligible.length) {
|
|
480
575
|
const cur = eligible[i]!;
|
|
481
|
-
const
|
|
576
|
+
const rank = decisions.get(admissionKey(cur))?.rank ?? ROUTING_RANK.FRESH;
|
|
482
577
|
let j = i + 1;
|
|
483
|
-
while (
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
578
|
+
while (j < eligible.length) {
|
|
579
|
+
const next = eligible[j]!;
|
|
580
|
+
const nextRank = decisions.get(admissionKey(next))?.rank ?? ROUTING_RANK.FRESH;
|
|
581
|
+
if (
|
|
582
|
+
next.provider_id !== cur.provider_id ||
|
|
583
|
+
next.model_id !== cur.model_id ||
|
|
584
|
+
nextRank !== rank
|
|
585
|
+
) {
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
j += 1;
|
|
589
|
+
}
|
|
488
590
|
const run = eligible.slice(i, j);
|
|
489
591
|
if (run.length > 1) {
|
|
490
592
|
const settings = this.resolveProviderRouting(snapshot, cur.provider_id, tid);
|
|
491
|
-
|
|
492
|
-
result.push(...reordered);
|
|
593
|
+
result.push(...this.reorderRun([...run], settings, tid));
|
|
493
594
|
} else {
|
|
494
595
|
result.push(...run);
|
|
495
596
|
}
|
|
@@ -497,44 +598,89 @@ export class RoutingEngine {
|
|
|
497
598
|
}
|
|
498
599
|
return result;
|
|
499
600
|
}
|
|
601
|
+
|
|
500
602
|
/**
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
* and
|
|
603
|
+
* Applies continuation affinity as a hard pin (or a rebase pass-through).
|
|
604
|
+
*
|
|
605
|
+
* Returns a plan when the pinned account is present and eligible, `null`
|
|
606
|
+
* when the continuation is rebaseable and the pin is empty (so the caller
|
|
607
|
+
* continues with ordinary routing), and throws the strict 400 when the pin
|
|
608
|
+
* is a hard constraint with no matching candidate.
|
|
506
609
|
*/
|
|
507
|
-
private
|
|
508
|
-
|
|
610
|
+
private resolveContinuationPlan(
|
|
611
|
+
affinity: RouteContinuationAffinity,
|
|
612
|
+
matching: readonly RouteCandidate[],
|
|
613
|
+
requestedModel: string,
|
|
614
|
+
resolvedModel: string,
|
|
509
615
|
snapshot: RouteSnapshot,
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
616
|
+
): RoutePlan | null {
|
|
617
|
+
const pinned = matching.filter(
|
|
618
|
+
(candidate) =>
|
|
619
|
+
candidate.provider_id === affinity.providerId &&
|
|
620
|
+
candidate.provider_account_id === affinity.providerAccountId,
|
|
621
|
+
);
|
|
622
|
+
if (pinned.length === 0) {
|
|
623
|
+
if (affinity.rebaseable) return null;
|
|
624
|
+
throw new GatewayError(
|
|
625
|
+
"invalid_request",
|
|
626
|
+
400,
|
|
627
|
+
"This tool result belongs to a Cursor account that is no longer routable. Reconnect the account, or resend the complete conversation.",
|
|
628
|
+
{
|
|
629
|
+
providerId: affinity.providerId,
|
|
630
|
+
accountId: affinity.providerAccountId,
|
|
631
|
+
},
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
// The pinned account owns the live session, so it is the only correct
|
|
635
|
+
// target for this tool result — but health still applies. Forcing a
|
|
636
|
+
// session whose account is hard-cooling (quota exhausted) reproduces the
|
|
637
|
+
// refusal that cooled it, which is how "still hit a cooled-down account"
|
|
638
|
+
// leaked through continuation affinity. Evaluate the pin before
|
|
639
|
+
// returning: time-bounded cooling answers 429 with the real reset; an
|
|
640
|
+
// account that is disabled/locked answers a clear 400.
|
|
641
|
+
const pinnedDecisions = pinned.map((candidate) => this.eligibility.evaluate(candidate));
|
|
642
|
+
const blocked = pinnedDecisions.find((decision) => !decision.eligible);
|
|
643
|
+
if (blocked !== undefined) {
|
|
644
|
+
const reason = blocked.reason;
|
|
645
|
+
if (
|
|
646
|
+
reason === "cooldown_hard" ||
|
|
647
|
+
reason === "model_cooldown" ||
|
|
648
|
+
(reason === "quota_exhausted" && typeof blocked.cooldown_until === "number")
|
|
649
|
+
) {
|
|
650
|
+
throw accountsRateLimitedError(requestedModel, resolvedModel, earliestResetMs(pinnedDecisions));
|
|
525
651
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
:
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
652
|
+
throw new GatewayError(
|
|
653
|
+
"invalid_request",
|
|
654
|
+
400,
|
|
655
|
+
"This tool result belongs to an account that is no longer routable. Reconnect the account, or resend the complete conversation.",
|
|
656
|
+
{
|
|
657
|
+
providerId: affinity.providerId,
|
|
658
|
+
accountId: affinity.providerAccountId,
|
|
659
|
+
},
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
const decisions = new Map<string, RoutingDecision>();
|
|
663
|
+
for (const candidate of pinned) {
|
|
664
|
+
const decision = pinnedDecisions.find((d) => d.candidate === candidate);
|
|
665
|
+
decisions.set(admissionKey(candidate), {
|
|
666
|
+
eligible: true,
|
|
667
|
+
rank: ROUTING_RANK.FRESH,
|
|
668
|
+
reason: "healthy",
|
|
669
|
+
...(decision?.cooldown_until !== undefined
|
|
670
|
+
? { cooldown_until: decision.cooldown_until }
|
|
671
|
+
: {}),
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
return {
|
|
675
|
+
revision: snapshot.revision,
|
|
676
|
+
candidates: pinned,
|
|
677
|
+
requested_model: requestedModel,
|
|
678
|
+
resolved_model: resolvedModel,
|
|
679
|
+
provider_id: pinned[0]!.provider_id,
|
|
680
|
+
decisions,
|
|
681
|
+
};
|
|
535
682
|
}
|
|
536
683
|
|
|
537
|
-
|
|
538
684
|
async plan(
|
|
539
685
|
requestedModel: string,
|
|
540
686
|
snapshot: RouteSnapshot,
|
|
@@ -562,69 +708,86 @@ export class RoutingEngine {
|
|
|
562
708
|
// or into a fresh turn that never asked for the call. So the candidate set
|
|
563
709
|
// is restricted before any health, cooling, or rotation logic runs, and an
|
|
564
710
|
// empty result is a refusal rather than a fallback to normal routing.
|
|
711
|
+
// Continuation affinity is applied first and absolutely.
|
|
712
|
+
//
|
|
713
|
+
// A request carrying a tool result for a provider session this process is
|
|
714
|
+
// holding MUST reach the exact live account that owns that session. Routing
|
|
715
|
+
// it anywhere else is not a degraded choice — it is a correctness failure:
|
|
716
|
+
// the result would be written onto a different account's upstream turn
|
|
717
|
+
// (corrupting both conversations and leaking the first account's context),
|
|
718
|
+
// or into a fresh turn that never asked for the call. So the candidate set
|
|
719
|
+
// is restricted before any health, cooling, or rotation logic runs, and an
|
|
720
|
+
// empty result is a refusal rather than a fallback to normal routing.
|
|
721
|
+
//
|
|
722
|
+
// The one exception is a *rebaseable* continuation: when the conversation
|
|
723
|
+
// is complete (every tool result is answered in the history) and the pinned
|
|
724
|
+
// account is no longer routable for the requested model, the request may
|
|
725
|
+
// fall through to normal routing so the adapter starts a fresh Run carrying
|
|
726
|
+
// the recorded history. This is what makes model switches across accounts
|
|
727
|
+
// work — see `resolveContinuationPlan` and `preparer.ts`.
|
|
565
728
|
if (continuationAffinity !== undefined) {
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
729
|
+
const continuationPlan = this.resolveContinuationPlan(
|
|
730
|
+
continuationAffinity,
|
|
731
|
+
matching,
|
|
732
|
+
requestedModel,
|
|
733
|
+
resolved.model,
|
|
734
|
+
snapshot,
|
|
570
735
|
);
|
|
571
|
-
if (
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
736
|
+
if (continuationPlan !== null) return continuationPlan;
|
|
737
|
+
}
|
|
738
|
+
// ── One evaluation pass ──────────────────────────────────────────────
|
|
739
|
+
// Every filter that used to run as a separate pass (usage floor, slow lane,
|
|
740
|
+
// health/cooldown eligibility, plan gate, capability) is merged into a
|
|
741
|
+
// single per-candidate decision. `plan()` then sorts once by rank and
|
|
742
|
+
// hands the full `decisions` map downstream, so no later layer re-derives
|
|
743
|
+
// a competing ordering.
|
|
744
|
+
const evaluations = await Promise.all(
|
|
745
|
+
matching.map(async (candidate) => {
|
|
746
|
+
const stamped = await this.applyUsageFloorToCandidate(candidate, snapshot, tid);
|
|
747
|
+
return this.evaluateCandidate(stamped, snapshot, tid, requiredCapabilities);
|
|
748
|
+
}),
|
|
749
|
+
);
|
|
750
|
+
const decisions = new Map<string, RoutingDecision>();
|
|
751
|
+
for (const evaluation of evaluations) {
|
|
752
|
+
decisions.set(admissionKey(evaluation.candidate), evaluation.decision);
|
|
753
|
+
}
|
|
754
|
+
// Saturation is read once (inflight counters) and merged into the same
|
|
755
|
+
// decision map rather than a second sort pass.
|
|
756
|
+
const saturated = await this.saturatedCandidates(matching);
|
|
757
|
+
for (const candidate of saturated) {
|
|
758
|
+
const key = admissionKey(candidate);
|
|
759
|
+
const existing = decisions.get(key);
|
|
760
|
+
if (existing !== undefined) {
|
|
761
|
+
decisions.set(key, {
|
|
762
|
+
...existing,
|
|
763
|
+
eligible: existing.eligible,
|
|
764
|
+
rank: ROUTING_RANK.SATURATED,
|
|
765
|
+
saturated: true,
|
|
766
|
+
});
|
|
581
767
|
}
|
|
582
|
-
return {
|
|
583
|
-
revision: snapshot.revision,
|
|
584
|
-
candidates: pinned,
|
|
585
|
-
requested_model: requestedModel,
|
|
586
|
-
resolved_model: resolved.model,
|
|
587
|
-
provider_id: pinned[0]!.provider_id,
|
|
588
|
-
};
|
|
589
768
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const laned = applySlowLane(gated);
|
|
595
|
-
const decisions = laned.map((candidate) => this.eligibility.evaluate(candidate));
|
|
596
|
-
let eligible = decisions.filter((d) => d.eligible).map((d) => d.candidate);
|
|
769
|
+
let eligible = evaluations
|
|
770
|
+
.map((evaluation) => evaluation.candidate)
|
|
771
|
+
.filter((candidate) => decisions.get(admissionKey(candidate))?.eligible === true);
|
|
772
|
+
|
|
597
773
|
if (eligible.length === 0) {
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
// "
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
// comment gives: the upstream said this (account, model) pair is
|
|
604
|
-
// exhausted until a named reset, so dialing it can only reproduce the
|
|
605
|
-
// refusal. Answering 503 there told the client the model was down when
|
|
606
|
-
// every account serving it was merely resting, which is the "503 DOWN
|
|
607
|
-
// while the model is allowed" report — and 503 invites a client to give
|
|
608
|
-
// up or fail a health check, where 429 says retry later.
|
|
609
|
-
const rateLimited = decisions.some(
|
|
774
|
+
// Time-bounded exclusions answer 429, not 503. See the long comment that
|
|
775
|
+
// used to live here: cooldown/floor exclusions with a stated reset are
|
|
776
|
+
// "not until then", not "down". Only a floor with no stated reset stays
|
|
777
|
+
// 503.
|
|
778
|
+
const rateLimited = [...decisions.values()].some(
|
|
610
779
|
(d) =>
|
|
611
780
|
d.reason === "cooldown_hard" ||
|
|
612
781
|
d.reason === "model_cooldown" ||
|
|
613
|
-
// A usage-floor exclusion whose window states a reset is the same
|
|
614
|
-
// shape as the two above: the account is resting until a named
|
|
615
|
-
// instant, not broken. Falling through to 503 tells the client the
|
|
616
|
-
// model is down while every account serving it is merely waiting for
|
|
617
|
-
// its window to refill — the "503 DOWN while the model is allowed"
|
|
618
|
-
// report again. Only a floor with *no* stated
|
|
619
|
-
// reset stays unavailable: there is nothing to retry against yet.
|
|
620
782
|
(d.reason === "quota_exhausted" && typeof d.cooldown_until === "number"),
|
|
621
783
|
);
|
|
622
784
|
if (rateLimited) {
|
|
623
|
-
throw accountsRateLimitedError(
|
|
785
|
+
throw accountsRateLimitedError(
|
|
786
|
+
requestedModel,
|
|
787
|
+
resolved.model,
|
|
788
|
+
earliestResetMs([...decisions.values()]),
|
|
789
|
+
);
|
|
624
790
|
}
|
|
625
|
-
// Combo members without any routable candidate never reach the client
|
|
626
|
-
// envelope — but the operator needs them to fix the combo, so they go
|
|
627
|
-
// to the server logs with the full resolution picture.
|
|
628
791
|
if (unmatchedMembers.length > 0) {
|
|
629
792
|
log.warn("[routing] combo members without routable candidates", {
|
|
630
793
|
requested: requestedModel,
|
|
@@ -632,119 +795,33 @@ export class RoutingEngine {
|
|
|
632
795
|
unmatched: [...new Set(unmatchedMembers)].sort(),
|
|
633
796
|
});
|
|
634
797
|
}
|
|
635
|
-
throw accountsUnavailableError(
|
|
636
|
-
requestedModel,
|
|
637
|
-
decisions.map((d) => d.reason),
|
|
638
|
-
);
|
|
798
|
+
throw accountsUnavailableError(requestedModel, [...decisions.values()].map((d) => d.reason));
|
|
639
799
|
}
|
|
640
|
-
|
|
641
|
-
//
|
|
642
|
-
|
|
643
|
-
// cooling remains there is no sibling to fail over to — dialing a cooling
|
|
644
|
-
// account can only reproduce the refusal that cooled it, and doing so made
|
|
645
|
-
// the gateway "still hit a cooled-down account" instead of answering the
|
|
646
|
-
// client honestly. Answer 429 (rate limited) so the client retries after
|
|
647
|
-
// the reset rather than 503 capacity that is merely resting.
|
|
648
|
-
// A slow-lane account still serves on spare capacity, so it counts as
|
|
649
|
-
// servable: an all-slow pool must plan and dial rather than answer 429
|
|
650
|
-
// while capacity sits idle.
|
|
651
|
-
const healthyCount = decisions.filter(
|
|
800
|
+
|
|
801
|
+
// Every eligible candidate cooling and no healthy/slow sibling → 429.
|
|
802
|
+
const healthyCount = [...decisions.values()].filter(
|
|
652
803
|
(d) => d.eligible && (d.reason === "healthy" || d.reason === "slow"),
|
|
653
804
|
).length;
|
|
654
805
|
if (healthyCount === 0) {
|
|
655
|
-
throw accountsRateLimitedError(
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
// cooling still routes instead of failing. Sorted on the evaluator's reason
|
|
660
|
-
// so the ordering and the eligibility rule cannot disagree about which
|
|
661
|
-
// candidates are cooling.
|
|
662
|
-
// Slow-lane accounts are eligible but tried behind fresh siblings, and
|
|
663
|
-
// ahead of cooling ones: fresh → slow → cooling → saturated. A slow account
|
|
664
|
-
// serves on spare capacity, so it must be reached when no fresh candidate
|
|
665
|
-
// remains, but it should not absorb traffic a fresh account could take
|
|
666
|
-
// without the lane's latency.
|
|
667
|
-
const slow = new Set(
|
|
668
|
-
decisions
|
|
669
|
-
.filter((decision) => decision.eligible && decision.reason === "slow")
|
|
670
|
-
.map((decision) => decision.candidate),
|
|
671
|
-
);
|
|
672
|
-
if (slow.size > 0 && slow.size < eligible.length) {
|
|
673
|
-
eligible = [
|
|
674
|
-
...eligible.filter((candidate) => !slow.has(candidate)),
|
|
675
|
-
...eligible.filter((candidate) => slow.has(candidate)),
|
|
676
|
-
];
|
|
677
|
-
}
|
|
678
|
-
const cooling = new Set(
|
|
679
|
-
decisions
|
|
680
|
-
.filter((decision) => decision.eligible && decision.reason === "cooldown")
|
|
681
|
-
.map((decision) => decision.candidate),
|
|
682
|
-
);
|
|
683
|
-
if (cooling.size > 0 && cooling.size < eligible.length) {
|
|
684
|
-
eligible = [
|
|
685
|
-
...eligible.filter((candidate) => !cooling.has(candidate)),
|
|
686
|
-
...eligible.filter((candidate) => cooling.has(candidate)),
|
|
687
|
-
];
|
|
688
|
-
}
|
|
689
|
-
// Accounts already at their concurrency ceiling go last, for the same
|
|
690
|
-
// reason cooling ones do. Without this the plan order put a saturated
|
|
691
|
-
// account first and the first attempt was spent learning it was full —
|
|
692
|
-
// an admission 429, a retry, and only then the sibling that had room. The
|
|
693
|
-
// caller paid a round-trip to be told something the gateway already knew
|
|
694
|
-
// from its own inflight counters.
|
|
695
|
-
//
|
|
696
|
-
// Deprioritized, not dropped: when every account is genuinely saturated
|
|
697
|
-
// the request must still be dialed somewhere and answered 429, rather than
|
|
698
|
-
// reading as "no account available" (503) for a deployment that is merely
|
|
699
|
-
// busy.
|
|
700
|
-
const saturated = await this.saturatedCandidates(eligible);
|
|
701
|
-
if (saturated.size > 0 && saturated.size < eligible.length) {
|
|
702
|
-
eligible = [
|
|
703
|
-
...eligible.filter((candidate) => !saturated.has(candidate)),
|
|
704
|
-
...eligible.filter((candidate) => saturated.has(candidate)),
|
|
705
|
-
];
|
|
706
|
-
}
|
|
707
|
-
// Capability-aware routing: filter against snapshot capability profiles
|
|
708
|
-
// (shared predicate with the planner). Empty here means no candidate
|
|
709
|
-
// supports the variant — the planner catches this code and re-plans a
|
|
710
|
-
// degraded variant; model/tenant/ambiguity errors above still throw.
|
|
711
|
-
if (requiredCapabilities !== undefined && requiredCapabilities.length > 0) {
|
|
712
|
-
eligible = eligible.filter((candidate) =>
|
|
713
|
-
candidateSupportsRequest(candidate, requiredCapabilities),
|
|
806
|
+
throw accountsRateLimitedError(
|
|
807
|
+
requestedModel,
|
|
808
|
+
resolved.model,
|
|
809
|
+
earliestResetMs([...decisions.values()]),
|
|
714
810
|
);
|
|
715
|
-
// No silent model switch. The requested model cannot serve
|
|
716
|
-
// this requirement set, so the planner degrades it in place (media becomes
|
|
717
|
-
// a placeholder, controls are dropped) and re-plans. When nothing
|
|
718
|
-
// degradable remains, the request fails as `capability_unsupported`
|
|
719
|
-
// rather than being served by a different model the caller never asked for.
|
|
720
|
-
if (eligible.length === 0)
|
|
721
|
-
throw capabilityUnsupportedError(requiredCapabilities.join(", "));
|
|
722
|
-
}
|
|
723
|
-
// Plan gate: a model the upstream refuses on a free plan is not offered to
|
|
724
|
-
// an account the upstream reports as free. That pair is a certain refusal,
|
|
725
|
-
// and sending it anyway advances the account's failure streak toward a
|
|
726
|
-
// six-hour park for something that is not the account's fault.
|
|
727
|
-
//
|
|
728
|
-
// Applied only when another candidate survives. Every account being free is
|
|
729
|
-
// not a routing failure — it is the operator having only free accounts, and
|
|
730
|
-
// the honest outcome is the upstream's own `11158`, which names the model
|
|
731
|
-
// and the plan. Dropping the last candidate would replace that clear refusal
|
|
732
|
-
// with a generic "no candidates" that names neither.
|
|
733
|
-
const planAllowed = eligible.filter((candidate) => planAllowsCandidate(candidate));
|
|
734
|
-
if (planAllowed.length > 0) eligible = planAllowed;
|
|
735
|
-
eligible = this.applyProviderRouting(eligible, snapshot, tid);
|
|
736
|
-
// Re-assert the cooling order: `applyProviderRouting` rotates accounts
|
|
737
|
-
// within one `provider::model` run, and that rotation is by design blind to
|
|
738
|
-
// health — so it can float a cooling account back to the front and undo the
|
|
739
|
-
// deprioritization above. Sorting again here is what makes the cooling rule
|
|
740
|
-
// hold regardless of the operator's rotation strategy. A stable partition
|
|
741
|
-
// keeps the rotation's own ordering inside each group.
|
|
742
|
-
if (cooling.size > 0 && cooling.size < eligible.length) {
|
|
743
|
-
eligible = [
|
|
744
|
-
...eligible.filter((candidate) => !cooling.has(candidate)),
|
|
745
|
-
...eligible.filter((candidate) => cooling.has(candidate)),
|
|
746
|
-
];
|
|
747
811
|
}
|
|
812
|
+
|
|
813
|
+
// Single sort by rank (fresh → slow → cooling → saturated), stable.
|
|
814
|
+
eligible = [...eligible].sort((left, right) => {
|
|
815
|
+
const leftRank = decisions.get(admissionKey(left))?.rank ?? ROUTING_RANK.FRESH;
|
|
816
|
+
const rightRank = decisions.get(admissionKey(right))?.rank ?? ROUTING_RANK.FRESH;
|
|
817
|
+
return leftRank - rightRank;
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
// Provider routing rotates accounts within the same rank group only; it can
|
|
821
|
+
// never float a cooling/saturated account ahead of a fresh one because the
|
|
822
|
+
// rank is the primary key of the sort.
|
|
823
|
+
eligible = this.applyProviderRoutingSorted(eligible, decisions, snapshot, tid);
|
|
824
|
+
|
|
748
825
|
const chosen = eligible[0]!;
|
|
749
826
|
return {
|
|
750
827
|
revision: snapshot.revision,
|
|
@@ -752,6 +829,7 @@ export class RoutingEngine {
|
|
|
752
829
|
requested_model: requestedModel,
|
|
753
830
|
resolved_model: resolved.model,
|
|
754
831
|
provider_id: chosen.provider_id,
|
|
832
|
+
decisions,
|
|
755
833
|
};
|
|
756
834
|
}
|
|
757
835
|
|
|
@@ -339,8 +339,7 @@ export class ResponsesEventEncoder extends SurfaceStreamEncoder<CanonicalEvent,
|
|
|
339
339
|
summary: [],
|
|
340
340
|
content: [],
|
|
341
341
|
};
|
|
342
|
-
if (content.encrypted_content
|
|
343
|
-
this.open = {
|
|
342
|
+
if (typeof content.encrypted_content === "string") item.encrypted_content = content.encrypted_content; this.open = {
|
|
344
343
|
kind: "reasoning",
|
|
345
344
|
item,
|
|
346
345
|
itemId,
|
|
@@ -501,7 +500,7 @@ export class ResponsesEventEncoder extends SurfaceStreamEncoder<CanonicalEvent,
|
|
|
501
500
|
);
|
|
502
501
|
summaryChunks.push(delta);
|
|
503
502
|
}
|
|
504
|
-
if (content.encrypted_content
|
|
503
|
+
if (typeof content.encrypted_content === "string")
|
|
505
504
|
events.push(
|
|
506
505
|
this.emit("response.reasoning.encrypted_content", {
|
|
507
506
|
item_id: this.open?.itemId,
|