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
|
@@ -1,224 +1,224 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Empty-stream replay + pre-start failover.
|
|
3
|
-
*
|
|
4
|
-
* When a provider returns HTTP 200 but the response body decodes to zero
|
|
5
|
-
* meaningful events, the request is replayed byte-identical (same headers,
|
|
6
|
-
* same body) up to `maxEmptyReplays` times with exponential backoff. If all
|
|
7
|
-
* replays on one endpoint fail, the helper advances to the next endpoint in
|
|
8
|
-
* the caller-supplied ordered list.
|
|
9
|
-
*
|
|
10
|
-
* The helper is transport-layer: it owns fetch, watchdog, backoff, and
|
|
11
|
-
* endpoint rotation. The caller owns SSE decoding via the `DrainStream`
|
|
12
|
-
* callback and supplies materialized headers + serialized body so replays
|
|
13
|
-
* are byte-identical.
|
|
14
|
-
*
|
|
15
|
-
* Design: the replay count, backoff, and the ordered endpoint list are the
|
|
16
|
-
* caller's contract; this helper owns fetch, watchdog, and rotation so a
|
|
17
|
-
* provider that answers 200 with no events is retried on the next endpoint
|
|
18
|
-
* rather than surfacing an empty body to the client.
|
|
19
|
-
*/
|
|
20
|
-
import { GatewayError } from "../../protocol/gateway-error";
|
|
21
|
-
import {
|
|
22
|
-
fallbackRetryDelayMs,
|
|
23
|
-
MIN_RETRY_BUDGET_MS,
|
|
24
|
-
sleep as abortableSleep,
|
|
25
|
-
} from "../failure-policy";
|
|
26
|
-
|
|
27
|
-
// ─── Public types ──────────────────────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
/** What the caller's drain observed. Counts only dispatched data events:
|
|
30
|
-
* comments/keepalives, `[DONE]`, and whitespace-only data are excluded. */
|
|
31
|
-
export interface StreamTally {
|
|
32
|
-
readonly dataEvents: number;
|
|
33
|
-
readonly meaningful: boolean;
|
|
34
|
-
readonly thoughtOnly: boolean;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Drain one response body to a tally. Provider-supplied; uses decodeSseEvents. */
|
|
38
|
-
export type DrainStream = (response: Response, signal: AbortSignal) => Promise<StreamTally>;
|
|
39
|
-
|
|
40
|
-
/** Bound fetch for one endpoint. Receives materialized headers + serialized body. */
|
|
41
|
-
export type EmptyStreamFetch = (
|
|
42
|
-
url: string,
|
|
43
|
-
init: { headers: Record<string, string>; body: string; signal: AbortSignal },
|
|
44
|
-
) => Promise<Response>;
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Retry-delay policy.
|
|
48
|
-
*
|
|
49
|
-
* Delays come from `fallbackRetryDelayMs` (capped exponential with full
|
|
50
|
-
* jitter), whose base and cap are operator-tunable. There is deliberately no
|
|
51
|
-
* per-caller base: the local formula this replaced was uncapped and
|
|
52
|
-
* unjittered, so a provider returning 200-with-no-events could drive an
|
|
53
|
-
* unbounded wait, and concurrent clients could resynchronize their retries.
|
|
54
|
-
*/
|
|
55
|
-
export interface EmptyStreamPolicy {
|
|
56
|
-
readonly maxEmptyReplays?: number;
|
|
57
|
-
readonly firstEventTimeoutMs?: number;
|
|
58
|
-
readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface EmptyStreamResult {
|
|
62
|
-
readonly response: Response;
|
|
63
|
-
readonly tally: StreamTally;
|
|
64
|
-
readonly endpoint: string;
|
|
65
|
-
readonly replaysUsed: number;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// ─── Defaults ──────────────────────────────────────────────────────────────
|
|
69
|
-
|
|
70
|
-
const DEFAULT_MAX_REPLAYS = 2;
|
|
71
|
-
const DEFAULT_FIRST_EVENT_TIMEOUT_MS = 200_000; // 200s, matches CARTETHYIA_STREAM_FIRST_CHUNK_TIMEOUT_MS default
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
75
|
-
|
|
76
|
-
/** Pure predicate: zero data events, or data events with nothing meaningful. */
|
|
77
|
-
export function isEmptyStream(tally: StreamTally): boolean {
|
|
78
|
-
return tally.dataEvents === 0 || (!tally.meaningful && !tally.thoughtOnly);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// ─── Main function ─────────────────────────────────────────────────────────
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Replays the byte-identical request while responses drain empty,
|
|
87
|
-
* failing over across ordered endpoints pre-start.
|
|
88
|
-
*
|
|
89
|
-
* Throws GatewayError (platform_unavailable, 502, upstream) on exhaustion,
|
|
90
|
-
* GatewayError (deadline_exceeded, 504, upstream) when a first event never
|
|
91
|
-
* arrives within the window, and GatewayError (transport_closed, 499) on
|
|
92
|
-
* client abort.
|
|
93
|
-
*/
|
|
94
|
-
export async function fetchWithEmptyStreamRecovery(
|
|
95
|
-
endpoints: readonly string[],
|
|
96
|
-
request: { headers: Record<string, string>; body: string },
|
|
97
|
-
deps: {
|
|
98
|
-
fetch: EmptyStreamFetch;
|
|
99
|
-
drain: DrainStream;
|
|
100
|
-
signal: AbortSignal;
|
|
101
|
-
deadlineMs: number;
|
|
102
|
-
policy?: EmptyStreamPolicy;
|
|
103
|
-
},
|
|
104
|
-
): Promise<EmptyStreamResult> {
|
|
105
|
-
const maxReplays = deps.policy?.maxEmptyReplays ?? DEFAULT_MAX_REPLAYS;
|
|
106
|
-
const firstEventTimeout = deps.policy?.firstEventTimeoutMs ?? DEFAULT_FIRST_EVENT_TIMEOUT_MS;
|
|
107
|
-
const sleep = deps.policy?.sleep ?? abortableSleep;
|
|
108
|
-
|
|
109
|
-
if (endpoints.length === 0) {
|
|
110
|
-
throw new GatewayError("platform_unavailable", 502, "No endpoints available", {}, "upstream");
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex++) {
|
|
114
|
-
const endpoint = endpoints[endpointIndex]!;
|
|
115
|
-
const isLastEndpoint = endpointIndex === endpoints.length - 1;
|
|
116
|
-
const replaysAllowed = isLastEndpoint ? maxReplays : maxReplays;
|
|
117
|
-
|
|
118
|
-
for (let replay = 0; replay <= replaysAllowed; replay++) {
|
|
119
|
-
// Check abort before each fetch
|
|
120
|
-
if (deps.signal.aborted) {
|
|
121
|
-
throw new GatewayError("transport_closed", 499, "Aborted");
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Check deadline floor
|
|
125
|
-
const now = Date.now();
|
|
126
|
-
const remaining = deps.deadlineMs - now;
|
|
127
|
-
if (remaining < MIN_RETRY_BUDGET_MS) {
|
|
128
|
-
throw new GatewayError(
|
|
129
|
-
"platform_unavailable",
|
|
130
|
-
502,
|
|
131
|
-
"Deadline exhausted before empty-stream replay",
|
|
132
|
-
{},
|
|
133
|
-
"upstream",
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const fetchTimeout = Math.min(firstEventTimeout, remaining);
|
|
138
|
-
|
|
139
|
-
let response: Response;
|
|
140
|
-
try {
|
|
141
|
-
response = await fetchWithTimeout(deps.fetch, endpoint, request, deps.signal, fetchTimeout);
|
|
142
|
-
} catch (error) {
|
|
143
|
-
if (error instanceof GatewayError) throw error;
|
|
144
|
-
break; // advance to next endpoint
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Drain the response
|
|
148
|
-
let tally: StreamTally;
|
|
149
|
-
try {
|
|
150
|
-
tally = await deps.drain(response, deps.signal);
|
|
151
|
-
} catch (error) {
|
|
152
|
-
if (error instanceof GatewayError) throw error;
|
|
153
|
-
break; // advance to next endpoint
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Check if empty
|
|
157
|
-
if (!isEmptyStream(tally)) {
|
|
158
|
-
// Success — return the response and tally
|
|
159
|
-
return {
|
|
160
|
-
response,
|
|
161
|
-
tally,
|
|
162
|
-
endpoint,
|
|
163
|
-
replaysUsed: replay,
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Empty — replay if we haven't exhausted this endpoint's budget
|
|
168
|
-
if (replay < replaysAllowed) {
|
|
169
|
-
const delay = fallbackRetryDelayMs(replay);
|
|
170
|
-
try {
|
|
171
|
-
await sleep(delay, deps.signal);
|
|
172
|
-
} catch (error) {
|
|
173
|
-
if (error instanceof GatewayError) throw error;
|
|
174
|
-
throw error;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// All endpoints exhausted
|
|
181
|
-
throw new GatewayError(
|
|
182
|
-
"platform_unavailable",
|
|
183
|
-
502,
|
|
184
|
-
"All endpoints returned empty streams",
|
|
185
|
-
{},
|
|
186
|
-
"upstream",
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// ─── Internal helpers ──────────────────────────────────────────────────────
|
|
191
|
-
|
|
192
|
-
async function fetchWithTimeout(
|
|
193
|
-
fetch: EmptyStreamFetch,
|
|
194
|
-
url: string,
|
|
195
|
-
request: { headers: Record<string, string>; body: string },
|
|
196
|
-
signal: AbortSignal,
|
|
197
|
-
timeoutMs: number,
|
|
198
|
-
): Promise<Response> {
|
|
199
|
-
// Race the fetch against a first-event timeout
|
|
200
|
-
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
201
|
-
const timer = setTimeout(() => {
|
|
202
|
-
// A first-event timeout is the gateway waiting on a silent upstream (e.g.
|
|
203
|
-
// a provider that holds the request in a queue), not the client leaving.
|
|
204
|
-
// It must surface as a deadline failure, never as a `transport_closed`
|
|
205
|
-
// 499 — that code is reserved for genuine client cancellations and would
|
|
206
|
-
// otherwise hide a real upstream wait behind "cancelled by client".
|
|
207
|
-
reject(new GatewayError("deadline_exceeded", 504, "First event timeout", {}, "upstream"));
|
|
208
|
-
}, timeoutMs);
|
|
209
|
-
signal.addEventListener("abort", () => {
|
|
210
|
-
clearTimeout(timer);
|
|
211
|
-
reject(new GatewayError("transport_closed", 499, "Aborted"));
|
|
212
|
-
}, { once: true });
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
try {
|
|
216
|
-
return await Promise.race([
|
|
217
|
-
fetch(url, { headers: request.headers, body: request.body, signal }),
|
|
218
|
-
timeoutPromise,
|
|
219
|
-
]);
|
|
220
|
-
} catch (error) {
|
|
221
|
-
if (error instanceof GatewayError) throw error;
|
|
222
|
-
throw error;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Empty-stream replay + pre-start failover.
|
|
3
|
+
*
|
|
4
|
+
* When a provider returns HTTP 200 but the response body decodes to zero
|
|
5
|
+
* meaningful events, the request is replayed byte-identical (same headers,
|
|
6
|
+
* same body) up to `maxEmptyReplays` times with exponential backoff. If all
|
|
7
|
+
* replays on one endpoint fail, the helper advances to the next endpoint in
|
|
8
|
+
* the caller-supplied ordered list.
|
|
9
|
+
*
|
|
10
|
+
* The helper is transport-layer: it owns fetch, watchdog, backoff, and
|
|
11
|
+
* endpoint rotation. The caller owns SSE decoding via the `DrainStream`
|
|
12
|
+
* callback and supplies materialized headers + serialized body so replays
|
|
13
|
+
* are byte-identical.
|
|
14
|
+
*
|
|
15
|
+
* Design: the replay count, backoff, and the ordered endpoint list are the
|
|
16
|
+
* caller's contract; this helper owns fetch, watchdog, and rotation so a
|
|
17
|
+
* provider that answers 200 with no events is retried on the next endpoint
|
|
18
|
+
* rather than surfacing an empty body to the client.
|
|
19
|
+
*/
|
|
20
|
+
import { GatewayError } from "../../protocol/gateway-error";
|
|
21
|
+
import {
|
|
22
|
+
fallbackRetryDelayMs,
|
|
23
|
+
MIN_RETRY_BUDGET_MS,
|
|
24
|
+
sleep as abortableSleep,
|
|
25
|
+
} from "../failure-policy";
|
|
26
|
+
|
|
27
|
+
// ─── Public types ──────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** What the caller's drain observed. Counts only dispatched data events:
|
|
30
|
+
* comments/keepalives, `[DONE]`, and whitespace-only data are excluded. */
|
|
31
|
+
export interface StreamTally {
|
|
32
|
+
readonly dataEvents: number;
|
|
33
|
+
readonly meaningful: boolean;
|
|
34
|
+
readonly thoughtOnly: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Drain one response body to a tally. Provider-supplied; uses decodeSseEvents. */
|
|
38
|
+
export type DrainStream = (response: Response, signal: AbortSignal) => Promise<StreamTally>;
|
|
39
|
+
|
|
40
|
+
/** Bound fetch for one endpoint. Receives materialized headers + serialized body. */
|
|
41
|
+
export type EmptyStreamFetch = (
|
|
42
|
+
url: string,
|
|
43
|
+
init: { headers: Record<string, string>; body: string; signal: AbortSignal },
|
|
44
|
+
) => Promise<Response>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Retry-delay policy.
|
|
48
|
+
*
|
|
49
|
+
* Delays come from `fallbackRetryDelayMs` (capped exponential with full
|
|
50
|
+
* jitter), whose base and cap are operator-tunable. There is deliberately no
|
|
51
|
+
* per-caller base: the local formula this replaced was uncapped and
|
|
52
|
+
* unjittered, so a provider returning 200-with-no-events could drive an
|
|
53
|
+
* unbounded wait, and concurrent clients could resynchronize their retries.
|
|
54
|
+
*/
|
|
55
|
+
export interface EmptyStreamPolicy {
|
|
56
|
+
readonly maxEmptyReplays?: number;
|
|
57
|
+
readonly firstEventTimeoutMs?: number;
|
|
58
|
+
readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface EmptyStreamResult {
|
|
62
|
+
readonly response: Response;
|
|
63
|
+
readonly tally: StreamTally;
|
|
64
|
+
readonly endpoint: string;
|
|
65
|
+
readonly replaysUsed: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Defaults ──────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
const DEFAULT_MAX_REPLAYS = 2;
|
|
71
|
+
const DEFAULT_FIRST_EVENT_TIMEOUT_MS = 200_000; // 200s, matches CARTETHYIA_STREAM_FIRST_CHUNK_TIMEOUT_MS default
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/** Pure predicate: zero data events, or data events with nothing meaningful. */
|
|
77
|
+
export function isEmptyStream(tally: StreamTally): boolean {
|
|
78
|
+
return tally.dataEvents === 0 || (!tally.meaningful && !tally.thoughtOnly);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
// ─── Main function ─────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Replays the byte-identical request while responses drain empty,
|
|
87
|
+
* failing over across ordered endpoints pre-start.
|
|
88
|
+
*
|
|
89
|
+
* Throws GatewayError (platform_unavailable, 502, upstream) on exhaustion,
|
|
90
|
+
* GatewayError (deadline_exceeded, 504, upstream) when a first event never
|
|
91
|
+
* arrives within the window, and GatewayError (transport_closed, 499) on
|
|
92
|
+
* client abort.
|
|
93
|
+
*/
|
|
94
|
+
export async function fetchWithEmptyStreamRecovery(
|
|
95
|
+
endpoints: readonly string[],
|
|
96
|
+
request: { headers: Record<string, string>; body: string },
|
|
97
|
+
deps: {
|
|
98
|
+
fetch: EmptyStreamFetch;
|
|
99
|
+
drain: DrainStream;
|
|
100
|
+
signal: AbortSignal;
|
|
101
|
+
deadlineMs: number;
|
|
102
|
+
policy?: EmptyStreamPolicy;
|
|
103
|
+
},
|
|
104
|
+
): Promise<EmptyStreamResult> {
|
|
105
|
+
const maxReplays = deps.policy?.maxEmptyReplays ?? DEFAULT_MAX_REPLAYS;
|
|
106
|
+
const firstEventTimeout = deps.policy?.firstEventTimeoutMs ?? DEFAULT_FIRST_EVENT_TIMEOUT_MS;
|
|
107
|
+
const sleep = deps.policy?.sleep ?? abortableSleep;
|
|
108
|
+
|
|
109
|
+
if (endpoints.length === 0) {
|
|
110
|
+
throw new GatewayError("platform_unavailable", 502, "No endpoints available", {}, "upstream");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex++) {
|
|
114
|
+
const endpoint = endpoints[endpointIndex]!;
|
|
115
|
+
const isLastEndpoint = endpointIndex === endpoints.length - 1;
|
|
116
|
+
const replaysAllowed = isLastEndpoint ? maxReplays : maxReplays;
|
|
117
|
+
|
|
118
|
+
for (let replay = 0; replay <= replaysAllowed; replay++) {
|
|
119
|
+
// Check abort before each fetch
|
|
120
|
+
if (deps.signal.aborted) {
|
|
121
|
+
throw new GatewayError("transport_closed", 499, "Aborted");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check deadline floor
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
const remaining = deps.deadlineMs - now;
|
|
127
|
+
if (remaining < MIN_RETRY_BUDGET_MS) {
|
|
128
|
+
throw new GatewayError(
|
|
129
|
+
"platform_unavailable",
|
|
130
|
+
502,
|
|
131
|
+
"Deadline exhausted before empty-stream replay",
|
|
132
|
+
{},
|
|
133
|
+
"upstream",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const fetchTimeout = Math.min(firstEventTimeout, remaining);
|
|
138
|
+
|
|
139
|
+
let response: Response;
|
|
140
|
+
try {
|
|
141
|
+
response = await fetchWithTimeout(deps.fetch, endpoint, request, deps.signal, fetchTimeout);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error instanceof GatewayError) throw error;
|
|
144
|
+
break; // advance to next endpoint
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Drain the response
|
|
148
|
+
let tally: StreamTally;
|
|
149
|
+
try {
|
|
150
|
+
tally = await deps.drain(response, deps.signal);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error instanceof GatewayError) throw error;
|
|
153
|
+
break; // advance to next endpoint
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check if empty
|
|
157
|
+
if (!isEmptyStream(tally)) {
|
|
158
|
+
// Success — return the response and tally
|
|
159
|
+
return {
|
|
160
|
+
response,
|
|
161
|
+
tally,
|
|
162
|
+
endpoint,
|
|
163
|
+
replaysUsed: replay,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Empty — replay if we haven't exhausted this endpoint's budget
|
|
168
|
+
if (replay < replaysAllowed) {
|
|
169
|
+
const delay = fallbackRetryDelayMs(replay);
|
|
170
|
+
try {
|
|
171
|
+
await sleep(delay, deps.signal);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error instanceof GatewayError) throw error;
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// All endpoints exhausted
|
|
181
|
+
throw new GatewayError(
|
|
182
|
+
"platform_unavailable",
|
|
183
|
+
502,
|
|
184
|
+
"All endpoints returned empty streams",
|
|
185
|
+
{},
|
|
186
|
+
"upstream",
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── Internal helpers ──────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
async function fetchWithTimeout(
|
|
193
|
+
fetch: EmptyStreamFetch,
|
|
194
|
+
url: string,
|
|
195
|
+
request: { headers: Record<string, string>; body: string },
|
|
196
|
+
signal: AbortSignal,
|
|
197
|
+
timeoutMs: number,
|
|
198
|
+
): Promise<Response> {
|
|
199
|
+
// Race the fetch against a first-event timeout
|
|
200
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
201
|
+
const timer = setTimeout(() => {
|
|
202
|
+
// A first-event timeout is the gateway waiting on a silent upstream (e.g.
|
|
203
|
+
// a provider that holds the request in a queue), not the client leaving.
|
|
204
|
+
// It must surface as a deadline failure, never as a `transport_closed`
|
|
205
|
+
// 499 — that code is reserved for genuine client cancellations and would
|
|
206
|
+
// otherwise hide a real upstream wait behind "cancelled by client".
|
|
207
|
+
reject(new GatewayError("deadline_exceeded", 504, "First event timeout", {}, "upstream"));
|
|
208
|
+
}, timeoutMs);
|
|
209
|
+
signal.addEventListener("abort", () => {
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
reject(new GatewayError("transport_closed", 499, "Aborted"));
|
|
212
|
+
}, { once: true });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
return await Promise.race([
|
|
217
|
+
fetch(url, { headers: request.headers, body: request.body, signal }),
|
|
218
|
+
timeoutPromise,
|
|
219
|
+
]);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error instanceof GatewayError) throw error;
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { isBundledProviderId } from "../../providers/provider-contracts";
|
|
2
2
|
import type { ProviderDispatchContext, ProviderId, ProviderAdapter } from "../../providers/provider-contracts";
|
|
3
3
|
import { GatewayError } from "../../protocol/gateway-error";
|
|
4
|
+
import { isWrappedInjectionProvider } from "../../console/system-prompt/contracts";
|
|
5
|
+
import { resolveTenantSystemPrompt } from "../request/system-prompt-injection";
|
|
6
|
+
import { readTenantSystemPromptsForDispatch } from "../request/tenant-preferences";
|
|
4
7
|
import type { CanonicalEvent } from "../../protocol/canonical-model";
|
|
5
8
|
import { resolveCredentialForAccount } from "../../providers/operations/provider-credential-service";
|
|
6
9
|
import type { ByokUpstreamHost } from "../../providers/operations/provider-catalog-service";
|
|
@@ -115,6 +118,26 @@ export async function handleProviderProxyRequest(
|
|
|
115
118
|
if (!prepared)
|
|
116
119
|
throw new GatewayError("admission_unavailable", 503, "The request was not prepared, so no route can be dialed. Resend the request.");
|
|
117
120
|
const canonicalRequest = await applyTenantPreferences(prepared, deps.db);
|
|
121
|
+
// Tenant system-prompt injection, resolved once per request. For providers
|
|
122
|
+
// whose upstream validates the fixed system prompt as a channel identity
|
|
123
|
+
// (the buddy family), the text cannot ride the canonical `system` array —
|
|
124
|
+
// the adapter replaces it — so it travels through dispatch context and the
|
|
125
|
+
// adapter folds it into its fixed turn, wrapped in the gateway's markers.
|
|
126
|
+
// `override` degrades to wrapped append there: stripping the identity would
|
|
127
|
+
// have the upstream refuse the request outright.
|
|
128
|
+
const candidateForPrompt = prepared.candidate;
|
|
129
|
+
const tenantSystemPromptText = isWrappedInjectionProvider(candidateForPrompt.provider_id)
|
|
130
|
+
? resolveTenantSystemPrompt(
|
|
131
|
+
await readTenantSystemPromptsForDispatch(
|
|
132
|
+
deps.db,
|
|
133
|
+
prepared.authorization.snapshot.tenant_id,
|
|
134
|
+
),
|
|
135
|
+
{
|
|
136
|
+
providerId: candidateForPrompt.provider_id,
|
|
137
|
+
modelId: candidateForPrompt.model_id,
|
|
138
|
+
},
|
|
139
|
+
)?.text
|
|
140
|
+
: undefined;
|
|
118
141
|
// Per-tenant timeout, applied as soon as the tenant is known. The deadline
|
|
119
142
|
// was armed at admission with the deployment default; this re-arms it.
|
|
120
143
|
await applyTenantDeadlineOverride(
|
|
@@ -153,8 +176,13 @@ export async function handleProviderProxyRequest(
|
|
|
153
176
|
estimatedOutputTokens: prepared.estimatedOutputTokens,
|
|
154
177
|
authorizationSnapshot: prepared.authorization.snapshot,
|
|
155
178
|
// A continuation borrows the held Cursor Run: one upstream turn must not
|
|
156
|
-
// occupy two account/pool concurrency slots.
|
|
157
|
-
|
|
179
|
+
// occupy two account/pool concurrency slots. A rebaseable continuation
|
|
180
|
+
// (complete history, pinned account no longer routable) does NOT borrow —
|
|
181
|
+
// it starts a fresh Run on a newly selected account, so it leases like a
|
|
182
|
+
// normal request.
|
|
183
|
+
...(prepared.continuationAffinity === undefined || prepared.continuationAffinity.rebaseable
|
|
184
|
+
? {}
|
|
185
|
+
: { borrowsHeldSession: true }),
|
|
158
186
|
},
|
|
159
187
|
candidates: attemptCandidates,
|
|
160
188
|
tenantId: prepared.authorization.tenantId,
|
|
@@ -231,6 +259,7 @@ export async function handleProviderProxyRequest(
|
|
|
231
259
|
? {}
|
|
232
260
|
: { imageClampMaxBytes: state.imageClampMaxBytes }),
|
|
233
261
|
...(conversationAffinity ? { conversationAffinity } : {}),
|
|
262
|
+
...(tenantSystemPromptText === undefined ? {} : { tenantSystemPrompt: tenantSystemPromptText }),
|
|
234
263
|
...(candidate.user_agent === undefined ? {} : { userAgent: candidate.user_agent }),
|
|
235
264
|
...(deps.networkBindingFactory
|
|
236
265
|
? {
|
|
@@ -50,7 +50,9 @@ import {
|
|
|
50
50
|
resolveCaptureModeForRequest,
|
|
51
51
|
shouldCaptureProviderBodyForRequest,
|
|
52
52
|
} from "../capture-policy";
|
|
53
|
-
import { resolveTenantImageClampMaxBytes } from "../../request/tenant-preferences";
|
|
53
|
+
import { resolveTenantImageClampMaxBytes, readTenantSystemPromptsForDispatch } from "../../request/tenant-preferences";
|
|
54
|
+
import { resolveTenantSystemPrompt } from "../../request/system-prompt-injection";
|
|
55
|
+
import { isWrappedInjectionProvider } from "../../../console/system-prompt/contracts";
|
|
54
56
|
import { proxySuccessHeaders, buildUpstreamDispatchContext } from "../upstream";
|
|
55
57
|
import type { AttemptLeaseOwnership } from "../leases";
|
|
56
58
|
import type { ProviderContinuationScope } from "../../../providers/provider-contracts";
|
|
@@ -217,6 +219,19 @@ export async function dispatchStreamingAttempt(input: StreamingDispatchInput): P
|
|
|
217
219
|
? {}
|
|
218
220
|
: { imageClampMaxBytes: state.imageClampMaxBytes }),
|
|
219
221
|
...(conversationAffinity ? { conversationAffinity } : {}),
|
|
222
|
+
// Same wrapped-injection text the non-streaming path carries: providers
|
|
223
|
+
// whose upstream validates the fixed system prompt as a channel identity
|
|
224
|
+
// (the buddy family) receive the tenant prompt through dispatch context
|
|
225
|
+
// so the adapter can fold it into its fixed turn. `override` degrades to
|
|
226
|
+
// wrapped append there; stripping the identity would be refused outright.
|
|
227
|
+
...(await (async () => {
|
|
228
|
+
if (!isWrappedInjectionProvider(candidate.provider_id)) return {};
|
|
229
|
+
const text = resolveTenantSystemPrompt(
|
|
230
|
+
await readTenantSystemPromptsForDispatch(deps.db, prepared.authorization.snapshot.tenant_id),
|
|
231
|
+
{ providerId: candidate.provider_id, modelId: candidate.model_id },
|
|
232
|
+
)?.text;
|
|
233
|
+
return text === undefined ? {} : { tenantSystemPrompt: text };
|
|
234
|
+
})()),
|
|
220
235
|
...(candidate.user_agent === undefined ? {} : { userAgent: candidate.user_agent }),
|
|
221
236
|
...(wrappedOutboundFetch ? { outboundFetch: wrappedOutboundFetch } : {}),
|
|
222
237
|
...(outboundFetch ? { outboundAttachmentFetch: outboundFetch } : {}),
|
|
@@ -61,6 +61,13 @@ export function buildUpstreamDispatchContext(input: {
|
|
|
61
61
|
* database handle.
|
|
62
62
|
*/
|
|
63
63
|
readonly imageClampMaxBytes?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Tenant system-prompt text resolved once per request (append/override
|
|
66
|
+
* injection). Carried through dispatch because the buddy adapters fold it
|
|
67
|
+
* into their fixed system turn after serialization — the canonical level
|
|
68
|
+
* cannot reach that turn.
|
|
69
|
+
*/
|
|
70
|
+
readonly tenantSystemPrompt?: string;
|
|
64
71
|
/** Opaque identity shared by retries of one logical gateway request. */
|
|
65
72
|
readonly requestIdentity?: object;
|
|
66
73
|
}): Record<string, unknown> {
|
|
@@ -101,6 +108,9 @@ export function buildUpstreamDispatchContext(input: {
|
|
|
101
108
|
...(input.imageClampMaxBytes === undefined
|
|
102
109
|
? {}
|
|
103
110
|
: { image_clamp_max_bytes: input.imageClampMaxBytes }),
|
|
111
|
+
...(input.tenantSystemPrompt === undefined
|
|
112
|
+
? {}
|
|
113
|
+
: { tenant_system_prompt: input.tenantSystemPrompt }),
|
|
104
114
|
...(input.requestIdentity ? { request_identity: input.requestIdentity } : {}),
|
|
105
115
|
} as Record<string, unknown>;
|
|
106
116
|
}
|
|
@@ -2,6 +2,7 @@ import { GatewayError, type GatewayErrorCode, type GatewayErrorOrigin } from "..
|
|
|
2
2
|
import { isRecord } from "../protocol/codecs";
|
|
3
3
|
import { resolveFallbackRetryBaseMs, resolveFallbackRetryCapMs } from "../config/timeouts";
|
|
4
4
|
import { parseUpstreamBackoff } from "../protocol/upstream-parsing";
|
|
5
|
+
import { resolveAccountQuotaCooldownMs } from "../config/cooldowns";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* How much of the upstream's error body is kept and surfaced.
|
|
@@ -232,6 +233,31 @@ export function classifyUpstreamFailure(error: unknown): UpstreamFailurePolicy {
|
|
|
232
233
|
// DNS failure. Failing the client request instead of failing over is the wrong
|
|
233
234
|
// outcome for a whole class the table already names as platform-wide.
|
|
234
235
|
const statusCode = error.status;
|
|
236
|
+
// Qoder answers an exhausted quota by closing the stream with an upstream 499
|
|
237
|
+
// (nginx client-close style). That is a refusal from the upstream — the
|
|
238
|
+
// gateway's own aborts are `transport_closed`, produced locally, and never
|
|
239
|
+
// carry a qoder providerId — so a 499 scoped to qoder is quota, not a
|
|
240
|
+
// malformed request. Without this it fell through to `invalid_request`
|
|
241
|
+
// (non-retryable, no cooldown) and the account was re-dialled on every
|
|
242
|
+
// request inside the exhausted window until a late 429 finally classified it.
|
|
243
|
+
if (
|
|
244
|
+
error.origin === "upstream" &&
|
|
245
|
+
statusCode === 499 &&
|
|
246
|
+
(providerId === "qoder" || providerId === "qodercn")
|
|
247
|
+
) {
|
|
248
|
+
const cooldownMs = resolveAccountQuotaCooldownMs();
|
|
249
|
+
return {
|
|
250
|
+
retryable: true,
|
|
251
|
+
mutatesAccount: true,
|
|
252
|
+
category: "quota_exceeded",
|
|
253
|
+
scope: "account",
|
|
254
|
+
origin: error.origin,
|
|
255
|
+
...(providerCode ? { providerCode } : {}),
|
|
256
|
+
...(providerId ? { providerId } : {}),
|
|
257
|
+
cooldownMs,
|
|
258
|
+
statusCode,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
235
261
|
const retryableByStatus =
|
|
236
262
|
statusCode === 401 ||
|
|
237
263
|
statusCode === 403 ||
|
|
@@ -459,6 +485,13 @@ const CONTEXT_LENGTH_CODES: ReadonlySet<string> = new Set([
|
|
|
459
485
|
"model_context_window_exceeded",
|
|
460
486
|
// InferHub's spelling for the same condition.
|
|
461
487
|
"context_too_long",
|
|
488
|
+
// Trae answers an overflow on its streaming surface with the bare numeric
|
|
489
|
+
// code `4026` and the prose "We're sorry, your context length has exceeded
|
|
490
|
+
// the maximum limit." — no HTTP status, no symbolic code (verified live
|
|
491
|
+
// 2026-10: `event:error data:{"code":4026,"message":"..."}`). Without it
|
|
492
|
+
// the frame fell to the 502 `platform_unavailable` fallback and the client
|
|
493
|
+
// never saw the 413 it compacts on.
|
|
494
|
+
"4026",
|
|
462
495
|
]);
|
|
463
496
|
|
|
464
497
|
/**
|