@srouterhq/server 0.0.0-stage → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/client/dist/assets/am-TQ7Jmdqe.js +1 -0
- package/client/dist/assets/ar-Bf5a7yfC.js +1 -0
- package/client/dist/assets/az-CrosE1xH.js +1 -0
- package/client/dist/assets/bg-BvWnmcUz.js +1 -0
- package/client/dist/assets/bn-ams5TsJp.js +1 -0
- package/client/dist/assets/cs-Dh67CaJ_.js +1 -0
- package/client/dist/assets/da-9WlT8Iq7.js +1 -0
- package/client/dist/assets/de-lWhJJzz7.js +1 -0
- package/client/dist/assets/el-Cu7_GhNq.js +1 -0
- package/client/dist/assets/es-BZuzsBcP.js +1 -0
- package/client/dist/assets/fa-Dwdn-jKS.js +1 -0
- package/client/dist/assets/fi-BFrFyOTy.js +1 -0
- package/client/dist/assets/fr-XhbtmPpj.js +1 -0
- package/client/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/client/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/client/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/client/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/client/dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
- package/client/dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
- package/client/dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
- package/client/dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
- package/client/dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
- package/client/dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
- package/client/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/client/dist/assets/gu-D0uhLUB8.js +1 -0
- package/client/dist/assets/ha-CVlm9pqp.js +1 -0
- package/client/dist/assets/he-CMIFeoWr.js +1 -0
- package/client/dist/assets/hi-ENTwhBpn.js +1 -0
- package/client/dist/assets/hr-fc03vMy_.js +1 -0
- package/client/dist/assets/hu-YBo2BIYt.js +1 -0
- package/client/dist/assets/id-BEWXYX69.js +1 -0
- package/client/dist/assets/ig-BKLZFKka.js +1 -0
- package/client/dist/assets/index-D9I4KtvR.js +145 -0
- package/client/dist/assets/index-txhEmCdb.css +2 -0
- package/client/dist/assets/it-Bzmei1Y2.js +1 -0
- package/client/dist/assets/ja-BfcpuqEl.js +1 -0
- package/client/dist/assets/ka-CnWjRgLo.js +1 -0
- package/client/dist/assets/km-BAjPR3Qj.js +1 -0
- package/client/dist/assets/kn-B15WKU5m.js +1 -0
- package/client/dist/assets/ko-C61fAB-w.js +1 -0
- package/client/dist/assets/lt-CYTr4_YW.js +1 -0
- package/client/dist/assets/ml-CtqxgyH3.js +1 -0
- package/client/dist/assets/mr-X5TMY7aR.js +1 -0
- package/client/dist/assets/ms-DSg8RqPS.js +1 -0
- package/client/dist/assets/my-D7RqR8uE.js +1 -0
- package/client/dist/assets/ne-DaflxkDj.js +1 -0
- package/client/dist/assets/nl-BOqqtsC8.js +1 -0
- package/client/dist/assets/no-BSHp_StB.js +1 -0
- package/client/dist/assets/or-BkLR9Vub.js +1 -0
- package/client/dist/assets/pa-CarJ2XgI.js +1 -0
- package/client/dist/assets/pl-C2n0V-0k.js +1 -0
- package/client/dist/assets/pt-BR-CqJdNhg2.js +1 -0
- package/client/dist/assets/pt-PT-C_HpMyKz.js +1 -0
- package/client/dist/assets/ro-DVRZqSAS.js +1 -0
- package/client/dist/assets/ru-DwAJG7VC.js +1 -0
- package/client/dist/assets/si-BxUezXm0.js +1 -0
- package/client/dist/assets/sk-eI1VcIfY.js +1 -0
- package/client/dist/assets/sr-CJF3auCr.js +1 -0
- package/client/dist/assets/srouter-logo-C6ZfjGIi.svg +14 -0
- package/client/dist/assets/sv-nqvwHAq_.js +1 -0
- package/client/dist/assets/sw-DWynE2pW.js +1 -0
- package/client/dist/assets/ta-BVM12knC.js +1 -0
- package/client/dist/assets/te-oyzEkF7_.js +1 -0
- package/client/dist/assets/th-COVeE0ZQ.js +1 -0
- package/client/dist/assets/tl-DsJANnQz.js +1 -0
- package/client/dist/assets/tr-B-Fl-urO.js +1 -0
- package/client/dist/assets/uk-C5qketAO.js +1 -0
- package/client/dist/assets/ur-QU9KugfV.js +1 -0
- package/client/dist/assets/uz-DL9cG_mY.js +1 -0
- package/client/dist/assets/vi-CQiVy3Pu.js +1 -0
- package/client/dist/assets/yo-DqazMdrZ.js +1 -0
- package/client/dist/assets/zh-CN-DnYL_Kio.js +1 -0
- package/client/dist/assets/zh-TW-5bdbosKj.js +1 -0
- package/client/dist/favicon.svg +14 -0
- package/client/dist/icons.svg +24 -0
- package/client/dist/index.html +37 -0
- package/dist/app.d.ts +4 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +372 -0
- package/dist/app.js.map +1 -0
- package/dist/db/index.d.ts +49 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +203 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/migrate/TEMPLATE.d.ts +4 -0
- package/dist/db/migrate/TEMPLATE.d.ts.map +1 -0
- package/dist/db/migrate/TEMPLATE.js +18 -0
- package/dist/db/migrate/TEMPLATE.js.map +1 -0
- package/dist/db/migrate/cli.d.ts +2 -0
- package/dist/db/migrate/cli.d.ts.map +1 -0
- package/dist/db/migrate/cli.js +177 -0
- package/dist/db/migrate/cli.js.map +1 -0
- package/dist/db/migrate/defaults.d.ts +52 -0
- package/dist/db/migrate/defaults.d.ts.map +1 -0
- package/dist/db/migrate/defaults.js +126 -0
- package/dist/db/migrate/defaults.js.map +1 -0
- package/dist/db/migrate/runner.d.ts +16 -0
- package/dist/db/migrate/runner.d.ts.map +1 -0
- package/dist/db/migrate/runner.js +178 -0
- package/dist/db/migrate/runner.js.map +1 -0
- package/dist/db/migrations/20260101_000000_legacy_baseline.d.ts +4 -0
- package/dist/db/migrations/20260101_000000_legacy_baseline.d.ts.map +1 -0
- package/dist/db/migrations/20260101_000000_legacy_baseline.js +2240 -0
- package/dist/db/migrations/20260101_000000_legacy_baseline.js.map +1 -0
- package/dist/db/migrations/20260627_000001_custom_provider_modalities.d.ts +4 -0
- package/dist/db/migrations/20260627_000001_custom_provider_modalities.d.ts.map +1 -0
- package/dist/db/migrations/20260627_000001_custom_provider_modalities.js +27 -0
- package/dist/db/migrations/20260627_000001_custom_provider_modalities.js.map +1 -0
- package/dist/db/migrations/20260627_000002_catalog_model_state.d.ts +4 -0
- package/dist/db/migrations/20260627_000002_catalog_model_state.d.ts.map +1 -0
- package/dist/db/migrations/20260627_000002_catalog_model_state.js +30 -0
- package/dist/db/migrations/20260627_000002_catalog_model_state.js.map +1 -0
- package/dist/db/migrations/20260628_120000_request_aggregates.d.ts +6 -0
- package/dist/db/migrations/20260628_120000_request_aggregates.d.ts.map +1 -0
- package/dist/db/migrations/20260628_120000_request_aggregates.js +104 -0
- package/dist/db/migrations/20260628_120000_request_aggregates.js.map +1 -0
- package/dist/db/migrations/20260630_000001_github_gpt41_context.d.ts +9 -0
- package/dist/db/migrations/20260630_000001_github_gpt41_context.d.ts.map +1 -0
- package/dist/db/migrations/20260630_000001_github_gpt41_context.js +22 -0
- package/dist/db/migrations/20260630_000001_github_gpt41_context.js.map +1 -0
- package/dist/db/migrations/20260706_000001_request_client_info.d.ts +4 -0
- package/dist/db/migrations/20260706_000001_request_client_info.d.ts.map +1 -0
- package/dist/db/migrations/20260706_000001_request_client_info.js +30 -0
- package/dist/db/migrations/20260706_000001_request_client_info.js.map +1 -0
- package/dist/db/migrations/20260706_000002_custom_model_tool_support.d.ts +17 -0
- package/dist/db/migrations/20260706_000002_custom_model_tool_support.d.ts.map +1 -0
- package/dist/db/migrations/20260706_000002_custom_model_tool_support.js +20 -0
- package/dist/db/migrations/20260706_000002_custom_model_tool_support.js.map +1 -0
- package/dist/db/migrations/20260714_000001_profile_chain_backfill.d.ts +12 -0
- package/dist/db/migrations/20260714_000001_profile_chain_backfill.d.ts.map +1 -0
- package/dist/db/migrations/20260714_000001_profile_chain_backfill.js +45 -0
- package/dist/db/migrations/20260714_000001_profile_chain_backfill.js.map +1 -0
- package/dist/db/migrations/20260720_000001_key_health_error.d.ts +5 -0
- package/dist/db/migrations/20260720_000001_key_health_error.d.ts.map +1 -0
- package/dist/db/migrations/20260720_000001_key_health_error.js +16 -0
- package/dist/db/migrations/20260720_000001_key_health_error.js.map +1 -0
- package/dist/db/migrations/20260726_000001_cooldown_probe_provenance.d.ts +4 -0
- package/dist/db/migrations/20260726_000001_cooldown_probe_provenance.d.ts.map +1 -0
- package/dist/db/migrations/20260726_000001_cooldown_probe_provenance.js +38 -0
- package/dist/db/migrations/20260726_000001_cooldown_probe_provenance.js.map +1 -0
- package/dist/db/migrations/20260726_000002_request_attempts.d.ts +4 -0
- package/dist/db/migrations/20260726_000002_request_attempts.d.ts.map +1 -0
- package/dist/db/migrations/20260726_000002_request_attempts.js +43 -0
- package/dist/db/migrations/20260726_000002_request_attempts.js.map +1 -0
- package/dist/db/migrations/20260726_000003_model_source_provenance.d.ts +4 -0
- package/dist/db/migrations/20260726_000003_model_source_provenance.d.ts.map +1 -0
- package/dist/db/migrations/20260726_000003_model_source_provenance.js +80 -0
- package/dist/db/migrations/20260726_000003_model_source_provenance.js.map +1 -0
- package/dist/db/migrations/20260726_000004_media_model_meta.d.ts +4 -0
- package/dist/db/migrations/20260726_000004_media_model_meta.d.ts.map +1 -0
- package/dist/db/migrations/20260726_000004_media_model_meta.js +34 -0
- package/dist/db/migrations/20260726_000004_media_model_meta.js.map +1 -0
- package/dist/db/migrations/20260726_000005_request_served_model.d.ts +4 -0
- package/dist/db/migrations/20260726_000005_request_served_model.d.ts.map +1 -0
- package/dist/db/migrations/20260726_000005_request_served_model.js +31 -0
- package/dist/db/migrations/20260726_000005_request_served_model.js.map +1 -0
- package/dist/db/migrations/20260726_000006_attempt_error_summary.d.ts +4 -0
- package/dist/db/migrations/20260726_000006_attempt_error_summary.d.ts.map +1 -0
- package/dist/db/migrations/20260726_000006_attempt_error_summary.js +29 -0
- package/dist/db/migrations/20260726_000006_attempt_error_summary.js.map +1 -0
- package/dist/db/migrations/20260727_000001_agent_compatibility.d.ts +4 -0
- package/dist/db/migrations/20260727_000001_agent_compatibility.d.ts.map +1 -0
- package/dist/db/migrations/20260727_000001_agent_compatibility.js +41 -0
- package/dist/db/migrations/20260727_000001_agent_compatibility.js.map +1 -0
- package/dist/db/migrations/20260728_000001_tombstone_provenance.d.ts +12 -0
- package/dist/db/migrations/20260728_000001_tombstone_provenance.d.ts.map +1 -0
- package/dist/db/migrations/20260728_000001_tombstone_provenance.js +37 -0
- package/dist/db/migrations/20260728_000001_tombstone_provenance.js.map +1 -0
- package/dist/db/migrations/20260729_000001_custom_model_endpoint_identity.d.ts +4 -0
- package/dist/db/migrations/20260729_000001_custom_model_endpoint_identity.d.ts.map +1 -0
- package/dist/db/migrations/20260729_000001_custom_model_endpoint_identity.js +145 -0
- package/dist/db/migrations/20260729_000001_custom_model_endpoint_identity.js.map +1 -0
- package/dist/db/migrations/20260802_000001_custom_endpoint_host_labels.d.ts +6 -0
- package/dist/db/migrations/20260802_000001_custom_endpoint_host_labels.d.ts.map +1 -0
- package/dist/db/migrations/20260802_000001_custom_endpoint_host_labels.js +52 -0
- package/dist/db/migrations/20260802_000001_custom_endpoint_host_labels.js.map +1 -0
- package/dist/db/migrations/20260805_000001_key_model_scope.d.ts +6 -0
- package/dist/db/migrations/20260805_000001_key_model_scope.d.ts.map +1 -0
- package/dist/db/migrations/20260805_000001_key_model_scope.js +17 -0
- package/dist/db/migrations/20260805_000001_key_model_scope.js.map +1 -0
- package/dist/db/migrations/20260805_000002_client_profiles.d.ts +4 -0
- package/dist/db/migrations/20260805_000002_client_profiles.d.ts.map +1 -0
- package/dist/db/migrations/20260805_000002_client_profiles.js +33 -0
- package/dist/db/migrations/20260805_000002_client_profiles.js.map +1 -0
- package/dist/db/migrations/20260810_000001_api_key_proxy.d.ts +4 -0
- package/dist/db/migrations/20260810_000001_api_key_proxy.d.ts.map +1 -0
- package/dist/db/migrations/20260810_000001_api_key_proxy.js +33 -0
- package/dist/db/migrations/20260810_000001_api_key_proxy.js.map +1 -0
- package/dist/db/migrations/20260819_000001_custom_model_tombstones.d.ts +4 -0
- package/dist/db/migrations/20260819_000001_custom_model_tombstones.d.ts.map +1 -0
- package/dist/db/migrations/20260819_000001_custom_model_tombstones.js +19 -0
- package/dist/db/migrations/20260819_000001_custom_model_tombstones.js.map +1 -0
- package/dist/db/migrations/20260820_000001_playground_conversations.d.ts +4 -0
- package/dist/db/migrations/20260820_000001_playground_conversations.d.ts.map +1 -0
- package/dist/db/migrations/20260820_000001_playground_conversations.js +47 -0
- package/dist/db/migrations/20260820_000001_playground_conversations.js.map +1 -0
- package/dist/db/migrations/20260823_000001_server_logs.d.ts +4 -0
- package/dist/db/migrations/20260823_000001_server_logs.d.ts.map +1 -0
- package/dist/db/migrations/20260823_000001_server_logs.js +53 -0
- package/dist/db/migrations/20260823_000001_server_logs.js.map +1 -0
- package/dist/db/migrations/20260823_000002_backups_table.d.ts +8 -0
- package/dist/db/migrations/20260823_000002_backups_table.d.ts.map +1 -0
- package/dist/db/migrations/20260823_000002_backups_table.js +22 -0
- package/dist/db/migrations/20260823_000002_backups_table.js.map +1 -0
- package/dist/db/migrations/20260823_000003_attempt_key_label.d.ts +19 -0
- package/dist/db/migrations/20260823_000003_attempt_key_label.d.ts.map +1 -0
- package/dist/db/migrations/20260823_000003_attempt_key_label.js +28 -0
- package/dist/db/migrations/20260823_000003_attempt_key_label.js.map +1 -0
- package/dist/db/migrations/20260823_000004_profile_auto_include.d.ts +14 -0
- package/dist/db/migrations/20260823_000004_profile_auto_include.d.ts.map +1 -0
- package/dist/db/migrations/20260823_000004_profile_auto_include.js +25 -0
- package/dist/db/migrations/20260823_000004_profile_auto_include.js.map +1 -0
- package/dist/db/migrations/20260901_000001_idempotency_claims.d.ts +4 -0
- package/dist/db/migrations/20260901_000001_idempotency_claims.d.ts.map +1 -0
- package/dist/db/migrations/20260901_000001_idempotency_claims.js +46 -0
- package/dist/db/migrations/20260901_000001_idempotency_claims.js.map +1 -0
- package/dist/db/migrations/20260901_000002_quota_observation_lookup.d.ts +4 -0
- package/dist/db/migrations/20260901_000002_quota_observation_lookup.d.ts.map +1 -0
- package/dist/db/migrations/20260901_000002_quota_observation_lookup.js +37 -0
- package/dist/db/migrations/20260901_000002_quota_observation_lookup.js.map +1 -0
- package/dist/db/migrations/20260901_000003_request_caller.d.ts +4 -0
- package/dist/db/migrations/20260901_000003_request_caller.d.ts.map +1 -0
- package/dist/db/migrations/20260901_000003_request_caller.js +27 -0
- package/dist/db/migrations/20260901_000003_request_caller.js.map +1 -0
- package/dist/db/migrations/20260902_000001_analytics_latency_percentile_index.d.ts +4 -0
- package/dist/db/migrations/20260902_000001_analytics_latency_percentile_index.d.ts.map +1 -0
- package/dist/db/migrations/20260902_000001_analytics_latency_percentile_index.js +35 -0
- package/dist/db/migrations/20260902_000001_analytics_latency_percentile_index.js.map +1 -0
- package/dist/db/migrations/20260903_000001_mcp_enabled_default.d.ts +4 -0
- package/dist/db/migrations/20260903_000001_mcp_enabled_default.d.ts.map +1 -0
- package/dist/db/migrations/20260903_000001_mcp_enabled_default.js +37 -0
- package/dist/db/migrations/20260903_000001_mcp_enabled_default.js.map +1 -0
- package/dist/db/migrations/20260903_000002_response_cache.d.ts +4 -0
- package/dist/db/migrations/20260903_000002_response_cache.d.ts.map +1 -0
- package/dist/db/migrations/20260903_000002_response_cache.js +53 -0
- package/dist/db/migrations/20260903_000002_response_cache.js.map +1 -0
- package/dist/db/migrations/20260904_000001_key_monthly_budget.d.ts +13 -0
- package/dist/db/migrations/20260904_000001_key_monthly_budget.d.ts.map +1 -0
- package/dist/db/migrations/20260904_000001_key_monthly_budget.js +35 -0
- package/dist/db/migrations/20260904_000001_key_monthly_budget.js.map +1 -0
- package/dist/db/migrations/20260913_000001_request_model_attribution.d.ts +4 -0
- package/dist/db/migrations/20260913_000001_request_model_attribution.d.ts.map +1 -0
- package/dist/db/migrations/20260913_000001_request_model_attribution.js +68 -0
- package/dist/db/migrations/20260913_000001_request_model_attribution.js.map +1 -0
- package/dist/db/migrations/20260914_000001_key_monthly_usage.d.ts +5 -0
- package/dist/db/migrations/20260914_000001_key_monthly_usage.d.ts.map +1 -0
- package/dist/db/migrations/20260914_000001_key_monthly_usage.js +37 -0
- package/dist/db/migrations/20260914_000001_key_monthly_usage.js.map +1 -0
- package/dist/db/migrations/20260915_000001_quota_snapshot_freshness.d.ts +4 -0
- package/dist/db/migrations/20260915_000001_quota_snapshot_freshness.d.ts.map +1 -0
- package/dist/db/migrations/20260915_000001_quota_snapshot_freshness.js +28 -0
- package/dist/db/migrations/20260915_000001_quota_snapshot_freshness.js.map +1 -0
- package/dist/db/migrations/20261004_000001_unified_key_sr_prefix.d.ts +4 -0
- package/dist/db/migrations/20261004_000001_unified_key_sr_prefix.d.ts.map +1 -0
- package/dist/db/migrations/20261004_000001_unified_key_sr_prefix.js +25 -0
- package/dist/db/migrations/20261004_000001_unified_key_sr_prefix.js.map +1 -0
- package/dist/db/migrations/20261005_000001_key_budget_period.d.ts +10 -0
- package/dist/db/migrations/20261005_000001_key_budget_period.d.ts.map +1 -0
- package/dist/db/migrations/20261005_000001_key_budget_period.js +78 -0
- package/dist/db/migrations/20261005_000001_key_budget_period.js.map +1 -0
- package/dist/db/migrations/20261005_000002_key_budget_period_auto.d.ts +21 -0
- package/dist/db/migrations/20261005_000002_key_budget_period_auto.d.ts.map +1 -0
- package/dist/db/migrations/20261005_000002_key_budget_period_auto.js +41 -0
- package/dist/db/migrations/20261005_000002_key_budget_period_auto.js.map +1 -0
- package/dist/db/model-pricing.d.ts +39 -0
- package/dist/db/model-pricing.d.ts.map +1 -0
- package/dist/db/model-pricing.js +274 -0
- package/dist/db/model-pricing.js.map +1 -0
- package/dist/db/node-sqlite.d.ts +9 -0
- package/dist/db/node-sqlite.d.ts.map +1 -0
- package/dist/db/node-sqlite.js +87 -0
- package/dist/db/node-sqlite.js.map +1 -0
- package/dist/db/types.d.ts +22 -0
- package/dist/db/types.d.ts.map +1 -0
- package/dist/db/types.js +2 -0
- package/dist/db/types.js.map +1 -0
- package/dist/docs/docs-page.d.ts +2 -0
- package/dist/docs/docs-page.d.ts.map +1 -0
- package/dist/docs/docs-page.js +319 -0
- package/dist/docs/docs-page.js.map +1 -0
- package/dist/docs/openapi.d.ts +1896 -0
- package/dist/docs/openapi.d.ts.map +1 -0
- package/dist/docs/openapi.js +1096 -0
- package/dist/docs/openapi.js.map +1 -0
- package/dist/env.d.ts +2 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +9 -0
- package/dist/env.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +144 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/anthropic-documents.d.ts +41 -0
- package/dist/lib/anthropic-documents.d.ts.map +1 -0
- package/dist/lib/anthropic-documents.js +132 -0
- package/dist/lib/anthropic-documents.js.map +1 -0
- package/dist/lib/app-version.d.ts +5 -0
- package/dist/lib/app-version.d.ts.map +1 -0
- package/dist/lib/app-version.js +61 -0
- package/dist/lib/app-version.js.map +1 -0
- package/dist/lib/attempt-trace.d.ts +23 -0
- package/dist/lib/attempt-trace.d.ts.map +1 -0
- package/dist/lib/attempt-trace.js +32 -0
- package/dist/lib/attempt-trace.js.map +1 -0
- package/dist/lib/budget.d.ts +6 -0
- package/dist/lib/budget.d.ts.map +1 -0
- package/dist/lib/budget.js +40 -0
- package/dist/lib/budget.js.map +1 -0
- package/dist/lib/client-classifier.d.ts +10 -0
- package/dist/lib/client-classifier.d.ts.map +1 -0
- package/dist/lib/client-classifier.js +126 -0
- package/dist/lib/client-classifier.js.map +1 -0
- package/dist/lib/client-context.d.ts +10 -0
- package/dist/lib/client-context.d.ts.map +1 -0
- package/dist/lib/client-context.js +39 -0
- package/dist/lib/client-context.js.map +1 -0
- package/dist/lib/config.d.ts +33 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +94 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/content.d.ts +33 -0
- package/dist/lib/content.d.ts.map +1 -0
- package/dist/lib/content.js +201 -0
- package/dist/lib/content.js.map +1 -0
- package/dist/lib/credential.d.ts +11 -0
- package/dist/lib/credential.d.ts.map +1 -0
- package/dist/lib/credential.js +18 -0
- package/dist/lib/credential.js.map +1 -0
- package/dist/lib/crypto.d.ts +31 -0
- package/dist/lib/crypto.d.ts.map +1 -0
- package/dist/lib/crypto.js +206 -0
- package/dist/lib/crypto.js.map +1 -0
- package/dist/lib/custom-provider-cleanup.d.ts +13 -0
- package/dist/lib/custom-provider-cleanup.d.ts.map +1 -0
- package/dist/lib/custom-provider-cleanup.js +43 -0
- package/dist/lib/custom-provider-cleanup.js.map +1 -0
- package/dist/lib/db-backup.d.ts +31 -0
- package/dist/lib/db-backup.d.ts.map +1 -0
- package/dist/lib/db-backup.js +255 -0
- package/dist/lib/db-backup.js.map +1 -0
- package/dist/lib/endpoint-scope.d.ts +42 -0
- package/dist/lib/endpoint-scope.d.ts.map +1 -0
- package/dist/lib/endpoint-scope.js +95 -0
- package/dist/lib/endpoint-scope.js.map +1 -0
- package/dist/lib/env-drift.d.ts +17 -0
- package/dist/lib/env-drift.d.ts.map +1 -0
- package/dist/lib/env-drift.js +108 -0
- package/dist/lib/env-drift.js.map +1 -0
- package/dist/lib/error-classify.d.ts +40 -0
- package/dist/lib/error-classify.d.ts.map +1 -0
- package/dist/lib/error-classify.js +687 -0
- package/dist/lib/error-classify.js.map +1 -0
- package/dist/lib/error-redaction.d.ts +8 -0
- package/dist/lib/error-redaction.d.ts.map +1 -0
- package/dist/lib/error-redaction.js +45 -0
- package/dist/lib/error-redaction.js.map +1 -0
- package/dist/lib/fallback-loop.d.ts +306 -0
- package/dist/lib/fallback-loop.d.ts.map +1 -0
- package/dist/lib/fallback-loop.js +1336 -0
- package/dist/lib/fallback-loop.js.map +1 -0
- package/dist/lib/file-permissions.d.ts +98 -0
- package/dist/lib/file-permissions.d.ts.map +1 -0
- package/dist/lib/file-permissions.js +159 -0
- package/dist/lib/file-permissions.js.map +1 -0
- package/dist/lib/gemini-wire.d.ts +76 -0
- package/dist/lib/gemini-wire.d.ts.map +1 -0
- package/dist/lib/gemini-wire.js +392 -0
- package/dist/lib/gemini-wire.js.map +1 -0
- package/dist/lib/guardrails.d.ts +37 -0
- package/dist/lib/guardrails.d.ts.map +1 -0
- package/dist/lib/guardrails.js +106 -0
- package/dist/lib/guardrails.js.map +1 -0
- package/dist/lib/header-value.d.ts +8 -0
- package/dist/lib/header-value.d.ts.map +1 -0
- package/dist/lib/header-value.js +55 -0
- package/dist/lib/header-value.js.map +1 -0
- package/dist/lib/image-normalize.d.ts +21 -0
- package/dist/lib/image-normalize.d.ts.map +1 -0
- package/dist/lib/image-normalize.js +218 -0
- package/dist/lib/image-normalize.js.map +1 -0
- package/dist/lib/inbound-chat.d.ts +48 -0
- package/dist/lib/inbound-chat.d.ts.map +1 -0
- package/dist/lib/inbound-chat.js +406 -0
- package/dist/lib/inbound-chat.js.map +1 -0
- package/dist/lib/key-parser.d.ts +79 -0
- package/dist/lib/key-parser.d.ts.map +1 -0
- package/dist/lib/key-parser.js +701 -0
- package/dist/lib/key-parser.js.map +1 -0
- package/dist/lib/key-proxy.d.ts +49 -0
- package/dist/lib/key-proxy.d.ts.map +1 -0
- package/dist/lib/key-proxy.js +92 -0
- package/dist/lib/key-proxy.js.map +1 -0
- package/dist/lib/log-redaction.d.ts +28 -0
- package/dist/lib/log-redaction.d.ts.map +1 -0
- package/dist/lib/log-redaction.js +166 -0
- package/dist/lib/log-redaction.js.map +1 -0
- package/dist/lib/model-scope.d.ts +9 -0
- package/dist/lib/model-scope.d.ts.map +1 -0
- package/dist/lib/model-scope.js +29 -0
- package/dist/lib/model-scope.js.map +1 -0
- package/dist/lib/one-time-code.d.ts +15 -0
- package/dist/lib/one-time-code.d.ts.map +1 -0
- package/dist/lib/one-time-code.js +41 -0
- package/dist/lib/one-time-code.js.map +1 -0
- package/dist/lib/output-cap.d.ts +24 -0
- package/dist/lib/output-cap.d.ts.map +1 -0
- package/dist/lib/output-cap.js +80 -0
- package/dist/lib/output-cap.js.map +1 -0
- package/dist/lib/password.d.ts +3 -0
- package/dist/lib/password.d.ts.map +1 -0
- package/dist/lib/password.js +27 -0
- package/dist/lib/password.js.map +1 -0
- package/dist/lib/process-safety-net.d.ts +32 -0
- package/dist/lib/process-safety-net.d.ts.map +1 -0
- package/dist/lib/process-safety-net.js +123 -0
- package/dist/lib/process-safety-net.js.map +1 -0
- package/dist/lib/provider-identity.d.ts +37 -0
- package/dist/lib/provider-identity.d.ts.map +1 -0
- package/dist/lib/provider-identity.js +110 -0
- package/dist/lib/provider-identity.js.map +1 -0
- package/dist/lib/provider-size-parser.d.ts +6 -0
- package/dist/lib/provider-size-parser.d.ts.map +1 -0
- package/dist/lib/provider-size-parser.js +72 -0
- package/dist/lib/provider-size-parser.js.map +1 -0
- package/dist/lib/provider-timeout.d.ts +17 -0
- package/dist/lib/provider-timeout.d.ts.map +1 -0
- package/dist/lib/provider-timeout.js +70 -0
- package/dist/lib/provider-timeout.js.map +1 -0
- package/dist/lib/proxy.d.ts +180 -0
- package/dist/lib/proxy.d.ts.map +1 -0
- package/dist/lib/proxy.js +1002 -0
- package/dist/lib/proxy.js.map +1 -0
- package/dist/lib/request-log.d.ts +4 -0
- package/dist/lib/request-log.d.ts.map +1 -0
- package/dist/lib/request-log.js +161 -0
- package/dist/lib/request-log.js.map +1 -0
- package/dist/lib/reset-code.d.ts +5 -0
- package/dist/lib/reset-code.d.ts.map +1 -0
- package/dist/lib/reset-code.js +39 -0
- package/dist/lib/reset-code.js.map +1 -0
- package/dist/lib/retry-hint.d.ts +14 -0
- package/dist/lib/retry-hint.d.ts.map +1 -0
- package/dist/lib/retry-hint.js +36 -0
- package/dist/lib/retry-hint.js.map +1 -0
- package/dist/lib/route-live.d.ts +29 -0
- package/dist/lib/route-live.d.ts.map +1 -0
- package/dist/lib/route-live.js +97 -0
- package/dist/lib/route-live.js.map +1 -0
- package/dist/lib/sampling-params.d.ts +183 -0
- package/dist/lib/sampling-params.d.ts.map +1 -0
- package/dist/lib/sampling-params.js +386 -0
- package/dist/lib/sampling-params.js.map +1 -0
- package/dist/lib/scheduler.d.ts +13 -0
- package/dist/lib/scheduler.d.ts.map +1 -0
- package/dist/lib/scheduler.js +11 -0
- package/dist/lib/scheduler.js.map +1 -0
- package/dist/lib/served-model.d.ts +16 -0
- package/dist/lib/served-model.d.ts.map +1 -0
- package/dist/lib/served-model.js +0 -0
- package/dist/lib/served-model.js.map +1 -0
- package/dist/lib/server-logs.d.ts +132 -0
- package/dist/lib/server-logs.d.ts.map +1 -0
- package/dist/lib/server-logs.js +473 -0
- package/dist/lib/server-logs.js.map +1 -0
- package/dist/lib/setup-code.d.ts +5 -0
- package/dist/lib/setup-code.d.ts.map +1 -0
- package/dist/lib/setup-code.js +35 -0
- package/dist/lib/setup-code.js.map +1 -0
- package/dist/lib/structured-output.d.ts +9 -0
- package/dist/lib/structured-output.d.ts.map +1 -0
- package/dist/lib/structured-output.js +72 -0
- package/dist/lib/structured-output.js.map +1 -0
- package/dist/lib/system-prompt.d.ts +30 -0
- package/dist/lib/system-prompt.d.ts.map +1 -0
- package/dist/lib/system-prompt.js +66 -0
- package/dist/lib/system-prompt.js.map +1 -0
- package/dist/lib/task-type.d.ts +18 -0
- package/dist/lib/task-type.d.ts.map +1 -0
- package/dist/lib/task-type.js +92 -0
- package/dist/lib/task-type.js.map +1 -0
- package/dist/lib/think-tags.d.ts +37 -0
- package/dist/lib/think-tags.d.ts.map +1 -0
- package/dist/lib/think-tags.js +203 -0
- package/dist/lib/think-tags.js.map +1 -0
- package/dist/lib/tool-args.d.ts +36 -0
- package/dist/lib/tool-args.d.ts.map +1 -0
- package/dist/lib/tool-args.js +190 -0
- package/dist/lib/tool-args.js.map +1 -0
- package/dist/lib/tool-call-rescue.d.ts +62 -0
- package/dist/lib/tool-call-rescue.d.ts.map +1 -0
- package/dist/lib/tool-call-rescue.js +258 -0
- package/dist/lib/tool-call-rescue.js.map +1 -0
- package/dist/lib/tool-capability.d.ts +13 -0
- package/dist/lib/tool-capability.d.ts.map +1 -0
- package/dist/lib/tool-capability.js +70 -0
- package/dist/lib/tool-capability.js.map +1 -0
- package/dist/lib/tool-validate.d.ts +44 -0
- package/dist/lib/tool-validate.d.ts.map +1 -0
- package/dist/lib/tool-validate.js +165 -0
- package/dist/lib/tool-validate.js.map +1 -0
- package/dist/lib/ttfb-budget.d.ts +13 -0
- package/dist/lib/ttfb-budget.d.ts.map +1 -0
- package/dist/lib/ttfb-budget.js +153 -0
- package/dist/lib/ttfb-budget.js.map +1 -0
- package/dist/lib/url-guard.d.ts +47 -0
- package/dist/lib/url-guard.d.ts.map +1 -0
- package/dist/lib/url-guard.js +229 -0
- package/dist/lib/url-guard.js.map +1 -0
- package/dist/lib/wake-detect.d.ts +12 -0
- package/dist/lib/wake-detect.d.ts.map +1 -0
- package/dist/lib/wake-detect.js +99 -0
- package/dist/lib/wake-detect.js.map +1 -0
- package/dist/middleware/errorHandler.d.ts +3 -0
- package/dist/middleware/errorHandler.d.ts.map +1 -0
- package/dist/middleware/errorHandler.js +45 -0
- package/dist/middleware/errorHandler.js.map +1 -0
- package/dist/middleware/rateLimit.d.ts +4 -0
- package/dist/middleware/rateLimit.d.ts.map +1 -0
- package/dist/middleware/rateLimit.js +125 -0
- package/dist/middleware/rateLimit.js.map +1 -0
- package/dist/middleware/requireAuth.d.ts +3 -0
- package/dist/middleware/requireAuth.d.ts.map +1 -0
- package/dist/middleware/requireAuth.js +17 -0
- package/dist/middleware/requireAuth.js.map +1 -0
- package/dist/providers/aclide.d.ts +18 -0
- package/dist/providers/aclide.d.ts.map +1 -0
- package/dist/providers/aclide.js +174 -0
- package/dist/providers/aclide.js.map +1 -0
- package/dist/providers/aihorde.d.ts +42 -0
- package/dist/providers/aihorde.d.ts.map +1 -0
- package/dist/providers/aihorde.js +192 -0
- package/dist/providers/aihorde.js.map +1 -0
- package/dist/providers/airforce.d.ts +11 -0
- package/dist/providers/airforce.d.ts.map +1 -0
- package/dist/providers/airforce.js +32 -0
- package/dist/providers/airforce.js.map +1 -0
- package/dist/providers/base.d.ts +177 -0
- package/dist/providers/base.d.ts.map +1 -0
- package/dist/providers/base.js +354 -0
- package/dist/providers/base.js.map +1 -0
- package/dist/providers/blaze.d.ts +11 -0
- package/dist/providers/blaze.d.ts.map +1 -0
- package/dist/providers/blaze.js +33 -0
- package/dist/providers/blaze.js.map +1 -0
- package/dist/providers/blockrun.d.ts +14 -0
- package/dist/providers/blockrun.d.ts.map +1 -0
- package/dist/providers/blockrun.js +36 -0
- package/dist/providers/blockrun.js.map +1 -0
- package/dist/providers/clod.d.ts +11 -0
- package/dist/providers/clod.d.ts.map +1 -0
- package/dist/providers/clod.js +39 -0
- package/dist/providers/clod.js.map +1 -0
- package/dist/providers/cloudflare.d.ts +15 -0
- package/dist/providers/cloudflare.d.ts.map +1 -0
- package/dist/providers/cloudflare.js +175 -0
- package/dist/providers/cloudflare.js.map +1 -0
- package/dist/providers/cohere.d.ts +11 -0
- package/dist/providers/cohere.d.ts.map +1 -0
- package/dist/providers/cohere.js +117 -0
- package/dist/providers/cohere.js.map +1 -0
- package/dist/providers/dreamprompting.d.ts +11 -0
- package/dist/providers/dreamprompting.d.ts.map +1 -0
- package/dist/providers/dreamprompting.js +41 -0
- package/dist/providers/dreamprompting.js.map +1 -0
- package/dist/providers/electronhub.d.ts +10 -0
- package/dist/providers/electronhub.d.ts.map +1 -0
- package/dist/providers/electronhub.js +56 -0
- package/dist/providers/electronhub.js.map +1 -0
- package/dist/providers/experiential.d.ts +10 -0
- package/dist/providers/experiential.d.ts.map +1 -0
- package/dist/providers/experiential.js +33 -0
- package/dist/providers/experiential.js.map +1 -0
- package/dist/providers/gizmo.d.ts +15 -0
- package/dist/providers/gizmo.d.ts.map +1 -0
- package/dist/providers/gizmo.js +54 -0
- package/dist/providers/gizmo.js.map +1 -0
- package/dist/providers/google.d.ts +30 -0
- package/dist/providers/google.d.ts.map +1 -0
- package/dist/providers/google.js +766 -0
- package/dist/providers/google.js.map +1 -0
- package/dist/providers/index.d.ts +13 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +561 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/llmtr.d.ts +15 -0
- package/dist/providers/llmtr.d.ts.map +1 -0
- package/dist/providers/llmtr.js +51 -0
- package/dist/providers/llmtr.js.map +1 -0
- package/dist/providers/logfare.d.ts +11 -0
- package/dist/providers/logfare.d.ts.map +1 -0
- package/dist/providers/logfare.js +33 -0
- package/dist/providers/logfare.js.map +1 -0
- package/dist/providers/lucidity.d.ts +11 -0
- package/dist/providers/lucidity.d.ts.map +1 -0
- package/dist/providers/lucidity.js +37 -0
- package/dist/providers/lucidity.js.map +1 -0
- package/dist/providers/modelscope.d.ts +50 -0
- package/dist/providers/modelscope.d.ts.map +1 -0
- package/dist/providers/modelscope.js +143 -0
- package/dist/providers/modelscope.js.map +1 -0
- package/dist/providers/moondream.d.ts +17 -0
- package/dist/providers/moondream.d.ts.map +1 -0
- package/dist/providers/moondream.js +140 -0
- package/dist/providers/moondream.js.map +1 -0
- package/dist/providers/openai-compat.d.ts +140 -0
- package/dist/providers/openai-compat.d.ts.map +1 -0
- package/dist/providers/openai-compat.js +537 -0
- package/dist/providers/openai-compat.js.map +1 -0
- package/dist/providers/opencode-free.d.ts +24 -0
- package/dist/providers/opencode-free.d.ts.map +1 -0
- package/dist/providers/opencode-free.js +262 -0
- package/dist/providers/opencode-free.js.map +1 -0
- package/dist/providers/pollinations.d.ts +32 -0
- package/dist/providers/pollinations.d.ts.map +1 -0
- package/dist/providers/pollinations.js +65 -0
- package/dist/providers/pollinations.js.map +1 -0
- package/dist/providers/router9.d.ts +16 -0
- package/dist/providers/router9.d.ts.map +1 -0
- package/dist/providers/router9.js +42 -0
- package/dist/providers/router9.js.map +1 -0
- package/dist/providers/sail.d.ts +44 -0
- package/dist/providers/sail.d.ts.map +1 -0
- package/dist/providers/sail.js +302 -0
- package/dist/providers/sail.js.map +1 -0
- package/dist/providers/septor.d.ts +10 -0
- package/dist/providers/septor.d.ts.map +1 -0
- package/dist/providers/septor.js +36 -0
- package/dist/providers/septor.js.map +1 -0
- package/dist/providers/speechify.d.ts +14 -0
- package/dist/providers/speechify.d.ts.map +1 -0
- package/dist/providers/speechify.js +28 -0
- package/dist/providers/speechify.js.map +1 -0
- package/dist/providers/speka.d.ts +12 -0
- package/dist/providers/speka.d.ts.map +1 -0
- package/dist/providers/speka.js +37 -0
- package/dist/providers/speka.js.map +1 -0
- package/dist/providers/waterfall.d.ts +11 -0
- package/dist/providers/waterfall.d.ts.map +1 -0
- package/dist/providers/waterfall.js +33 -0
- package/dist/providers/waterfall.js.map +1 -0
- package/dist/providers/xkiro.d.ts +71 -0
- package/dist/providers/xkiro.d.ts.map +1 -0
- package/dist/providers/xkiro.js +119 -0
- package/dist/providers/xkiro.js.map +1 -0
- package/dist/providers/zhipu.d.ts +43 -0
- package/dist/providers/zhipu.d.ts.map +1 -0
- package/dist/providers/zhipu.js +95 -0
- package/dist/providers/zhipu.js.map +1 -0
- package/dist/routes/analytics.d.ts +2 -0
- package/dist/routes/analytics.d.ts.map +1 -0
- package/dist/routes/analytics.js +738 -0
- package/dist/routes/analytics.js.map +1 -0
- package/dist/routes/anthropic.d.ts +7 -0
- package/dist/routes/anthropic.d.ts.map +1 -0
- package/dist/routes/anthropic.js +1076 -0
- package/dist/routes/anthropic.js.map +1 -0
- package/dist/routes/auth.d.ts +2 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +310 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/backups.d.ts +2 -0
- package/dist/routes/backups.d.ts.map +1 -0
- package/dist/routes/backups.js +127 -0
- package/dist/routes/backups.js.map +1 -0
- package/dist/routes/cache.d.ts +2 -0
- package/dist/routes/cache.d.ts.map +1 -0
- package/dist/routes/cache.js +40 -0
- package/dist/routes/cache.js.map +1 -0
- package/dist/routes/client-profiles.d.ts +2 -0
- package/dist/routes/client-profiles.d.ts.map +1 -0
- package/dist/routes/client-profiles.js +128 -0
- package/dist/routes/client-profiles.js.map +1 -0
- package/dist/routes/compression.d.ts +2 -0
- package/dist/routes/compression.d.ts.map +1 -0
- package/dist/routes/compression.js +82 -0
- package/dist/routes/compression.js.map +1 -0
- package/dist/routes/conversations.d.ts +3 -0
- package/dist/routes/conversations.d.ts.map +1 -0
- package/dist/routes/conversations.js +215 -0
- package/dist/routes/conversations.js.map +1 -0
- package/dist/routes/docs.d.ts +2 -0
- package/dist/routes/docs.d.ts.map +1 -0
- package/dist/routes/docs.js +21 -0
- package/dist/routes/docs.js.map +1 -0
- package/dist/routes/embeddings.d.ts +2 -0
- package/dist/routes/embeddings.d.ts.map +1 -0
- package/dist/routes/embeddings.js +255 -0
- package/dist/routes/embeddings.js.map +1 -0
- package/dist/routes/fallback.d.ts +2 -0
- package/dist/routes/fallback.d.ts.map +1 -0
- package/dist/routes/fallback.js +631 -0
- package/dist/routes/fallback.js.map +1 -0
- package/dist/routes/free-tier.d.ts +22 -0
- package/dist/routes/free-tier.d.ts.map +1 -0
- package/dist/routes/free-tier.js +175 -0
- package/dist/routes/free-tier.js.map +1 -0
- package/dist/routes/gemini.d.ts +2 -0
- package/dist/routes/gemini.d.ts.map +1 -0
- package/dist/routes/gemini.js +218 -0
- package/dist/routes/gemini.js.map +1 -0
- package/dist/routes/health.d.ts +2 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +72 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/keys.d.ts +17 -0
- package/dist/routes/keys.d.ts.map +1 -0
- package/dist/routes/keys.js +1787 -0
- package/dist/routes/keys.js.map +1 -0
- package/dist/routes/logs.d.ts +2 -0
- package/dist/routes/logs.d.ts.map +1 -0
- package/dist/routes/logs.js +88 -0
- package/dist/routes/logs.js.map +1 -0
- package/dist/routes/mcp.d.ts +4 -0
- package/dist/routes/mcp.d.ts.map +1 -0
- package/dist/routes/mcp.js +563 -0
- package/dist/routes/mcp.js.map +1 -0
- package/dist/routes/media.d.ts +2 -0
- package/dist/routes/media.d.ts.map +1 -0
- package/dist/routes/media.js +180 -0
- package/dist/routes/media.js.map +1 -0
- package/dist/routes/models.d.ts +2 -0
- package/dist/routes/models.d.ts.map +1 -0
- package/dist/routes/models.js +439 -0
- package/dist/routes/models.js.map +1 -0
- package/dist/routes/ollama.d.ts +7 -0
- package/dist/routes/ollama.d.ts.map +1 -0
- package/dist/routes/ollama.js +595 -0
- package/dist/routes/ollama.js.map +1 -0
- package/dist/routes/profiles.d.ts +3 -0
- package/dist/routes/profiles.d.ts.map +1 -0
- package/dist/routes/profiles.js +431 -0
- package/dist/routes/profiles.js.map +1 -0
- package/dist/routes/proxy.d.ts +32 -0
- package/dist/routes/proxy.d.ts.map +1 -0
- package/dist/routes/proxy.js +2793 -0
- package/dist/routes/proxy.js.map +1 -0
- package/dist/routes/responses.d.ts +1161 -0
- package/dist/routes/responses.d.ts.map +1 -0
- package/dist/routes/responses.js +1521 -0
- package/dist/routes/responses.js.map +1 -0
- package/dist/routes/settings.d.ts +2 -0
- package/dist/routes/settings.d.ts.map +1 -0
- package/dist/routes/settings.js +549 -0
- package/dist/routes/settings.js.map +1 -0
- package/dist/routes/status.d.ts +3 -0
- package/dist/routes/status.d.ts.map +1 -0
- package/dist/routes/status.js +214 -0
- package/dist/routes/status.js.map +1 -0
- package/dist/routes/update.d.ts +31 -0
- package/dist/routes/update.d.ts.map +1 -0
- package/dist/routes/update.js +536 -0
- package/dist/routes/update.js.map +1 -0
- package/dist/routes/url-tokens.d.ts +2 -0
- package/dist/routes/url-tokens.d.ts.map +1 -0
- package/dist/routes/url-tokens.js +31 -0
- package/dist/routes/url-tokens.js.map +1 -0
- package/dist/scripts/export-catalog.d.ts +2 -0
- package/dist/scripts/export-catalog.d.ts.map +1 -0
- package/dist/scripts/export-catalog.js +114 -0
- package/dist/scripts/export-catalog.js.map +1 -0
- package/dist/scripts/rotate-encryption-key.d.ts +81 -0
- package/dist/scripts/rotate-encryption-key.d.ts.map +1 -0
- package/dist/scripts/rotate-encryption-key.js +232 -0
- package/dist/scripts/rotate-encryption-key.js.map +1 -0
- package/dist/scripts/routing-sim.d.ts +2 -0
- package/dist/scripts/routing-sim.d.ts.map +1 -0
- package/dist/scripts/routing-sim.js +130 -0
- package/dist/scripts/routing-sim.js.map +1 -0
- package/dist/scripts/test-all-models.d.ts +2 -0
- package/dist/scripts/test-all-models.d.ts.map +1 -0
- package/dist/scripts/test-all-models.js +56 -0
- package/dist/scripts/test-all-models.js.map +1 -0
- package/dist/services/anthropic-map.d.ts +39 -0
- package/dist/services/anthropic-map.d.ts.map +1 -0
- package/dist/services/anthropic-map.js +138 -0
- package/dist/services/anthropic-map.js.map +1 -0
- package/dist/services/auth.d.ts +30 -0
- package/dist/services/auth.d.ts.map +1 -0
- package/dist/services/auth.js +124 -0
- package/dist/services/auth.js.map +1 -0
- package/dist/services/auto-discover.d.ts +77 -0
- package/dist/services/auto-discover.d.ts.map +1 -0
- package/dist/services/auto-discover.js +1047 -0
- package/dist/services/auto-discover.js.map +1 -0
- package/dist/services/backups.d.ts +67 -0
- package/dist/services/backups.d.ts.map +1 -0
- package/dist/services/backups.js +444 -0
- package/dist/services/backups.js.map +1 -0
- package/dist/services/builtin-model-discovery.d.ts +107 -0
- package/dist/services/builtin-model-discovery.d.ts.map +1 -0
- package/dist/services/builtin-model-discovery.js +296 -0
- package/dist/services/builtin-model-discovery.js.map +1 -0
- package/dist/services/cache.d.ts +190 -0
- package/dist/services/cache.d.ts.map +1 -0
- package/dist/services/cache.js +617 -0
- package/dist/services/cache.js.map +1 -0
- package/dist/services/catalog-sync.d.ts +257 -0
- package/dist/services/catalog-sync.d.ts.map +1 -0
- package/dist/services/catalog-sync.js +1041 -0
- package/dist/services/catalog-sync.js.map +1 -0
- package/dist/services/compression/config.d.ts +33 -0
- package/dist/services/compression/config.d.ts.map +1 -0
- package/dist/services/compression/config.js +158 -0
- package/dist/services/compression/config.js.map +1 -0
- package/dist/services/compression/engines/aging.d.ts +2 -0
- package/dist/services/compression/engines/aging.d.ts.map +1 -0
- package/dist/services/compression/engines/aging.js +53 -0
- package/dist/services/compression/engines/aging.js.map +1 -0
- package/dist/services/compression/engines/custom-filters.d.ts +4 -0
- package/dist/services/compression/engines/custom-filters.d.ts.map +1 -0
- package/dist/services/compression/engines/custom-filters.js +49 -0
- package/dist/services/compression/engines/custom-filters.js.map +1 -0
- package/dist/services/compression/engines/dedup.d.ts +2 -0
- package/dist/services/compression/engines/dedup.d.ts.map +1 -0
- package/dist/services/compression/engines/dedup.js +61 -0
- package/dist/services/compression/engines/dedup.js.map +1 -0
- package/dist/services/compression/engines/filter-definitions.d.ts +75 -0
- package/dist/services/compression/engines/filter-definitions.d.ts.map +1 -0
- package/dist/services/compression/engines/filter-definitions.js +45 -0
- package/dist/services/compression/engines/filter-definitions.js.map +1 -0
- package/dist/services/compression/engines/hard-budget.d.ts +2 -0
- package/dist/services/compression/engines/hard-budget.d.ts.map +1 -0
- package/dist/services/compression/engines/hard-budget.js +52 -0
- package/dist/services/compression/engines/hard-budget.js.map +1 -0
- package/dist/services/compression/engines/index.d.ts +9 -0
- package/dist/services/compression/engines/index.d.ts.map +1 -0
- package/dist/services/compression/engines/index.js +9 -0
- package/dist/services/compression/engines/index.js.map +1 -0
- package/dist/services/compression/engines/jsoncompact.d.ts +3 -0
- package/dist/services/compression/engines/jsoncompact.d.ts.map +1 -0
- package/dist/services/compression/engines/jsoncompact.js +145 -0
- package/dist/services/compression/engines/jsoncompact.js.map +1 -0
- package/dist/services/compression/engines/lite.d.ts +2 -0
- package/dist/services/compression/engines/lite.d.ts.map +1 -0
- package/dist/services/compression/engines/lite.js +34 -0
- package/dist/services/compression/engines/lite.js.map +1 -0
- package/dist/services/compression/engines/read-lifecycle.d.ts +2 -0
- package/dist/services/compression/engines/read-lifecycle.d.ts.map +1 -0
- package/dist/services/compression/engines/read-lifecycle.js +70 -0
- package/dist/services/compression/engines/read-lifecycle.js.map +1 -0
- package/dist/services/compression/engines/relevance.d.ts +2 -0
- package/dist/services/compression/engines/relevance.d.ts.map +1 -0
- package/dist/services/compression/engines/relevance.js +74 -0
- package/dist/services/compression/engines/relevance.js.map +1 -0
- package/dist/services/compression/engines/toolfilter.d.ts +2 -0
- package/dist/services/compression/engines/toolfilter.d.ts.map +1 -0
- package/dist/services/compression/engines/toolfilter.js +195 -0
- package/dist/services/compression/engines/toolfilter.js.map +1 -0
- package/dist/services/compression/fidelity-gate.d.ts +12 -0
- package/dist/services/compression/fidelity-gate.d.ts.map +1 -0
- package/dist/services/compression/fidelity-gate.js +90 -0
- package/dist/services/compression/fidelity-gate.js.map +1 -0
- package/dist/services/compression/helpers.d.ts +9 -0
- package/dist/services/compression/helpers.d.ts.map +1 -0
- package/dist/services/compression/helpers.js +52 -0
- package/dist/services/compression/helpers.js.map +1 -0
- package/dist/services/compression/pipeline.d.ts +7 -0
- package/dist/services/compression/pipeline.d.ts.map +1 -0
- package/dist/services/compression/pipeline.js +245 -0
- package/dist/services/compression/pipeline.js.map +1 -0
- package/dist/services/compression/preservation.d.ts +27 -0
- package/dist/services/compression/preservation.d.ts.map +1 -0
- package/dist/services/compression/preservation.js +109 -0
- package/dist/services/compression/preservation.js.map +1 -0
- package/dist/services/compression/registry.d.ts +6 -0
- package/dist/services/compression/registry.d.ts.map +1 -0
- package/dist/services/compression/registry.js +16 -0
- package/dist/services/compression/registry.js.map +1 -0
- package/dist/services/compression/stats.d.ts +29 -0
- package/dist/services/compression/stats.d.ts.map +1 -0
- package/dist/services/compression/stats.js +55 -0
- package/dist/services/compression/stats.js.map +1 -0
- package/dist/services/compression/types.d.ts +90 -0
- package/dist/services/compression/types.d.ts.map +1 -0
- package/dist/services/compression/types.js +2 -0
- package/dist/services/compression/types.js.map +1 -0
- package/dist/services/context-handoff.d.ts +22 -0
- package/dist/services/context-handoff.d.ts.map +1 -0
- package/dist/services/context-handoff.js +164 -0
- package/dist/services/context-handoff.js.map +1 -0
- package/dist/services/cooldown-probe.d.ts +24 -0
- package/dist/services/cooldown-probe.d.ts.map +1 -0
- package/dist/services/cooldown-probe.js +181 -0
- package/dist/services/cooldown-probe.js.map +1 -0
- package/dist/services/custom-endpoint.d.ts +58 -0
- package/dist/services/custom-endpoint.d.ts.map +1 -0
- package/dist/services/custom-endpoint.js +167 -0
- package/dist/services/custom-endpoint.js.map +1 -0
- package/dist/services/custom-media-register.d.ts +22 -0
- package/dist/services/custom-media-register.d.ts.map +1 -0
- package/dist/services/custom-media-register.js +45 -0
- package/dist/services/custom-media-register.js.map +1 -0
- package/dist/services/custom-model-register.d.ts +50 -0
- package/dist/services/custom-model-register.d.ts.map +1 -0
- package/dist/services/custom-model-register.js +103 -0
- package/dist/services/custom-model-register.js.map +1 -0
- package/dist/services/custom-model-seed.d.ts +18 -0
- package/dist/services/custom-model-seed.d.ts.map +1 -0
- package/dist/services/custom-model-seed.js +42 -0
- package/dist/services/custom-model-seed.js.map +1 -0
- package/dist/services/custom-model-sync.d.ts +37 -0
- package/dist/services/custom-model-sync.d.ts.map +1 -0
- package/dist/services/custom-model-sync.js +118 -0
- package/dist/services/custom-model-sync.js.map +1 -0
- package/dist/services/custom-model-tombstone.d.ts +5 -0
- package/dist/services/custom-model-tombstone.d.ts.map +1 -0
- package/dist/services/custom-model-tombstone.js +30 -0
- package/dist/services/custom-model-tombstone.js.map +1 -0
- package/dist/services/declarative-config.d.ts +356 -0
- package/dist/services/declarative-config.d.ts.map +1 -0
- package/dist/services/declarative-config.js +427 -0
- package/dist/services/declarative-config.js.map +1 -0
- package/dist/services/degradation.d.ts +40 -0
- package/dist/services/degradation.d.ts.map +1 -0
- package/dist/services/degradation.js +119 -0
- package/dist/services/degradation.js.map +1 -0
- package/dist/services/embeddings.d.ts +76 -0
- package/dist/services/embeddings.d.ts.map +1 -0
- package/dist/services/embeddings.js +319 -0
- package/dist/services/embeddings.js.map +1 -0
- package/dist/services/fusion.d.ts +162 -0
- package/dist/services/fusion.d.ts.map +1 -0
- package/dist/services/fusion.js +789 -0
- package/dist/services/fusion.js.map +1 -0
- package/dist/services/gemini-map.d.ts +14 -0
- package/dist/services/gemini-map.d.ts.map +1 -0
- package/dist/services/gemini-map.js +78 -0
- package/dist/services/gemini-map.js.map +1 -0
- package/dist/services/health.d.ts +52 -0
- package/dist/services/health.d.ts.map +1 -0
- package/dist/services/health.js +352 -0
- package/dist/services/health.js.map +1 -0
- package/dist/services/idempotency.d.ts +49 -0
- package/dist/services/idempotency.d.ts.map +1 -0
- package/dist/services/idempotency.js +140 -0
- package/dist/services/idempotency.js.map +1 -0
- package/dist/services/key-budget.d.ts +74 -0
- package/dist/services/key-budget.d.ts.map +1 -0
- package/dist/services/key-budget.js +200 -0
- package/dist/services/key-budget.js.map +1 -0
- package/dist/services/media.d.ts +123 -0
- package/dist/services/media.d.ts.map +1 -0
- package/dist/services/media.js +954 -0
- package/dist/services/media.js.map +1 -0
- package/dist/services/model-discovery.d.ts +126 -0
- package/dist/services/model-discovery.d.ts.map +1 -0
- package/dist/services/model-discovery.js +662 -0
- package/dist/services/model-discovery.js.map +1 -0
- package/dist/services/model-groups.d.ts +149 -0
- package/dist/services/model-groups.d.ts.map +1 -0
- package/dist/services/model-groups.js +314 -0
- package/dist/services/model-groups.js.map +1 -0
- package/dist/services/model-listing.d.ts +18 -0
- package/dist/services/model-listing.d.ts.map +1 -0
- package/dist/services/model-listing.js +94 -0
- package/dist/services/model-listing.js.map +1 -0
- package/dist/services/model-retirement.d.ts +29 -0
- package/dist/services/model-retirement.d.ts.map +1 -0
- package/dist/services/model-retirement.js +86 -0
- package/dist/services/model-retirement.js.map +1 -0
- package/dist/services/model-state.d.ts +90 -0
- package/dist/services/model-state.d.ts.map +1 -0
- package/dist/services/model-state.js +342 -0
- package/dist/services/model-state.js.map +1 -0
- package/dist/services/model-weight-overrides.d.ts +48 -0
- package/dist/services/model-weight-overrides.d.ts.map +1 -0
- package/dist/services/model-weight-overrides.js +127 -0
- package/dist/services/model-weight-overrides.js.map +1 -0
- package/dist/services/notification-emitter.d.ts +50 -0
- package/dist/services/notification-emitter.d.ts.map +1 -0
- package/dist/services/notification-emitter.js +177 -0
- package/dist/services/notification-emitter.js.map +1 -0
- package/dist/services/opencode-free-sync.d.ts +29 -0
- package/dist/services/opencode-free-sync.d.ts.map +1 -0
- package/dist/services/opencode-free-sync.js +206 -0
- package/dist/services/opencode-free-sync.js.map +1 -0
- package/dist/services/penalty-inspector.d.ts +56 -0
- package/dist/services/penalty-inspector.d.ts.map +1 -0
- package/dist/services/penalty-inspector.js +167 -0
- package/dist/services/penalty-inspector.js.map +1 -0
- package/dist/services/profile-models.d.ts +5 -0
- package/dist/services/profile-models.d.ts.map +1 -0
- package/dist/services/profile-models.js +62 -0
- package/dist/services/profile-models.js.map +1 -0
- package/dist/services/provider-credential.d.ts +19 -0
- package/dist/services/provider-credential.d.ts.map +1 -0
- package/dist/services/provider-credential.js +51 -0
- package/dist/services/provider-credential.js.map +1 -0
- package/dist/services/provider-quota.d.ts +64 -0
- package/dist/services/provider-quota.d.ts.map +1 -0
- package/dist/services/provider-quota.js +563 -0
- package/dist/services/provider-quota.js.map +1 -0
- package/dist/services/quirks.d.ts +0 -0
- package/dist/services/quirks.d.ts.map +1 -0
- package/dist/services/quirks.js +0 -0
- package/dist/services/quirks.js.map +1 -0
- package/dist/services/quota-forecast.d.ts +50 -0
- package/dist/services/quota-forecast.d.ts.map +1 -0
- package/dist/services/quota-forecast.js +150 -0
- package/dist/services/quota-forecast.js.map +1 -0
- package/dist/services/quota-outlook.d.ts +4 -0
- package/dist/services/quota-outlook.d.ts.map +1 -0
- package/dist/services/quota-outlook.js +92 -0
- package/dist/services/quota-outlook.js.map +1 -0
- package/dist/services/ratelimit.d.ts +249 -0
- package/dist/services/ratelimit.d.ts.map +1 -0
- package/dist/services/ratelimit.js +1182 -0
- package/dist/services/ratelimit.js.map +1 -0
- package/dist/services/request-retention.d.ts +60 -0
- package/dist/services/request-retention.d.ts.map +1 -0
- package/dist/services/request-retention.js +229 -0
- package/dist/services/request-retention.js.map +1 -0
- package/dist/services/router.d.ts +384 -0
- package/dist/services/router.d.ts.map +1 -0
- package/dist/services/router.js +1986 -0
- package/dist/services/router.js.map +1 -0
- package/dist/services/scoring.d.ts +156 -0
- package/dist/services/scoring.d.ts.map +1 -0
- package/dist/services/scoring.js +435 -0
- package/dist/services/scoring.js.map +1 -0
- package/dist/services/url-tokens.d.ts +15 -0
- package/dist/services/url-tokens.d.ts.map +1 -0
- package/dist/services/url-tokens.js +55 -0
- package/dist/services/url-tokens.js.map +1 -0
- package/package.json +77 -4
- package/README.md +0 -3
|
@@ -0,0 +1,1336 @@
|
|
|
1
|
+
import { soonestBudgetResetAt } from '../services/key-budget.js';
|
|
2
|
+
import { recordRateLimitHit, recordModelFailure, recordSuccess, hasOtherUsableKey, routableKeyIdsForModel, formatResetEta } from '../services/router.js';
|
|
3
|
+
import { safeHeaderValue } from './header-value.js';
|
|
4
|
+
import { recordRequest, recordTokens, setCooldown, getActiveCooldownsForKeys, getCooldownDecisionForLimit, getSoonestCooldownExpiry, getPaymentRequiredCooldownMs, getModelForbiddenCooldownMs, learnLimitFromError, } from '../services/ratelimit.js';
|
|
5
|
+
import { isRetryableError, isRateLimitSignal, isKeyAuthError, isClientAbortError, isHedgeAbortError, isDailyQuotaExhaustedError, isPaymentRequiredError, isModelNotFoundError, isModelAccessForbiddenError, isAccountSuspendedError, isProviderBadRequestError, isProviderDegradedError, isProviderLevelError, isContextTooLargeError, isTimeoutErrorText, isStreamTruncatedError, } from './error-classify.js';
|
|
6
|
+
import { sanitizeProviderErrorMessage, summarizeAttemptError } from './error-redaction.js';
|
|
7
|
+
import { benchForTools, clearToolRejections, noteToolRejection, toolCapabilityKey } from './tool-capability.js';
|
|
8
|
+
import { parseProviderReportedSize } from './provider-size-parser.js';
|
|
9
|
+
import { recordRouteTrace } from './route-live.js';
|
|
10
|
+
import { checkKeyHealth, markKeyHealthyFromRequest } from '../services/health.js';
|
|
11
|
+
import { noteModelRetirementSignal } from '../services/model-retirement.js';
|
|
12
|
+
import { getDb, getSetting } from '../db/index.js';
|
|
13
|
+
import { newBreaker, recordBreakerFailure } from './guardrails.js';
|
|
14
|
+
import { getRequestTrace, newRequestTrace, runWithRequestTrace } from './attempt-trace.js';
|
|
15
|
+
import { logRequest, persistRequestAttempts } from './request-log.js';
|
|
16
|
+
import { withKeyProxy } from './proxy.js';
|
|
17
|
+
import { getEndpointTimeBudgetMs } from './ttfb-budget.js';
|
|
18
|
+
import { learnOutputCapFromError, learnedOutputCap } from './output-cap.js';
|
|
19
|
+
// Every surface caps failover hops at the same number.
|
|
20
|
+
export const FALLBACK_MAX_RETRIES = 20;
|
|
21
|
+
// ── Model-level failure benching ─────────────────────────────────────────────
|
|
22
|
+
// A model that keeps failing upstream (401/429/5xx/empty-stream/timeout) must
|
|
23
|
+
// not keep being picked by auto-routing: every dead-end attempt wastes seconds
|
|
24
|
+
// of user-visible latency. The per-key cooldown above benches the *key*; this
|
|
25
|
+
// is the model-level counterpart — a sliding window of failures *across keys*
|
|
26
|
+
// that benches the whole MODEL once the streak is convincing. The window counts
|
|
27
|
+
// across keys, so the bench must span keys too: benching only the key that
|
|
28
|
+
// happened to fail last leaves every sibling key serving the same sick model
|
|
29
|
+
// until each one trips its own streak. Recovery stays automatic: the bench is
|
|
30
|
+
// 'heuristic', the only source the cooldown-probe job may clear early, and it
|
|
31
|
+
// re-validates each benched key once half the bench has been served (the probe
|
|
32
|
+
// exercises the credential, not the model, so a model still sick behind a good
|
|
33
|
+
// key simply re-trips the streak). Client-cancels never reach
|
|
34
|
+
// recordRetryableFailure (the loop returns on isClientAbortError before this
|
|
35
|
+
// bookkeeping), so they don't count.
|
|
36
|
+
export const MODEL_FAILURE_WINDOW_MS = 15 * 60 * 1000; // sliding window: 15 min
|
|
37
|
+
export const MODEL_FAILURE_THRESHOLD = 3; // failures within window
|
|
38
|
+
export const MODEL_FAILURE_COOLDOWN_MS = 10 * 60 * 1000; // bench duration: 10 min
|
|
39
|
+
const modelFailureTimestamps = new Map(); // model_db_id → times
|
|
40
|
+
/** Record one retryable upstream failure for a model. Once the sliding window
|
|
41
|
+
* holds ≥ MODEL_FAILURE_THRESHOLD failures, bench the model on EVERY key that
|
|
42
|
+
* can route to it so the model sinks out of routing until upstream heals, then
|
|
43
|
+
* reset the counter (one bench per streak). */
|
|
44
|
+
function noteModelFailure(route, now) {
|
|
45
|
+
const window = (modelFailureTimestamps.get(route.modelDbId) ?? [])
|
|
46
|
+
.filter(t => now - t < MODEL_FAILURE_WINDOW_MS);
|
|
47
|
+
window.push(now);
|
|
48
|
+
modelFailureTimestamps.set(route.modelDbId, window);
|
|
49
|
+
if (window.length < MODEL_FAILURE_THRESHOLD)
|
|
50
|
+
return;
|
|
51
|
+
// Fall back to the failing key alone when the model's key set can't be read
|
|
52
|
+
// (model row gone, DB unavailable) — a narrower bench beats none.
|
|
53
|
+
const keyIds = routableKeyIdsForModel(route.modelDbId);
|
|
54
|
+
const targets = keyIds.length > 0 ? keyIds : [route.keyId];
|
|
55
|
+
const benchUntil = now + MODEL_FAILURE_COOLDOWN_MS;
|
|
56
|
+
const active = getActiveCooldownsForKeys(targets, now);
|
|
57
|
+
for (const keyId of targets) {
|
|
58
|
+
// Never SHORTEN an existing bench: a provider-stated reset or an escalated
|
|
59
|
+
// ladder step outlasting this window knows more than this heuristic does,
|
|
60
|
+
// and overwriting it would also drop its non-probeable provenance.
|
|
61
|
+
const current = active.get(keyId)
|
|
62
|
+
?.find(c => c.platform === route.platform && c.modelId === route.modelId);
|
|
63
|
+
if (current && current.expiresAtMs >= benchUntil)
|
|
64
|
+
continue;
|
|
65
|
+
setCooldown(route.platform, route.modelId, keyId, MODEL_FAILURE_COOLDOWN_MS, 'heuristic');
|
|
66
|
+
}
|
|
67
|
+
modelFailureTimestamps.delete(route.modelDbId);
|
|
68
|
+
}
|
|
69
|
+
/** A served request is the strongest counter-evidence: clear the failure
|
|
70
|
+
* window so a recovered model is not benched for a stale streak. */
|
|
71
|
+
function clearModelFailure(route) {
|
|
72
|
+
modelFailureTimestamps.delete(route.modelDbId);
|
|
73
|
+
}
|
|
74
|
+
/** Bench one key on every enabled model of its platform it can route to (the
|
|
75
|
+
* route's own model is benched by the caller). For KEY-level verdicts only —
|
|
76
|
+
* an account suspension, an empty balance — never for a model-level one.
|
|
77
|
+
* Also rules the key out on those models for the rest of THIS request via
|
|
78
|
+
* state.skipKeys, so the loop's next hop cannot land on the same account.
|
|
79
|
+
* Never throws: an unreadable catalog leaves the narrower per-route bench in
|
|
80
|
+
* place.
|
|
81
|
+
*/
|
|
82
|
+
function benchKeyAcrossPlatform(route, durationMs, source, state, reason) {
|
|
83
|
+
let modelIds = [];
|
|
84
|
+
try {
|
|
85
|
+
modelIds = getDb().prepare('SELECT model_id FROM models WHERE platform = ? AND enabled = 1 AND (key_id IS NULL OR key_id = ?)').all(route.platform, route.keyId).map(r => r.model_id);
|
|
86
|
+
}
|
|
87
|
+
catch (dbErr) {
|
|
88
|
+
console.warn(`[FallbackLoop] could not list ${route.platform} models to bench key ${route.keyId}: ${dbErr?.message ?? dbErr}`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
let benched = 0;
|
|
92
|
+
for (const modelId of modelIds) {
|
|
93
|
+
if (modelId === route.modelId)
|
|
94
|
+
continue;
|
|
95
|
+
setCooldown(route.platform, modelId, route.keyId, durationMs, source);
|
|
96
|
+
state.skipKeys.add(`${route.platform}:${modelId}:${route.keyId}`);
|
|
97
|
+
benched++;
|
|
98
|
+
}
|
|
99
|
+
console.warn(`[FallbackLoop] ${route.platform} key ${route.keyId} ${reason}; benched it on ${benched + 1} model(s) for ${Math.round(durationMs / 60_000)}min`);
|
|
100
|
+
}
|
|
101
|
+
// ── Wall-clock retry budget ──────────────────────────────────────────────────
|
|
102
|
+
// Serial failover has no time bound of its own: the observed worst case was a
|
|
103
|
+
// 38.8s TTFB over 11 attempts, and the theoretical worst is maxRetries x the
|
|
104
|
+
// per-attempt HTTP timeout. The budget is checked before STARTING each retry
|
|
105
|
+
// and, when abortInFlight is available, while waiting for its first byte.
|
|
106
|
+
// Successful endpoint TTFB history can widen the configured base budget.
|
|
107
|
+
// The first attempt always runs, and so does the FIRST retry: when
|
|
108
|
+
// attempt 0 alone consumes the whole budget (a slow-failing model), refusing
|
|
109
|
+
// attempt 1 would make failover structurally impossible for exactly the
|
|
110
|
+
// requests that need it (#751). The budget stops attempts >= 2 only.
|
|
111
|
+
// 0 disables the budget entirely.
|
|
112
|
+
// Precedence mirrors the response cache: the settings-table value wins when
|
|
113
|
+
// present (runtime-tunable), then the env var, then the default.
|
|
114
|
+
export const DEFAULT_FALLBACK_TIME_BUDGET_MS = 45_000;
|
|
115
|
+
// Share of the wall-clock budget an aborted attempt must have been silent for
|
|
116
|
+
// before the hedge abort counts as provider health rather than bad luck. An
|
|
117
|
+
// attempt late in a ladder inherits only the scraps of the budget its
|
|
118
|
+
// predecessors left, and killing it proves nothing about that route; one that
|
|
119
|
+
// owned most of the window and still sent no first byte is stalled. Kept a
|
|
120
|
+
// fraction rather than a constant because the budget IS the declared patience
|
|
121
|
+
// — raising FALLBACK_TIME_BUDGET_MS should raise the evidence bar with it.
|
|
122
|
+
export const HEDGE_BENCH_MIN_SILENT_FRACTION = 0.75;
|
|
123
|
+
export const FALLBACK_TIME_BUDGET_SETTING = 'fallback_time_budget_ms';
|
|
124
|
+
export function getFallbackTimeBudgetMs() {
|
|
125
|
+
let stored;
|
|
126
|
+
try {
|
|
127
|
+
stored = getSetting(FALLBACK_TIME_BUDGET_SETTING);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
stored = undefined; // DB not ready — never throw on the proxy hot path
|
|
131
|
+
}
|
|
132
|
+
const candidates = [stored, process.env.FALLBACK_TIME_BUDGET_MS];
|
|
133
|
+
for (const raw of candidates) {
|
|
134
|
+
if (raw === undefined || raw.trim() === '')
|
|
135
|
+
continue;
|
|
136
|
+
const n = Number(raw);
|
|
137
|
+
if (Number.isFinite(n) && n >= 0)
|
|
138
|
+
return n;
|
|
139
|
+
}
|
|
140
|
+
return DEFAULT_FALLBACK_TIME_BUDGET_MS;
|
|
141
|
+
}
|
|
142
|
+
/** Total for the next dispatch. Input-only observations still need output space. */
|
|
143
|
+
export function fallbackRoutingTokens(state, estimatedTotal, outputReserve) {
|
|
144
|
+
return Math.max(estimatedTotal, state.observedTotalTokens ?? 0, state.observedInputTokens == null ? 0 : state.observedInputTokens + Math.max(0, outputReserve));
|
|
145
|
+
}
|
|
146
|
+
export function newFallbackState() {
|
|
147
|
+
return { skipKeys: new Set(), skipModels: new Set(), skipPlatforms: new Set(), modelNotFoundPlatforms: new Map() };
|
|
148
|
+
}
|
|
149
|
+
// DISTINCT model_not_found hops on one platform, within one request, before the
|
|
150
|
+
// whole platform is ruled out: a healthy catalog never misses three different
|
|
151
|
+
// models in a single failover chain — a stale/broken one does it routinely
|
|
152
|
+
// (NavyAI: 24 models tried in one session, 0 ok, #1218). 3 keeps one fluke
|
|
153
|
+
// removal from condemning a provider.
|
|
154
|
+
export const MODEL_NOT_FOUND_PLATFORM_LIMIT = 3;
|
|
155
|
+
// Milliseconds until the next UTC midnight — when most providers' daily free
|
|
156
|
+
// allocations reset. Floored at one minute so a hit seconds before midnight
|
|
157
|
+
// still records a real bench instead of a no-op.
|
|
158
|
+
export function msUntilNextUtcMidnight(now = Date.now()) {
|
|
159
|
+
const d = new Date(now);
|
|
160
|
+
const next = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1);
|
|
161
|
+
return Math.max(next - now, 60_000);
|
|
162
|
+
}
|
|
163
|
+
const pacificDate = new Intl.DateTimeFormat('en-US', {
|
|
164
|
+
timeZone: 'America/Los_Angeles', year: 'numeric', month: 'numeric', day: 'numeric',
|
|
165
|
+
});
|
|
166
|
+
const pacificHour = new Intl.DateTimeFormat('en-US', {
|
|
167
|
+
timeZone: 'America/Los_Angeles', hour: 'numeric', hourCycle: 'h23',
|
|
168
|
+
});
|
|
169
|
+
/** Gemini daily quotas reset at midnight Pacific, including DST transitions.
|
|
170
|
+
* https://ai.google.dev/gemini-api/docs/rate-limits */
|
|
171
|
+
export function msUntilNextPacificMidnight(now = Date.now()) {
|
|
172
|
+
const parts = pacificDate.formatToParts(now);
|
|
173
|
+
const part = (name) => Number(parts.find(p => p.type === name).value);
|
|
174
|
+
// 08:00 UTC on the next Pacific date is either midnight (PST) or 01:00
|
|
175
|
+
// (PDT). Read the offset on that date, not now: DST days have 23/25 hours.
|
|
176
|
+
const candidate = Date.UTC(part('year'), part('month') - 1, part('day') + 1, 8);
|
|
177
|
+
const midnight = candidate - Number(pacificHour.format(candidate)) * 3_600_000;
|
|
178
|
+
return Math.max(midnight - now, 60_000);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The one true cooldown-duration selection after a retryable upstream failure:
|
|
182
|
+
* - 402 out-of-credits → a full day (PAYMENT_REQUIRED_COOLDOWN_MS)
|
|
183
|
+
* - 403 model-not-on-tier → a full day (MODEL_FORBIDDEN_COOLDOWN_MS), because a
|
|
184
|
+
* tier/subscription gate won't clear on the next minute window (issue #256).
|
|
185
|
+
* Both honour the operator's cooldown ceiling (#952): relay endpoints emit
|
|
186
|
+
* these for transient reasons, and a day is then far too long.
|
|
187
|
+
* - a 429 that says the DAILY free allocation is spent (Cloudflare "used up
|
|
188
|
+
* your daily free allocation of 10,000 neurons") → benched until the next
|
|
189
|
+
* UTC midnight, like the 402 path. The old transient 90s cooldown made the
|
|
190
|
+
* router re-pick a dead-for-the-day provider all day long. An explicit
|
|
191
|
+
* provider Retry-After wins over the midnight heuristic: rolling daily
|
|
192
|
+
* windows (Groq RPD "try again in 7m12s" with a Retry-After header) reset
|
|
193
|
+
* well before midnight, and the provider knows its own reset time best.
|
|
194
|
+
* Gemini daily violations instead wait until midnight Pacific; a shorter
|
|
195
|
+
* RetryInfo can refer to a simultaneous per-minute violation.
|
|
196
|
+
* - anything else → the transient/daily escalation ladder, honoring the
|
|
197
|
+
* provider's Retry-After as a floor (getCooldownDurationForLimit).
|
|
198
|
+
*/
|
|
199
|
+
export function cooldownForError(route, err) {
|
|
200
|
+
return cooldownDecisionForError(route, err).durationMs;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* cooldownForError plus the provenance tag the cooldown-probe recovery job
|
|
204
|
+
* keys off (see CooldownSource in services/ratelimit.ts): 402 → 'credit' and
|
|
205
|
+
* 403 → 'tier' (a key-validation probe passing proves nothing about credits or
|
|
206
|
+
* tier, so those are never probed); a daily-quota bench → 'authoritative' (the
|
|
207
|
+
* expiry is the provider's own reset time, whether from Retry-After or the
|
|
208
|
+
* UTC-midnight convention — a fact, not a guess); everything else defers to
|
|
209
|
+
* getCooldownDecisionForLimit, which tags 'authoritative' only when an explicit
|
|
210
|
+
* Retry-After actually determined the expiry.
|
|
211
|
+
*/
|
|
212
|
+
export function cooldownDecisionForError(route, err) {
|
|
213
|
+
if (isPaymentRequiredError(err))
|
|
214
|
+
return { durationMs: getPaymentRequiredCooldownMs(), source: 'credit' };
|
|
215
|
+
// Before the model-forbidden check: a suspended account is a 403 too, but
|
|
216
|
+
// it is the KEY that is out, for as long as an empty balance would be — and
|
|
217
|
+
// under the same operator ceiling (#952).
|
|
218
|
+
if (isAccountSuspendedError(err))
|
|
219
|
+
return { durationMs: getPaymentRequiredCooldownMs(), source: 'credit' };
|
|
220
|
+
if (isModelAccessForbiddenError(err))
|
|
221
|
+
return { durationMs: getModelForbiddenCooldownMs(), source: 'tier' };
|
|
222
|
+
if (isDailyQuotaExhaustedError(err)) {
|
|
223
|
+
if (route.platform === 'google') {
|
|
224
|
+
// RetryInfo can describe a simultaneous per-minute violation. It cannot
|
|
225
|
+
// reopen a daily allowance before the Pacific reset (#1339).
|
|
226
|
+
return { durationMs: Math.max(msUntilNextPacificMidnight(), err?.retryAfterMs ?? 0), source: 'authoritative' };
|
|
227
|
+
}
|
|
228
|
+
return { durationMs: err?.retryAfterMs ?? msUntilNextUtcMidnight(), source: 'authoritative' };
|
|
229
|
+
}
|
|
230
|
+
return getCooldownDecisionForLimit(route.platform, route.modelId, route.keyId, { rpd: route.rpdLimit, tpd: route.tpdLimit }, err?.retryAfterMs,
|
|
231
|
+
// Only a real 429/rate-limit error may feed the null-limits exhaustion
|
|
232
|
+
// heuristic; a timeout or 5xx is retryable but says nothing about quota,
|
|
233
|
+
// so it stays on the short transient bench instead of the ladder (#592).
|
|
234
|
+
{ quotaSignal: isRateLimitSignal(err) });
|
|
235
|
+
}
|
|
236
|
+
// ── Truncated-stream streak (#1218) ──────────────────────────────────────────
|
|
237
|
+
// A stream that answers 200, sends partial SSE, then dies without [DONE] /
|
|
238
|
+
// finish_reason ("stream ended unexpectedly") is retried normally — one
|
|
239
|
+
// truncation is common on flaky free gateways. But the same route producing
|
|
240
|
+
// them back-to-back is a sick edge: bench it briefly so the ladder stops
|
|
241
|
+
// re-paying the round trip. Success on the route resets the streak.
|
|
242
|
+
export const TRUNCATION_STREAK_LIMIT = 3;
|
|
243
|
+
// 5 min vs the ordinary 90s transient bench: the streak must add REAL distance
|
|
244
|
+
// over the default or it changes nothing — the escalation ladder already gives
|
|
245
|
+
// a single truncation 90s.
|
|
246
|
+
export const TRUNCATION_BENCH_MS = 5 * 60 * 1000;
|
|
247
|
+
const truncationStreaks = new Map(); // "platform:modelId:keyId"
|
|
248
|
+
export function resetTruncationStreaks() {
|
|
249
|
+
truncationStreaks.clear();
|
|
250
|
+
}
|
|
251
|
+
// ── Empty-completion streak (issue #751) ─────────────────────────────────────
|
|
252
|
+
// The skipBench exemption below assumes an empty 'length' completion is
|
|
253
|
+
// REQUEST behavior (this turn's max_tokens spent on hidden reasoning). A
|
|
254
|
+
// model+key that produces them consecutively is broken, not unlucky — with an
|
|
255
|
+
// unconditional exemption it stayed penalty-free forever while failing every
|
|
256
|
+
// request routed to it. At the streak limit the exemption lifts and the
|
|
257
|
+
// failure takes the normal cooldown/penalty/limit-learning path (and counts
|
|
258
|
+
// toward the breaker). Format violations (skipModelForRequest) never accrue:
|
|
259
|
+
// they are model behavior for THIS request's response_format, not key health.
|
|
260
|
+
export const EMPTY_COMPLETION_STREAK_LIMIT = 3;
|
|
261
|
+
const emptyCompletionStreaks = new Map(); // "platform:modelId:keyId"
|
|
262
|
+
export function resetEmptyCompletionStreaks() {
|
|
263
|
+
emptyCompletionStreaks.clear();
|
|
264
|
+
}
|
|
265
|
+
/** Drop every model's sliding failure window. Module state outlives a test
|
|
266
|
+
* case, so without this a case inherits the previous one's failure counts and
|
|
267
|
+
* trips the threshold early — clearing `rate_limit_cooldowns` alone does not
|
|
268
|
+
* reach it. */
|
|
269
|
+
export function resetModelFailureWindows() {
|
|
270
|
+
modelFailureTimestamps.clear();
|
|
271
|
+
}
|
|
272
|
+
/** Operator clear (#952): drop every model's failure window and report how many
|
|
273
|
+
* models were mid-streak. The benches those streaks produced are ordinary
|
|
274
|
+
* cooldown rows and go with clearAllCooldowns. */
|
|
275
|
+
export function clearModelFailureWindows() {
|
|
276
|
+
const count = modelFailureTimestamps.size;
|
|
277
|
+
modelFailureTimestamps.clear();
|
|
278
|
+
return count;
|
|
279
|
+
}
|
|
280
|
+
// Advance (or break) the streak for this failure and report whether the
|
|
281
|
+
// skipBench exemption still holds. Called exactly once per retryable failure,
|
|
282
|
+
// from recordRetryableFailure; the loop reuses its returned decision for the
|
|
283
|
+
// breaker so the two consumers can never disagree.
|
|
284
|
+
function consumeSkipBenchExemption(route, err) {
|
|
285
|
+
const key = `${route.platform}:${route.modelId}:${route.keyId}`;
|
|
286
|
+
if (err?.skipBench !== true) {
|
|
287
|
+
// A normally-penalized failure breaks the streak: the cooldown ladder is
|
|
288
|
+
// already handling whatever is wrong with this model+key.
|
|
289
|
+
emptyCompletionStreaks.delete(key);
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
if (err?.skipModelForRequest === true)
|
|
293
|
+
return true;
|
|
294
|
+
const streak = (emptyCompletionStreaks.get(key) ?? 0) + 1;
|
|
295
|
+
emptyCompletionStreaks.set(key, streak);
|
|
296
|
+
return streak < EMPTY_COMPLETION_STREAK_LIMIT;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Apply the full per-key failure bookkeeping shared by every surface after a
|
|
300
|
+
* retryable failure:
|
|
301
|
+
* - rule out the WHOLE model for the rest of the request on a 404 (removed
|
|
302
|
+
* upstream) or 403 (off this key's tier) — a sibling key would fail it the
|
|
303
|
+
* same way (PR #111 / issue #256);
|
|
304
|
+
* - bench this model+key via cooldownForError;
|
|
305
|
+
* - demote the model in the scorer ONLY when the failure exhausted it — i.e.
|
|
306
|
+
* no sibling key can still serve it (#454 gate). skipKeys already contains
|
|
307
|
+
* the just-failed key here, preserving #479's "count budget across keys"
|
|
308
|
+
* semantics: hasOtherUsableKey excludes both the failed key and skipKeys;
|
|
309
|
+
* - learn a provider-reported ceiling (e.g. a Groq 413 "TPM: Limit 30000")
|
|
310
|
+
* from the error body so the next pre-check fails over before the 413.
|
|
311
|
+
*
|
|
312
|
+
* Reasoning-truncation exemption: an error thrown with `skipBench: true` (a
|
|
313
|
+
* reasoning model that spent the whole max_tokens budget on hidden reasoning,
|
|
314
|
+
* finish_reason 'length') still fails over — the key is skipped for THIS
|
|
315
|
+
* request — but is NOT a provider-health signal, so no cooldown, no model
|
|
316
|
+
* penalty, and no limit-learning are recorded. Benching those was costing
|
|
317
|
+
* healthy models a 90s cooldown + a scorer penalty per truncated turn. The
|
|
318
|
+
* exemption is streak-bounded (#751): from the EMPTY_COMPLETION_STREAK_LIMITth
|
|
319
|
+
* consecutive empty completion on the same model+key it stops applying, until
|
|
320
|
+
* a success (or a normally-penalized failure) resets the streak.
|
|
321
|
+
*
|
|
322
|
+
* Returns whether the skipBench exemption held for this failure, so the loop
|
|
323
|
+
* can keep the breaker in lockstep with the bench decision.
|
|
324
|
+
*
|
|
325
|
+
* Callers add the just-failed key to skipKeys via this function (do not pre-add).
|
|
326
|
+
*/
|
|
327
|
+
export function recordRetryableFailure(route, err, state, now = Date.now()) {
|
|
328
|
+
// `skipModelForRequest: true` = the failure is MODEL behavior, not key
|
|
329
|
+
// state (ignored response_format, JSON truncated at max_tokens): a sibling
|
|
330
|
+
// key would reproduce it exactly, so rule out the whole model for this
|
|
331
|
+
// request instead of burning one failover hop per key.
|
|
332
|
+
// Context-too-large is MODEL-level too: a sibling key serves the same model
|
|
333
|
+
// with the same context window (and, for Groq-style per-key TPM 413s, the
|
|
334
|
+
// same tier ceiling), so it would reject the same request identically.
|
|
335
|
+
if (isModelNotFoundError(err) || isModelAccessForbiddenError(err) || isContextTooLargeError(err) || err?.skipModelForRequest === true) {
|
|
336
|
+
state.skipModels.add(route.modelDbId);
|
|
337
|
+
}
|
|
338
|
+
// A stale CATALOG (not a single dead model) answers model-not-found on every
|
|
339
|
+
// sibling model in a row: NavyAI served 24 models over one session, 0 ok,
|
|
340
|
+
// each re-paying a 2.5–10s round trip (#1218). The per-model skip above
|
|
341
|
+
// can't see the pattern — each miss is a different model. Count DISTINCT
|
|
342
|
+
// model_not_found hops per platform within one request; from the threshold,
|
|
343
|
+
// rule the whole platform out for the rest of the request: the catalog (or
|
|
344
|
+
// the key's access to it) is broken, and the next PROVIDER is the better
|
|
345
|
+
// hop. Same request-scoped lifetime as skipModels (#111/#256 semantics).
|
|
346
|
+
// Custom relays are exempt: every relay shares the one platform id 'custom'
|
|
347
|
+
// (#651), so three misses spread over three different relays would rule out
|
|
348
|
+
// every healthy relay too. Their misses stay per-model.
|
|
349
|
+
if (isModelNotFoundError(err) && !route.endpointScope) {
|
|
350
|
+
const seen = state.modelNotFoundPlatforms.get(route.platform) ?? new Set();
|
|
351
|
+
seen.add(route.modelDbId);
|
|
352
|
+
state.modelNotFoundPlatforms.set(route.platform, seen);
|
|
353
|
+
if (seen.size >= MODEL_NOT_FOUND_PLATFORM_LIMIT) {
|
|
354
|
+
state.skipPlatforms.add(route.platform);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// A model-level 404/410 that says the model is GONE (not merely missing right
|
|
358
|
+
// now) outlives this request: persist it once the evidence is strong enough,
|
|
359
|
+
// or the retired model burns a fallback slot on every request forever (#634).
|
|
360
|
+
// The trace object identifies the request, so one request's failover across
|
|
361
|
+
// sibling keys counts as the single observation it is.
|
|
362
|
+
noteModelRetirementSignal(route, err, getRequestTrace());
|
|
363
|
+
// Provider-reported request size (#507): if the just-failed attempt was a
|
|
364
|
+
// size-related rejection that names the request's real token count, latch it
|
|
365
|
+
// onto the request state. The route() closure on the next attempt picks it
|
|
366
|
+
// up and feeds it as the routing estimate, so the existing tpm_limit /
|
|
367
|
+
// context_window gates in router.ts skip low-ceiling models on retry
|
|
368
|
+
// instead of letting them fire and 413 again. Only fires on a size-class
|
|
369
|
+
// error — the parser is conservative and returns null for opaque bodies
|
|
370
|
+
// (every non-supported platform, the bare "Request Entity Too Large"
|
|
371
|
+
// Groq shape, and the github limit-only case), so behavior is unchanged
|
|
372
|
+
// when there's nothing to latch. Max wins so a smaller later reading of
|
|
373
|
+
// the same request can't drop the estimate.
|
|
374
|
+
if (isContextTooLargeError(err) || isProviderBadRequestError(err)) {
|
|
375
|
+
const reported = parseProviderReportedSize(route.platform, err?.message);
|
|
376
|
+
if (reported != null) {
|
|
377
|
+
const field = reported.kind === 'input' ? 'observedInputTokens' : 'observedTotalTokens';
|
|
378
|
+
state[field] = Math.max(state[field] ?? 0, reported.tokens);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
state.skipKeys.add(`${route.platform}:${route.modelId}:${route.keyId}`);
|
|
382
|
+
// #788: provider-level failures (5xx / timeout / transport / degraded) mean
|
|
383
|
+
// the PROVIDER is sick, not this key — every key AND every model of that
|
|
384
|
+
// platform would fail identically. Rule out the whole platform for this
|
|
385
|
+
// request so the loop moves to the NEXT provider instead of burning one
|
|
386
|
+
// failover hop per key. Key-scoped failures (auth/quota) stay on the
|
|
387
|
+
// single-key path, and the per-key cooldown below is still the only thing
|
|
388
|
+
// that outlives the request.
|
|
389
|
+
if (isProviderLevelError(err)) {
|
|
390
|
+
state.skipPlatforms.add(route.platform);
|
|
391
|
+
}
|
|
392
|
+
// Too big for this model is a fact about the REQUEST, not the model's
|
|
393
|
+
// health: it still serves every smaller request. Skip it for this request
|
|
394
|
+
// (above) and learn the reported ceiling so the router sizes it out next
|
|
395
|
+
// time, but no cooldown and no penalty. Benching it let one oversized agent
|
|
396
|
+
// turn (Claude Code ships ~16k tokens of tool schemas) sink every small-TPM
|
|
397
|
+
// model it touched, so later ordinary requests found the pool rate limited.
|
|
398
|
+
if (isContextTooLargeError(err)) {
|
|
399
|
+
learnLimitFromError(route.modelDbId, err);
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
// A max_tokens above this model's output ceiling (Claude Code asks for
|
|
403
|
+
// 128000; Groq gpt-oss and Ollama Cloud's Nemotron stop at 65536) is the
|
|
404
|
+
// request's shape, not the model's health and not missing tool support, so
|
|
405
|
+
// it stays off the cooldown, penalty and tool-rejection books. A ceiling
|
|
406
|
+
// learned just now makes the same route usable again at once (the next
|
|
407
|
+
// attempt is clamped to it); one that was already applied and still got
|
|
408
|
+
// rejected rules the model out for this request.
|
|
409
|
+
const capBefore = learnedOutputCap(route.platform, route.modelId);
|
|
410
|
+
const ceiling = learnOutputCapFromError(route, err);
|
|
411
|
+
if (ceiling != null) {
|
|
412
|
+
if (capBefore == null || ceiling < capBefore) {
|
|
413
|
+
state.skipKeys.delete(`${route.platform}:${route.modelId}:${route.keyId}`);
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
state.skipModels.add(route.modelDbId);
|
|
417
|
+
}
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
if (consumeSkipBenchExemption(route, err))
|
|
421
|
+
return true;
|
|
422
|
+
const decision = cooldownDecisionForError(route, err);
|
|
423
|
+
setCooldown(route.platform, route.modelId, route.keyId, decision.durationMs, decision.source);
|
|
424
|
+
// A truncated stream (200 + partial SSE, no [DONE]/finish_reason) on the
|
|
425
|
+
// SAME route repeatedly is a sick edge, not bad luck — but a single one is
|
|
426
|
+
// common enough on flaky free gateways that benching on first sight would
|
|
427
|
+
// over-fire. Streak-bounded like empty completions (#751): N truncations in
|
|
428
|
+
// a row on this platform+model+key bench the route for one cooldown window;
|
|
429
|
+
// a success resets the streak (recordUpstreamSuccess).
|
|
430
|
+
if (isStreamTruncatedError(err)) {
|
|
431
|
+
const tk = `${route.platform}:${route.modelId}:${route.keyId}`;
|
|
432
|
+
const streak = (truncationStreaks.get(tk) ?? 0) + 1;
|
|
433
|
+
if (streak >= TRUNCATION_STREAK_LIMIT) {
|
|
434
|
+
truncationStreaks.delete(tk);
|
|
435
|
+
setCooldown(route.platform, route.modelId, route.keyId, TRUNCATION_BENCH_MS, 'heuristic');
|
|
436
|
+
console.warn(`[FallbackLoop] ${route.platform} ${route.modelId} key ${route.keyId}: ${streak} consecutive truncated streams — benching the route for ${Math.round(TRUNCATION_BENCH_MS / 1000)}s`);
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
truncationStreaks.set(tk, streak);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
// Any other failure class on this route breaks the truncation streak: the
|
|
444
|
+
// cooldown ladder is already handling whatever that is.
|
|
445
|
+
truncationStreaks.delete(`${route.platform}:${route.modelId}:${route.keyId}`);
|
|
446
|
+
}
|
|
447
|
+
// A 400 "bad request" answer to a request that carries `tools` is how a
|
|
448
|
+
// model that does not really support tool calling shows up (#1230). The 90s
|
|
449
|
+
// transient cooldown above forgets it, so remember it per model+endpoint;
|
|
450
|
+
// after a few distinct requests the router tries that model last for tool
|
|
451
|
+
// requests (lib/tool-capability.ts). Only the final classification counts:
|
|
452
|
+
// context-too-large, model-not-found and degraded 400s are other problems.
|
|
453
|
+
if (state.wantsTools === true && classifyAttemptError(err) === 'provider_bad_request') {
|
|
454
|
+
const tk = toolCapabilityKey(route.platform, route.modelId, route.endpointScope);
|
|
455
|
+
const seen = (state.toolRejects ??= new Set());
|
|
456
|
+
if (!seen.has(tk)) {
|
|
457
|
+
seen.add(tk);
|
|
458
|
+
noteToolRejection(tk, now);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// A suspended ACCOUNT fails every model behind the key identically. The
|
|
462
|
+
// per-route cooldown above benches only the model that happened to be
|
|
463
|
+
// tried, so the next request picks the platform's next model and pays the
|
|
464
|
+
// same round trip again — on a platform with a large catalog that is the
|
|
465
|
+
// whole failover budget, every request, until each model has tripped
|
|
466
|
+
// separately. Bench the key across the platform's catalog in one go, and
|
|
467
|
+
// rule the platform out for the rest of this request: a sibling key would
|
|
468
|
+
// be a different account, but with the budget already spent on this one
|
|
469
|
+
// the next PROVIDER is the better hop.
|
|
470
|
+
if (isAccountSuspendedError(err)) {
|
|
471
|
+
state.skipPlatforms.add(route.platform);
|
|
472
|
+
benchKeyAcrossPlatform(route, decision.durationMs, decision.source, state, 'reports the account suspended');
|
|
473
|
+
}
|
|
474
|
+
else if (isPaymentRequiredError(err)) {
|
|
475
|
+
// A 402 is the ACCOUNT's balance, not one model's (#1239: one broke
|
|
476
|
+
// HuggingFace key answered three different models with 402 in a single
|
|
477
|
+
// request, three hops spent rediscovering the same empty wallet). Bench
|
|
478
|
+
// the key on every model of the platform for the credit window. Unlike a
|
|
479
|
+
// suspension the platform itself stays in play: a sibling key is a
|
|
480
|
+
// different wallet and may well have credits, so only THIS key is out.
|
|
481
|
+
benchKeyAcrossPlatform(route, decision.durationMs, decision.source, state, 'is out of credits (402)');
|
|
482
|
+
}
|
|
483
|
+
// Model-level failure benching: a model failing across keys (or repeatedly on
|
|
484
|
+
// one key) must sink out of routing instead of being re-picked every request.
|
|
485
|
+
noteModelFailure(route, now);
|
|
486
|
+
// Model-level penalty only when no sibling key can still serve (#454).
|
|
487
|
+
if (!hasOtherUsableKey(route.modelDbId, route.keyId, state.skipKeys)) {
|
|
488
|
+
// Hard limit signals (429/402) carry the heavier demotion; ordinary
|
|
489
|
+
// upstream failures (5xx/timeout/empty stream) get a lighter one so the
|
|
490
|
+
// model sinks gradually instead of being banished on a single blip.
|
|
491
|
+
if (isRateLimitSignal(err)) {
|
|
492
|
+
recordRateLimitHit(route.modelDbId);
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
recordModelFailure(route.modelDbId);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
learnLimitFromError(route.modelDbId, err);
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
// ── Upstream 401 handling (key-fatal, not request-fatal) ─────────────────────
|
|
502
|
+
// A 401 means THIS key is bad, not this model or this request. The old behavior
|
|
503
|
+
// (non-retryable → 502) stranded the provider's healthy sibling key and every
|
|
504
|
+
// other provider in the chain, and left the bad key in rotation failing traffic
|
|
505
|
+
// until the next 5-minute health cycle. Now the loop skips the key, benches the
|
|
506
|
+
// model+key long enough to cover the health cycle, and kicks an immediate
|
|
507
|
+
// targeted revalidation so a confirmed-bad key flips to status 'invalid' (and
|
|
508
|
+
// out of routing) within seconds instead of minutes.
|
|
509
|
+
export const AUTH_FAILURE_COOLDOWN_MS = 5 * 60 * 1000;
|
|
510
|
+
// Dedupe window for the fire-and-forget revalidation: many concurrent requests
|
|
511
|
+
// hitting the same bad key must not stampede the provider's validate endpoint.
|
|
512
|
+
const REVALIDATION_DEDUPE_MS = 30_000;
|
|
513
|
+
const lastRevalidation = new Map();
|
|
514
|
+
function triggerKeyRevalidation(platform, keyId) {
|
|
515
|
+
const now = Date.now();
|
|
516
|
+
const last = lastRevalidation.get(keyId) ?? 0;
|
|
517
|
+
if (now - last < REVALIDATION_DEDUPE_MS)
|
|
518
|
+
return;
|
|
519
|
+
lastRevalidation.set(keyId, now);
|
|
520
|
+
console.warn(`[FallbackLoop] Upstream 401 from ${platform} key ${keyId}; revalidating it now instead of waiting for the health cycle`);
|
|
521
|
+
void checkKeyHealth(keyId).catch(err => {
|
|
522
|
+
console.error(`[FallbackLoop] Immediate revalidation of key ${keyId} failed:`, err?.message);
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Bookkeeping for an auth-fatal (401 / invalid key) attempt: skip the key for
|
|
527
|
+
* this request, bench the model+key for the health-cycle window, and start an
|
|
528
|
+
* immediate revalidation. Deliberately NO model penalty and NO limit-learning —
|
|
529
|
+
* a bad key says nothing about the model's health.
|
|
530
|
+
*/
|
|
531
|
+
export function recordAuthFailure(route, state) {
|
|
532
|
+
state.skipKeys.add(`${route.platform}:${route.modelId}:${route.keyId}`);
|
|
533
|
+
setCooldown(route.platform, route.modelId, route.keyId, AUTH_FAILURE_COOLDOWN_MS);
|
|
534
|
+
triggerKeyRevalidation(route.platform, route.keyId);
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* The success-side accounting every surface runs after a completed attempt:
|
|
538
|
+
* count the request + its tokens against the model+key's rate-limit windows and
|
|
539
|
+
* clear the model's 429 penalty. `rateLimitTokens` is whatever the surface metered
|
|
540
|
+
* (the provider's usage.total_tokens for non-stream, an estimate for stream).
|
|
541
|
+
*/
|
|
542
|
+
export function recordUpstreamSuccess(route, rateLimitTokens, state) {
|
|
543
|
+
// A tool-carrying request that was served proves two things (#1230): this
|
|
544
|
+
// model does handle tools, and the request itself was well-formed, so the
|
|
545
|
+
// models that answered it with a 400 earlier in this chain are the ones at
|
|
546
|
+
// fault. Defer those for tool requests without waiting for more evidence.
|
|
547
|
+
if (state?.wantsTools === true) {
|
|
548
|
+
const winner = toolCapabilityKey(route.platform, route.modelId, route.endpointScope);
|
|
549
|
+
clearToolRejections(winner);
|
|
550
|
+
for (const tk of state.toolRejects ?? [])
|
|
551
|
+
if (tk !== winner)
|
|
552
|
+
benchForTools(tk);
|
|
553
|
+
}
|
|
554
|
+
recordRequest(route.platform, route.modelId, route.keyId);
|
|
555
|
+
recordTokens(route.platform, route.modelId, route.keyId, rateLimitTokens);
|
|
556
|
+
recordSuccess(route.modelDbId);
|
|
557
|
+
// A served request proves the model+key can complete: the empty-completion
|
|
558
|
+
// streak (#751) starts over.
|
|
559
|
+
emptyCompletionStreaks.delete(`${route.platform}:${route.modelId}:${route.keyId}`);
|
|
560
|
+
// A served request is the strongest evidence the route's edge is alive:
|
|
561
|
+
// the truncated-stream streak (#1218) starts over too.
|
|
562
|
+
truncationStreaks.delete(`${route.platform}:${route.modelId}:${route.keyId}`);
|
|
563
|
+
// A served request is the strongest possible evidence the model works, so
|
|
564
|
+
// clear any model-level failure streak that could bench it later.
|
|
565
|
+
clearModelFailure(route);
|
|
566
|
+
// A served request is the strongest possible evidence the key works, so clear
|
|
567
|
+
// any stale 'error' status left by an earlier transport blip instead of waiting
|
|
568
|
+
// for the next health pass to make the key routable again.
|
|
569
|
+
markKeyHealthyFromRequest(route.keyId);
|
|
570
|
+
}
|
|
571
|
+
export function classifyAttemptError(err) {
|
|
572
|
+
if (isKeyAuthError(err))
|
|
573
|
+
return 'auth';
|
|
574
|
+
if (isPaymentRequiredError(err))
|
|
575
|
+
return 'out_of_credits';
|
|
576
|
+
if (isDailyQuotaExhaustedError(err))
|
|
577
|
+
return 'daily_quota_exhausted';
|
|
578
|
+
if (isModelNotFoundError(err))
|
|
579
|
+
return 'model_not_found';
|
|
580
|
+
if (isModelAccessForbiddenError(err))
|
|
581
|
+
return 'forbidden';
|
|
582
|
+
// Before the bad-request check: OpenAI-compat context errors arrive as
|
|
583
|
+
// "API error 400: This model's maximum context length is …", which the
|
|
584
|
+
// generic provider_bad_request rule would otherwise swallow.
|
|
585
|
+
if (isContextTooLargeError(err))
|
|
586
|
+
return 'context_too_large';
|
|
587
|
+
// A DEGRADED-function 400 (NVIDIA NIM, #522) is provider health, not request
|
|
588
|
+
// shape — keep it out of provider_bad_request so the trail reads honestly.
|
|
589
|
+
if (isProviderDegradedError(err))
|
|
590
|
+
return 'upstream_error';
|
|
591
|
+
if (isProviderBadRequestError(err))
|
|
592
|
+
return 'provider_bad_request';
|
|
593
|
+
const msg = (err?.message ?? '').toLowerCase();
|
|
594
|
+
if (msg.includes('empty completion'))
|
|
595
|
+
return 'empty_completion';
|
|
596
|
+
if (msg.includes('ignored response_format') || msg.includes('truncated json'))
|
|
597
|
+
return 'format_ignored';
|
|
598
|
+
// Before the generic classes so the trail names the real cause rather than
|
|
599
|
+
// booking a schema violation as a bare 'error'.
|
|
600
|
+
if (msg.includes('invalid tool arguments'))
|
|
601
|
+
return 'invalid_tool_arguments';
|
|
602
|
+
if (isTimeoutErrorText(msg))
|
|
603
|
+
return 'timeout';
|
|
604
|
+
if (msg.includes('429') || msg.includes('rate limit') || msg.includes('too many requests') || msg.includes('quota'))
|
|
605
|
+
return 'rate_limited';
|
|
606
|
+
const status = typeof err?.status === 'number' ? err.status : 0;
|
|
607
|
+
if (status >= 500 || msg.includes('500') || msg.includes('502') || msg.includes('503') || msg.includes('unavailable') || msg.includes('internal server error'))
|
|
608
|
+
return 'upstream_error';
|
|
609
|
+
return 'error';
|
|
610
|
+
}
|
|
611
|
+
const TRAIL_MAX_SHOWN = 10;
|
|
612
|
+
const TRAIL_HEADER_MAX_LENGTH = 1024;
|
|
613
|
+
// ── Detailed failover trace header (opt-in) ──────────────────────────────────
|
|
614
|
+
// X-Fallback-Trail already tells a caller WHICH hops burned and WHY, but not
|
|
615
|
+
// how long they cost. That is the part an agent cannot reconstruct: a request
|
|
616
|
+
// answered in 40s reads identically whether one provider stalled for 39s or
|
|
617
|
+
// four failed fast. The per-hop timings and the redacted provider message are
|
|
618
|
+
// already collected for the request_attempts table; this exposes them on the
|
|
619
|
+
// response so the caller sees them without a dashboard round trip.
|
|
620
|
+
//
|
|
621
|
+
// Off by default. It widens what an already-loopback-ish surface reveals —
|
|
622
|
+
// hop timings plus provider error text — so it is opt-in like the discovery
|
|
623
|
+
// aliases, not something a default install starts emitting. Precedence matches
|
|
624
|
+
// the failover budget above: settings-table value, then env var, then off.
|
|
625
|
+
export const EXPOSE_FALLBACK_DETAIL_SETTING = 'expose_fallback_detail_header';
|
|
626
|
+
// Ten hops of "platform/model keyN=class t=…+…ms msg=…" with a capped message
|
|
627
|
+
// each. Kept well under the 8 KB total header budget that proxies commonly
|
|
628
|
+
// enforce, since this rides alongside X-Fallback-Trail's own 1 KB.
|
|
629
|
+
const DETAIL_HEADER_MAX_LENGTH = 2048;
|
|
630
|
+
// summarizeAttemptError caps at 200; the header wants a tighter budget so ten
|
|
631
|
+
// hops still fit. The full text remains in request_attempts either way.
|
|
632
|
+
const DETAIL_SUMMARY_MAX_LENGTH = 120;
|
|
633
|
+
export function isFallbackDetailHeaderEnabled() {
|
|
634
|
+
let stored;
|
|
635
|
+
try {
|
|
636
|
+
stored = getSetting(EXPOSE_FALLBACK_DETAIL_SETTING);
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
stored = undefined; // DB not ready — never throw on the proxy hot path
|
|
640
|
+
}
|
|
641
|
+
for (const raw of [stored, process.env.FALLBACK_DETAIL_HEADER]) {
|
|
642
|
+
if (raw === undefined || raw.trim() === '')
|
|
643
|
+
continue;
|
|
644
|
+
const value = raw.trim().toLowerCase();
|
|
645
|
+
return value === '1' || value === 'true';
|
|
646
|
+
}
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* One `platform/model keyN=outcome t=<start>+<duration>ms msg=<summary>` segment
|
|
651
|
+
* per hop, `; `-joined — the same leading shape as X-Fallback-Trail so the two
|
|
652
|
+
* headers line up when read together.
|
|
653
|
+
*
|
|
654
|
+
* The summary is the already-redacted `errorSummary`, never `err.message`. Its
|
|
655
|
+
* semicolons become commas because `; ` is the record separator; nothing else
|
|
656
|
+
* is escaped, which keeps the value readable, and `safeHeaderValue` handles any
|
|
657
|
+
* non-ASCII on the way out.
|
|
658
|
+
*/
|
|
659
|
+
export function formatAttemptDetail(records) {
|
|
660
|
+
const shown = records.slice(0, TRAIL_MAX_SHOWN).map(r => {
|
|
661
|
+
const parts = [
|
|
662
|
+
`${r.platform}/${r.modelId}`,
|
|
663
|
+
`key${r.keyOrdinal}=${r.outcome}`,
|
|
664
|
+
`t=${r.startOffsetMs}+${r.durationMs}ms`,
|
|
665
|
+
];
|
|
666
|
+
if (r.errorSummary) {
|
|
667
|
+
parts.push(`msg=${r.errorSummary.slice(0, DETAIL_SUMMARY_MAX_LENGTH).replace(/;/g, ',')}`);
|
|
668
|
+
}
|
|
669
|
+
return parts.join(' ');
|
|
670
|
+
});
|
|
671
|
+
const extra = records.length - shown.length;
|
|
672
|
+
return shown.join('; ') + (extra > 0 ? `; +${extra} more` : '');
|
|
673
|
+
}
|
|
674
|
+
export function formatAttemptTrail(attempts) {
|
|
675
|
+
const shown = attempts
|
|
676
|
+
.slice(0, TRAIL_MAX_SHOWN)
|
|
677
|
+
.map(a => `${a.platform}/${a.modelId} key${a.keyOrdinal}: ${a.errorClass}`);
|
|
678
|
+
const extra = attempts.length - shown.length;
|
|
679
|
+
return shown.join('; ') + (extra > 0 ? `; +${extra} more` : '');
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Set the failover diagnostics headers every surface stamps on its responses:
|
|
683
|
+
* X-Fallback-Attempts (how many hops failed before this response) and
|
|
684
|
+
* X-Fallback-Trail (what each hop was and why it failed). Until now the trail
|
|
685
|
+
* only reached clients inside exhaustion error MESSAGES — a request that
|
|
686
|
+
* eventually succeeded gave no hint that it burned five hops first, which is
|
|
687
|
+
* exactly the case an operator wants to notice. Values go through
|
|
688
|
+
* safeHeaderValue so a non-ASCII or control-laden model id can neither inject
|
|
689
|
+
* header lines nor make Node reject the response outright (#619).
|
|
690
|
+
*
|
|
691
|
+
* When EXPOSE_FALLBACK_DETAIL_SETTING is on, X-Fallback-Detail joins them with
|
|
692
|
+
* per-hop timings and the redacted provider message. Every caller runs inside
|
|
693
|
+
* the request's AsyncLocalStorage scope, so the trace is readable here without
|
|
694
|
+
* threading it through all five surfaces.
|
|
695
|
+
*
|
|
696
|
+
* Note what the detail header can and cannot contain: at flush time the trace
|
|
697
|
+
* holds exactly the hops that already FAILED, each with final timings. The hop
|
|
698
|
+
* currently being served is recorded only after dispatch returns — after
|
|
699
|
+
* res.json(), or after the whole stream has finished — so its duration is not
|
|
700
|
+
* knowable while headers are still open, on either path.
|
|
701
|
+
*/
|
|
702
|
+
export function setFallbackHeaders(res, failedAttempts, trail) {
|
|
703
|
+
if (failedAttempts > 0)
|
|
704
|
+
res.setHeader('X-Fallback-Attempts', String(failedAttempts));
|
|
705
|
+
if (trail && trail.length > 0) {
|
|
706
|
+
const value = trail
|
|
707
|
+
.slice(0, TRAIL_MAX_SHOWN)
|
|
708
|
+
.map(a => `${a.platform}/${a.modelId} key${a.keyOrdinal}=${a.errorClass}`)
|
|
709
|
+
.join('; ') + (trail.length > TRAIL_MAX_SHOWN ? `; +${trail.length - TRAIL_MAX_SHOWN} more` : '');
|
|
710
|
+
// Ten hops of "platform/model keyN=class" outgrow the default cap, so the
|
|
711
|
+
// trail gets its own budget.
|
|
712
|
+
res.setHeader('X-Fallback-Trail', safeHeaderValue(value, TRAIL_HEADER_MAX_LENGTH));
|
|
713
|
+
}
|
|
714
|
+
// Checked before the setting so the overwhelmingly common no-failover request
|
|
715
|
+
// never pays for a settings read.
|
|
716
|
+
const records = getRequestTrace()?.records;
|
|
717
|
+
if (records && records.length > 0 && isFallbackDetailHeaderEnabled()) {
|
|
718
|
+
res.setHeader('X-Fallback-Detail', safeHeaderValue(formatAttemptDetail(records), DETAIL_HEADER_MAX_LENGTH));
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* The OpenAI-compatible `error` object for an exhaustion body — shared by every
|
|
723
|
+
* OpenAI-shaped surface so the wire shape (message/type/code/retryAtMs) cannot
|
|
724
|
+
* drift between them.
|
|
725
|
+
*/
|
|
726
|
+
export function exhaustionErrorPayload(body) {
|
|
727
|
+
const payload = {
|
|
728
|
+
message: body.message,
|
|
729
|
+
type: body.type,
|
|
730
|
+
};
|
|
731
|
+
if (body.code)
|
|
732
|
+
payload.code = body.code;
|
|
733
|
+
if (body.retryAtMs != null)
|
|
734
|
+
payload.retryAtMs = body.retryAtMs;
|
|
735
|
+
return payload;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Stamp the standard retry headers for an exhaustion body: a Retry-After of
|
|
739
|
+
* ceil((retryAtMs - now) / 1000) seconds when the body carries a concrete
|
|
740
|
+
* retry time. Callers must only invoke this before headers are flushed (i.e.
|
|
741
|
+
* never on a committed SSE stream).
|
|
742
|
+
*/
|
|
743
|
+
export function setExhaustionHeaders(res, body, now = Date.now()) {
|
|
744
|
+
if (body.retryAtMs == null)
|
|
745
|
+
return;
|
|
746
|
+
res.setHeader('Retry-After', String(Math.max(0, Math.ceil((body.retryAtMs - now) / 1000))));
|
|
747
|
+
}
|
|
748
|
+
// Attempt classes that mean "this candidate is unavailable until a KNOWN time"
|
|
749
|
+
// — a rate-limit window, a benched daily allocation, an out-of-credits key
|
|
750
|
+
// (day bench), or a tier-forbidden model (day bench). When EVERY attempt is in
|
|
751
|
+
// this family the pool recovers by itself, so the honest exhaustion is a 429
|
|
752
|
+
// with a concrete retry time. Anything else in the mix (a 5xx, a timeout, a
|
|
753
|
+
// vanished model) means waiting is not a promise, so the mixed case renders a
|
|
754
|
+
// 502 instead.
|
|
755
|
+
const UNAVAILABLE_UNTIL_KNOWN_TIME = new Set([
|
|
756
|
+
'rate_limited',
|
|
757
|
+
'daily_quota_exhausted',
|
|
758
|
+
'out_of_credits',
|
|
759
|
+
'forbidden',
|
|
760
|
+
]);
|
|
761
|
+
/**
|
|
762
|
+
* The shared exhaustion response body — the single failure-kind → terminal-
|
|
763
|
+
* status ladder every surface renders. Aggregated over the per-attempt failure
|
|
764
|
+
* classes (most-specific first):
|
|
765
|
+
* - Every attempt failed auth (401/invalid key) → 502 provider_error saying the
|
|
766
|
+
* PROVIDER keys are bad — distinct from a rate-limit exhaustion, and never
|
|
767
|
+
* 'authentication_error' (which would wrongly blame the CLIENT's key).
|
|
768
|
+
* - Every attempt died on a context/prompt-too-large rejection → 413: no
|
|
769
|
+
* candidate can fit this request; retrying cannot help, shrinking it can.
|
|
770
|
+
* - Every attempt got a model-not-found/gone from its provider → 404: the
|
|
771
|
+
* model has been removed upstream everywhere we route it. (No 410 — the
|
|
772
|
+
* catalog keeps no removal tombstones, so "verifiably existed before" is
|
|
773
|
+
* not determinable here.)
|
|
774
|
+
* - A chain that died on a DEGRADED-function 400 (NVIDIA NIM, #522) → 503:
|
|
775
|
+
* provider capacity, not a bad request.
|
|
776
|
+
* - A request every routed provider rejected as invalid → 400
|
|
777
|
+
* invalid_request_error, not a misleading rate-limit exhaustion.
|
|
778
|
+
* - Circuit-breaker stop → 503 (the pool looks unhealthy; retry later).
|
|
779
|
+
* - Every attempt unavailable-until-known-time (rate limits, benched
|
|
780
|
+
* quotas/credits/tiers) → 429 rate_limit_error with `retryAtMs` (soonest
|
|
781
|
+
* cooldown expiry) for a matching Retry-After header.
|
|
782
|
+
* - Mixed/other upstream failures (5xx, timeouts, transport errors) → 502
|
|
783
|
+
* provider_error: the UPSTREAMS failed. Never 500 — that status is
|
|
784
|
+
* reserved for our own bugs.
|
|
785
|
+
* All bodies carry the attempt trail (what was tried, per attempt) and, where
|
|
786
|
+
* meaningful, the soonest-cooldown-reset hint.
|
|
787
|
+
*/
|
|
788
|
+
export function exhaustedRetryError(lastError, maxRetries, ctx) {
|
|
789
|
+
const safeLastError = sanitizeProviderErrorMessage(lastError?.message);
|
|
790
|
+
const attempts = ctx?.attempts ?? [];
|
|
791
|
+
const trail = attempts.length > 0 ? ` Attempt trail: ${formatAttemptTrail(attempts)}.` : '';
|
|
792
|
+
const budgetNote = ctx?.timedOut
|
|
793
|
+
? ` (stopped early: retry time budget ${Math.round((ctx.budgetMs ?? 0) / 1000)}s exceeded — one failover hop is always allowed, and past that the budget stops starting further retries and cancels an attempt still waiting on its first byte; raise FALLBACK_TIME_BUDGET_MS or the fallback_time_budget_ms setting to allow a longer failover chain)`
|
|
794
|
+
: '';
|
|
795
|
+
const everyAttempt = (cls) => attempts.length > 0 && attempts.every(a => (cls instanceof Set ? cls.has(a.errorClass) : a.errorClass === cls));
|
|
796
|
+
if (everyAttempt('auth')) {
|
|
797
|
+
return {
|
|
798
|
+
kind: 'auth',
|
|
799
|
+
status: 502,
|
|
800
|
+
type: 'provider_error',
|
|
801
|
+
code: 'provider_authentication_failed',
|
|
802
|
+
message: `All ${attempts.length} attempted provider key(s) failed authentication${budgetNote}. ` +
|
|
803
|
+
'The configured upstream API key(s) look invalid or expired; they are being revalidated now and will be marked invalid automatically. ' +
|
|
804
|
+
`Check the provider keys in the dashboard.${trail} Last error: ${safeLastError}`,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
// Aggregate diagnoses before the lastError-shape ones below: when EVERY
|
|
808
|
+
// attempt failed the same way, that unanimity is stronger evidence than the
|
|
809
|
+
// shape of whichever error happened to come last.
|
|
810
|
+
if (everyAttempt('context_too_large')) {
|
|
811
|
+
return {
|
|
812
|
+
kind: 'context_too_large',
|
|
813
|
+
status: 413,
|
|
814
|
+
type: 'invalid_request_error',
|
|
815
|
+
code: 'context_length_exceeded',
|
|
816
|
+
message: `The request is too large for every routed candidate: all ${attempts.length} attempt(s) were rejected as ` +
|
|
817
|
+
`over the model's context/size limit${budgetNote}. Retrying will not help — reduce the prompt/history size or ` +
|
|
818
|
+
`enable a larger-context model.${trail} Last error: ${safeLastError}`,
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
if (everyAttempt('model_not_found')) {
|
|
822
|
+
return {
|
|
823
|
+
kind: 'model_not_found',
|
|
824
|
+
status: 404,
|
|
825
|
+
type: 'invalid_request_error',
|
|
826
|
+
code: 'model_not_found',
|
|
827
|
+
message: `Every routed provider reports the model as not found or removed upstream (${attempts.length} attempt(s))` +
|
|
828
|
+
`${budgetNote}. The catalog entry looks stale; pick another model or call /v1/models for the available list.` +
|
|
829
|
+
`${trail} Last error: ${safeLastError}`,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
// A chain that died on a DEGRADED-function 400 (NVIDIA NIM, #522) is a
|
|
833
|
+
// provider-capacity outage, not a bad request: render 503 so clients retry
|
|
834
|
+
// later instead of "fixing" a request that was never broken.
|
|
835
|
+
if (isProviderDegradedError(lastError)) {
|
|
836
|
+
return {
|
|
837
|
+
kind: 'unavailable',
|
|
838
|
+
status: 503,
|
|
839
|
+
type: 'service_unavailable',
|
|
840
|
+
code: 'provider_degraded',
|
|
841
|
+
message: `The routed provider reported the model's hosted deployment as temporarily degraded${budgetNote}. ` +
|
|
842
|
+
`This is a provider-side condition; retry later or route to another provider.${trail} Last error: ${safeLastError}`,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
// 400 only when the trail agrees with the last error: EVERY attempt was a
|
|
846
|
+
// provider-side request rejection (or the legacy no-trail shape). A mixed
|
|
847
|
+
// trail — stale models, an empty wallet, a rate limit, and one bad-request
|
|
848
|
+
// hop that happened to come last — is not evidence the caller's request is
|
|
849
|
+
// invalid, and rendering it as one sent users debugging their own payload
|
|
850
|
+
// (#1239: 5× provider_bad_request + 3× out_of_credits read as "All routed
|
|
851
|
+
// providers rejected the request as invalid"). Those fall through to the
|
|
852
|
+
// mixed 502 below, whose message names the class breakdown.
|
|
853
|
+
if (isProviderBadRequestError(lastError) && (attempts.length === 0 || everyAttempt('provider_bad_request'))) {
|
|
854
|
+
return {
|
|
855
|
+
kind: 'bad_request',
|
|
856
|
+
status: 400,
|
|
857
|
+
type: 'invalid_request_error',
|
|
858
|
+
code: 'provider_rejected_request',
|
|
859
|
+
message: `All routed providers rejected the request as invalid${budgetNote}.${trail} Last error: ${safeLastError}`,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
// Circuit-breaker guardrail stop. Checked after the aggregate and bad-request
|
|
863
|
+
// diagnoses, which are more specific about WHY the pool is failing.
|
|
864
|
+
if (ctx?.breakerFails) {
|
|
865
|
+
const breakerEta = formatResetEta(getSoonestCooldownExpiry());
|
|
866
|
+
const breakerEtaNote = breakerEta ? ` Soonest cooldown reset ${breakerEta}.` : '';
|
|
867
|
+
return {
|
|
868
|
+
kind: 'unavailable',
|
|
869
|
+
status: 503,
|
|
870
|
+
type: 'service_unavailable',
|
|
871
|
+
code: 'upstream_unhealthy',
|
|
872
|
+
message: `Failover stopped early by the circuit-breaker guardrail: ${ctx.breakerFails} consecutive upstream ` +
|
|
873
|
+
`failure${ctx.breakerFails === 1 ? '' : 's'} (max_consecutive_upstream_fails). The enabled pool looks ` +
|
|
874
|
+
`unhealthy right now, so the remaining candidates were skipped instead of burning quota on them.` +
|
|
875
|
+
`${breakerEtaNote}${trail} Last error: ${safeLastError}`,
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
// 429 only when EVERY candidate is unavailable until a known time (or the
|
|
879
|
+
// caller gave us no per-attempt classes to aggregate — the legacy shape).
|
|
880
|
+
if (attempts.length === 0 || everyAttempt(UNAVAILABLE_UNTIL_KNOWN_TIME)) {
|
|
881
|
+
const attemptCount = attempts.length > 0 ? attempts.length : maxRetries;
|
|
882
|
+
const scope = attemptCount == null
|
|
883
|
+
? 'All models rate-limited'
|
|
884
|
+
: `All models rate-limited after ${attemptCount} attempt${attemptCount === 1 ? '' : 's'}`;
|
|
885
|
+
const retryAtMs = getSoonestCooldownExpiry() ?? undefined;
|
|
886
|
+
const eta = formatResetEta(retryAtMs);
|
|
887
|
+
const etaNote = eta ? ` Soonest cooldown reset ${eta}.` : '';
|
|
888
|
+
return {
|
|
889
|
+
kind: 'rate_limit',
|
|
890
|
+
status: 429,
|
|
891
|
+
type: 'rate_limit_error',
|
|
892
|
+
code: 'rate_limit_exceeded',
|
|
893
|
+
...(retryAtMs != null ? { retryAtMs } : {}),
|
|
894
|
+
message: `${scope}${budgetNote}.${etaNote}${trail} Last error: ${safeLastError}`,
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
// Mixed/other upstream failures (5xx, timeouts, transport errors, or a mix of
|
|
898
|
+
// those with rate limits): the upstream pool failed us, so say 502 — not 429
|
|
899
|
+
// (which would promise recovery-by-waiting the attempts don't support) and
|
|
900
|
+
// not 500 (which would blame our own code).
|
|
901
|
+
//
|
|
902
|
+
// The breakdown names how many attempts fell in each class so a mixed trail
|
|
903
|
+
// reads as what it is. When SOME hops were provider-side request rejections
|
|
904
|
+
// the body must not promise the request was fine either — it says which
|
|
905
|
+
// providers rejected it and leaves the verdict to the reader.
|
|
906
|
+
const breakdown = formatClassBreakdown(attempts);
|
|
907
|
+
const someBadRequest = attempts.some(a => a.errorClass === 'provider_bad_request');
|
|
908
|
+
const verdict = someBadRequest
|
|
909
|
+
? 'Most of these failures are provider-side (stale models, exhausted credits or quotas), but some providers ' +
|
|
910
|
+
'also rejected the request shape — check the attempt trail for which ones before changing your request.'
|
|
911
|
+
: 'This is a provider-side failure, not a problem with your request; retry, or check provider status.';
|
|
912
|
+
return {
|
|
913
|
+
kind: 'upstream',
|
|
914
|
+
status: 502,
|
|
915
|
+
type: 'provider_error',
|
|
916
|
+
code: 'upstream_failed',
|
|
917
|
+
message: `All ${attempts.length} routed attempt(s) failed with upstream provider errors${breakdown}${budgetNote}. ` +
|
|
918
|
+
`${verdict}${trail} Last error: ${safeLastError}`,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
/** " (provider_bad_request ×5, out_of_credits ×3)" — the per-class tally of a
|
|
922
|
+
* mixed trail, most frequent first; empty when there is nothing to tally. */
|
|
923
|
+
function formatClassBreakdown(attempts) {
|
|
924
|
+
if (attempts.length === 0)
|
|
925
|
+
return '';
|
|
926
|
+
const counts = new Map();
|
|
927
|
+
for (const a of attempts)
|
|
928
|
+
counts.set(a.errorClass, (counts.get(a.errorClass) ?? 0) + 1);
|
|
929
|
+
const parts = [...counts.entries()]
|
|
930
|
+
.sort((a, b) => b[1] - a[1])
|
|
931
|
+
.map(([cls, n]) => `${cls} ×${n}`);
|
|
932
|
+
return ` (${parts.join(', ')})`;
|
|
933
|
+
}
|
|
934
|
+
function classifyRoutingDiagLine(line) {
|
|
935
|
+
const l = line.toLowerCase();
|
|
936
|
+
// "< estimated" first: the tpm_limit-too-small line also contains 'tpm',
|
|
937
|
+
// which would otherwise misread as a transient window.
|
|
938
|
+
if (l.includes('< estimated'))
|
|
939
|
+
return 'too_large';
|
|
940
|
+
const monthlyReasons = l.match(/key\(s\) — (.*)$/)?.[1];
|
|
941
|
+
if (monthlyReasons?.split(', ').every(reason => /^monthly-budget-cap:\d+$/.test(reason)))
|
|
942
|
+
return 'monthly_budget';
|
|
943
|
+
if (/no provider registered|no enabled\+healthy key|no usable key|decrypt-error|no-resolved-provider|custom-key-mismatch/.test(l))
|
|
944
|
+
return 'config';
|
|
945
|
+
if (/cooldown|rpm|rpd|tpm|tpd|provider-daily-cap|provider-minute-cap|provider-daily-token-cap|key-concurrency|monthly-budget-cap/.test(l))
|
|
946
|
+
return 'time_bound';
|
|
947
|
+
return 'other';
|
|
948
|
+
}
|
|
949
|
+
export function routingExhaustionBody(routeErr) {
|
|
950
|
+
const diag = Array.isArray(routeErr?.diagnostics) ? routeErr.diagnostics : [];
|
|
951
|
+
const message = routeErr?.message ?? 'No model available to route this request';
|
|
952
|
+
const classes = diag.map(classifyRoutingDiagLine);
|
|
953
|
+
if (diag.length === 0 || classes.every(c => c === 'config')) {
|
|
954
|
+
return {
|
|
955
|
+
kind: 'unavailable',
|
|
956
|
+
status: 503,
|
|
957
|
+
type: 'service_unavailable',
|
|
958
|
+
code: 'no_providers_configured',
|
|
959
|
+
message: diag.length === 0
|
|
960
|
+
? `No models are enabled/configured to serve this request. Add provider API keys and enable models in the dashboard. ${message}`
|
|
961
|
+
: `No candidate model has a configured, usable provider key. Add provider API keys in the dashboard. ${message}`,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
if (classes.every(c => c === 'too_large' || c === 'config') && classes.includes('too_large')) {
|
|
965
|
+
return {
|
|
966
|
+
kind: 'context_too_large',
|
|
967
|
+
status: 413,
|
|
968
|
+
type: 'invalid_request_error',
|
|
969
|
+
code: 'context_length_exceeded',
|
|
970
|
+
message: `The request is too large for every available candidate's context/token window. ` +
|
|
971
|
+
`Reduce the prompt/history size or enable a larger-context model. ${message}`,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
if (classes.includes('monthly_budget') && classes.every(c => c === 'monthly_budget' || c === 'config' || c === 'too_large')) {
|
|
975
|
+
return {
|
|
976
|
+
kind: 'rate_limit', status: 429, type: 'rate_limit_error', code: 'quota_exceeded',
|
|
977
|
+
retryAtMs: Date.parse(soonestBudgetResetAt()),
|
|
978
|
+
message: `Key budget exhausted for the current period. Raise the cap or wait until the next UTC reset. ${message}`,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
if (classes.some(c => c === 'time_bound') && classes.every(c => c !== 'other')) {
|
|
982
|
+
const retryAtMs = getSoonestCooldownExpiry() ?? undefined;
|
|
983
|
+
return {
|
|
984
|
+
kind: 'rate_limit',
|
|
985
|
+
status: 429,
|
|
986
|
+
type: 'rate_limit_error',
|
|
987
|
+
code: 'rate_limit_exceeded',
|
|
988
|
+
...(retryAtMs != null ? { retryAtMs } : {}),
|
|
989
|
+
message,
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
// Capability filters or mixed reasons: keep the router's verdict (429) but
|
|
993
|
+
// stamp a machine-readable code so clients can tell it from a plain
|
|
994
|
+
// rate-limit exhaustion.
|
|
995
|
+
return {
|
|
996
|
+
kind: 'rate_limit',
|
|
997
|
+
status: typeof routeErr?.status === 'number' ? routeErr.status : 429,
|
|
998
|
+
type: 'rate_limit_error',
|
|
999
|
+
code: 'routing_exhausted',
|
|
1000
|
+
message,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
/** Log the router's per-candidate disposition for a zero-attempt exhaustion.
|
|
1004
|
+
* Lives in the loop, not the surfaces: routing gave up before any upstream was
|
|
1005
|
+
* tried, so nothing else records WHY the pool was empty, and an opaque
|
|
1006
|
+
* routing_error is indistinguishable from a genuinely dry pool (issue _1).
|
|
1007
|
+
* Every surface used to be responsible for logging this itself and only
|
|
1008
|
+
* routes/proxy.ts ever did, so /v1/messages, /v1/responses and the inbound
|
|
1009
|
+
* wires logged nothing. The loop owns the format; surfaces only name
|
|
1010
|
+
* themselves via FallbackHooks.logIdentity. */
|
|
1011
|
+
function logRoutingExhaustion(routeErr, identity) {
|
|
1012
|
+
const disposition = Array.isArray(routeErr?.diagnostics) ? routeErr.diagnostics : [];
|
|
1013
|
+
const surface = identity?.surface ?? 'unidentified surface';
|
|
1014
|
+
const req = identity?.requestId ? ` req=${identity.requestId.replace(/-/g, '').slice(0, 6)}` : '';
|
|
1015
|
+
const requested = identity?.requestedModel ? ` requested=${identity.requestedModel}` : '';
|
|
1016
|
+
console.warn(`[FallbackLoop] ${surface} routing exhausted (no upstream tried)${req}${requested} ` +
|
|
1017
|
+
`candidates=${disposition.length}` +
|
|
1018
|
+
(disposition.length ? `:\n ${disposition.join('\n ')}` : ''));
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* The shared attempt loop. Owns iteration, the wall-clock retry budget, the
|
|
1022
|
+
* routeRequest-exhaustion path, the auth/retryable/fatal classification, the
|
|
1023
|
+
* per-failure bookkeeping (recordRetryableFailure / recordAuthFailure), the
|
|
1024
|
+
* attempt trail, and the final exhaustion body. Everything surface-specific —
|
|
1025
|
+
* request translation, stream framing, error-body shape, context handoff, group
|
|
1026
|
+
* routing — lives in the hooks.
|
|
1027
|
+
*/
|
|
1028
|
+
export async function runFallbackLoop(hooks) {
|
|
1029
|
+
// Durable per-attempt trace (P2 #15): every dispatched attempt — including
|
|
1030
|
+
// the successful final one — is recorded with timing and outcome, then
|
|
1031
|
+
// persisted as one insert batch into `request_attempts`, keyed to the
|
|
1032
|
+
// terminal `requests` row of this ladder. The trace rides AsyncLocalStorage
|
|
1033
|
+
// so logRequest() (called deep inside the surfaces' dispatch closures) can
|
|
1034
|
+
// report back the row ids it writes without any surface changing. The flush
|
|
1035
|
+
// runs after the loop returns — i.e. after the response is finished — so it
|
|
1036
|
+
// never sits on the client's latency path, and it is a no-op when no
|
|
1037
|
+
// `requests` row was written (a pure abort writes its own 'canceled' row,
|
|
1038
|
+
// so its trace persists too).
|
|
1039
|
+
const trace = newRequestTrace();
|
|
1040
|
+
try {
|
|
1041
|
+
await runWithRequestTrace(trace, () => runFallbackLoopAttempts(hooks, trace));
|
|
1042
|
+
}
|
|
1043
|
+
finally {
|
|
1044
|
+
persistRequestAttempts(trace);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
async function runFallbackLoopAttempts(hooks, trace) {
|
|
1048
|
+
const maxRetries = hooks.maxRetries ?? FALLBACK_MAX_RETRIES;
|
|
1049
|
+
const baseBudgetMs = hooks.timeBudgetMs ?? getFallbackTimeBudgetMs();
|
|
1050
|
+
const startedAt = Date.now();
|
|
1051
|
+
const attempts = hooks.attemptLog ?? [];
|
|
1052
|
+
const keyOrdinals = new Map();
|
|
1053
|
+
const keyOrdinal = (route) => {
|
|
1054
|
+
const key = `${route.platform}:${route.keyId}`;
|
|
1055
|
+
let ord = keyOrdinals.get(key);
|
|
1056
|
+
if (ord === undefined) {
|
|
1057
|
+
ord = keyOrdinals.size + 1;
|
|
1058
|
+
keyOrdinals.set(key, ord);
|
|
1059
|
+
}
|
|
1060
|
+
return ord;
|
|
1061
|
+
};
|
|
1062
|
+
let lastError = null;
|
|
1063
|
+
// Circuit-breaker guardrail (default off): the Nth consecutive upstream
|
|
1064
|
+
// failure aborts the chain with a 503 instead of grinding the remaining
|
|
1065
|
+
// candidates of a pool that is failing across the board. Auth failures count
|
|
1066
|
+
// too — a wall of dead keys is exactly the "stop early" case. Within one
|
|
1067
|
+
// request every recorded failure is consecutive by construction (a success
|
|
1068
|
+
// ends the loop), so this is "max upstream failures per request".
|
|
1069
|
+
const breaker = newBreaker(hooks.breakerLimit);
|
|
1070
|
+
// Shared terminal for the client-gone stops below: these returns skip the
|
|
1071
|
+
// surfaces entirely (nothing to render), so no ok/fail trace would ever come
|
|
1072
|
+
// and the routing strip would keep the last attempt pinned on "processing"
|
|
1073
|
+
// until MAX_AGE_MS. Only surfaces that trace start/next pass a requestId
|
|
1074
|
+
// (Proxy/Responses); anthropic/inbound-chat never do, so they stay silent.
|
|
1075
|
+
const traceCanceled = (platform, model, attempt) => {
|
|
1076
|
+
const requestId = hooks.logIdentity?.requestId;
|
|
1077
|
+
if (!requestId || !platform || !model || attempt == null)
|
|
1078
|
+
return;
|
|
1079
|
+
recordRouteTrace({ event: 'canceled', requestId, attempt, platform, model });
|
|
1080
|
+
};
|
|
1081
|
+
const stopIfBreakerTripped = (route, attempt) => {
|
|
1082
|
+
if (!recordBreakerFailure(breaker))
|
|
1083
|
+
return false;
|
|
1084
|
+
// Tripped after the client already hung up: stop the chain, but there is
|
|
1085
|
+
// no socket to render an exhaustion body to (mirrors the loop-top
|
|
1086
|
+
// clientGone check).
|
|
1087
|
+
if (hooks.clientGone?.()) {
|
|
1088
|
+
console.log(`[FallbackLoop] breaker tripped after client disconnect — stopping without rendering (${attempts.length} failed attempt(s))`);
|
|
1089
|
+
traceCanceled(route?.platform, route?.modelId, attempt);
|
|
1090
|
+
return true;
|
|
1091
|
+
}
|
|
1092
|
+
hooks.onExhausted(exhaustedRetryError(lastError, maxRetries, { attempts, breakerFails: breaker.consecutive }), { attempts, timedOut: false });
|
|
1093
|
+
return true;
|
|
1094
|
+
};
|
|
1095
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
1096
|
+
// Client disconnect: nobody is waiting for this chain anymore, so stop
|
|
1097
|
+
// burning provider quota on retries. Nothing to render — the socket is
|
|
1098
|
+
// gone — so return without calling any exhaustion hook.
|
|
1099
|
+
if (attempt > 0 && hooks.clientGone?.()) {
|
|
1100
|
+
console.log(`[FallbackLoop] client disconnected — stopping failover after ${attempts.length} failed attempt(s)`);
|
|
1101
|
+
const prior = attempts[attempts.length - 1];
|
|
1102
|
+
traceCanceled(prior?.platform, prior?.modelId, attempt - 1);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
let route;
|
|
1106
|
+
try {
|
|
1107
|
+
route = hooks.route(attempt);
|
|
1108
|
+
}
|
|
1109
|
+
catch (routeErr) {
|
|
1110
|
+
// With no candidate to supply an endpoint-specific allowance, retain
|
|
1111
|
+
// the original timeout diagnosis when the base budget is already spent.
|
|
1112
|
+
if (attempt > 1 && baseBudgetMs > 0 && Date.now() - startedAt >= baseBudgetMs) {
|
|
1113
|
+
hooks.onExhausted(exhaustedRetryError(lastError, maxRetries, { attempts, timedOut: true, budgetMs: baseBudgetMs }), { attempts, timedOut: true });
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
const exhaustion = lastError
|
|
1117
|
+
? exhaustedRetryError(lastError, undefined, { attempts })
|
|
1118
|
+
: routingExhaustionBody(routeErr);
|
|
1119
|
+
// Zero attempts ran: log the router's per-candidate disposition, the only
|
|
1120
|
+
// record of why the pool was empty. With prior attempts the trail in the
|
|
1121
|
+
// exhaustion body already explains the failure.
|
|
1122
|
+
if (!lastError)
|
|
1123
|
+
logRoutingExhaustion(routeErr, hooks.logIdentity);
|
|
1124
|
+
hooks.onRoutingExhausted(lastError, routeErr, exhaustion, { attempts, timedOut: false });
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
let hedgeTimer;
|
|
1128
|
+
try {
|
|
1129
|
+
// Select the endpoint before checking the budget: a slow endpoint may
|
|
1130
|
+
// still have time even after the base budget has expired. Recompute from
|
|
1131
|
+
// the base for every candidate so its allowance cannot leak to a faster
|
|
1132
|
+
// endpoint later in the ladder. The clock still starts at loop entry.
|
|
1133
|
+
const budgetMs = attempt > 1
|
|
1134
|
+
? getEndpointTimeBudgetMs(baseBudgetMs, route.platform, route.endpointScope)
|
|
1135
|
+
: baseBudgetMs;
|
|
1136
|
+
// Attempt 0 and the first retry remain exempt (#751). Routing reserves a
|
|
1137
|
+
// lease, so even a candidate rejected here must pass through finally.
|
|
1138
|
+
if (attempt > 1 && budgetMs > 0 && Date.now() - startedAt >= budgetMs) {
|
|
1139
|
+
hooks.onExhausted(exhaustedRetryError(lastError, maxRetries, { attempts, timedOut: true, budgetMs }), { attempts, timedOut: true });
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
// Per-attempt trace record: pushed exactly once per dispatched attempt, on
|
|
1143
|
+
// whichever exit the attempt takes. startOffsetMs/durationMs bracket the
|
|
1144
|
+
// dispatch (for a successful stream, durationMs runs until the response
|
|
1145
|
+
// finished — that IS the attempt). When the attempt ended on an error, a
|
|
1146
|
+
// short REDACTED summary of it rides along — the outcome class alone loses
|
|
1147
|
+
// the provider's actual words, which is exactly what the dashboard's
|
|
1148
|
+
// drill-down needs to answer "why did this hop fail".
|
|
1149
|
+
const attemptStartedAt = Date.now();
|
|
1150
|
+
const traceAttempt = (outcome, err) => {
|
|
1151
|
+
trace.records.push({
|
|
1152
|
+
ordinal: trace.records.length,
|
|
1153
|
+
platform: route.platform,
|
|
1154
|
+
modelId: route.modelId,
|
|
1155
|
+
keyOrdinal: keyOrdinal(route),
|
|
1156
|
+
keyLabel: route.keyLabel ?? null,
|
|
1157
|
+
outcome,
|
|
1158
|
+
startOffsetMs: attemptStartedAt - startedAt,
|
|
1159
|
+
durationMs: Date.now() - attemptStartedAt,
|
|
1160
|
+
errorSummary: err != null ? summarizeAttemptError(err?.message) : null,
|
|
1161
|
+
});
|
|
1162
|
+
};
|
|
1163
|
+
// Everything from here to the end of the iteration runs inside a finally that
|
|
1164
|
+
// frees the route's in-flight lease. Every exit — success, auth rotation,
|
|
1165
|
+
// retryable continue, fatal, breaker trip, contract violation — passes through
|
|
1166
|
+
// it, so no path can leak a lease and leave the key's concurrency budget short.
|
|
1167
|
+
// Success accounting happens inside dispatch, so the persisted counters are
|
|
1168
|
+
// already written by the time the provisional lease goes away.
|
|
1169
|
+
// Fallback-v2 hedging: arm a timer for the remaining wall-clock budget so a
|
|
1170
|
+
// STALLED attempt is aborted mid-flight (abortInFlight) instead of only
|
|
1171
|
+
// refusing to start the next retry behind it. Mirrors the loop-top budget
|
|
1172
|
+
// check — attempt 0 and the first retry always run (#751).
|
|
1173
|
+
//
|
|
1174
|
+
// The timer only covers the silent window. dispatch calls ctx.disarmHedge()
|
|
1175
|
+
// as soon as the attempt proves it is alive (first byte / headers flushed),
|
|
1176
|
+
// because past that point cancelling would truncate a healthy response and
|
|
1177
|
+
// buy nothing: a committed stream can no longer fail over anyway. Slow is
|
|
1178
|
+
// not the same as stalled, and only stalled is worth killing.
|
|
1179
|
+
const disarmHedge = () => {
|
|
1180
|
+
if (hedgeTimer) {
|
|
1181
|
+
clearTimeout(hedgeTimer);
|
|
1182
|
+
hedgeTimer = undefined;
|
|
1183
|
+
}
|
|
1184
|
+
};
|
|
1185
|
+
if (attempt > 1 && budgetMs > 0 && hooks.abortInFlight) {
|
|
1186
|
+
const remaining = budgetMs - (Date.now() - startedAt);
|
|
1187
|
+
if (remaining > 0) {
|
|
1188
|
+
hedgeTimer = setTimeout(() => {
|
|
1189
|
+
console.log(`[FallbackLoop] retry time budget (${budgetMs}ms) expired with no first byte on ${route.platform}/${route.modelId} — aborting stalled upstream`);
|
|
1190
|
+
hooks.abortInFlight?.();
|
|
1191
|
+
}, remaining);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
let outcome;
|
|
1195
|
+
try {
|
|
1196
|
+
// #590 (per-key proxy): if THIS key carries its own proxy URL, route the
|
|
1197
|
+
// attempt through it (withKeyProxy → proxyFetch reads the ALS store);
|
|
1198
|
+
// otherwise the global proxy / direct path applies as before. The URL
|
|
1199
|
+
// arrives already decrypted on the route (services/router.ts), so an
|
|
1200
|
+
// attempt costs nothing extra — no query, no decrypt.
|
|
1201
|
+
outcome = await withKeyProxy(route.proxyUrl, () => hooks.dispatch(route, attempt, { disarmHedge }));
|
|
1202
|
+
}
|
|
1203
|
+
catch (err) {
|
|
1204
|
+
// Client-caused abort: the composed fetch signal fired because OUR
|
|
1205
|
+
// client hung up mid-attempt (see newClientAbortError). Not a
|
|
1206
|
+
// provider-health signal — no cooldown, no penalty, no failure stats,
|
|
1207
|
+
// no logFailure 'error' row — and no further attempts either: nobody is
|
|
1208
|
+
// waiting, and there is no socket to render anything to. The finally
|
|
1209
|
+
// below still frees the in-flight lease.
|
|
1210
|
+
if (isClientAbortError(err)) {
|
|
1211
|
+
const elapsedMs = Date.now() - startedAt;
|
|
1212
|
+
console.log(`[FallbackLoop] client disconnected mid-attempt on ${route.platform}/${route.modelId} — upstream canceled, stopping without benching`);
|
|
1213
|
+
// Visibility row (#752): a pure abort used to leave NOTHING in the
|
|
1214
|
+
// requests table, so the dashboard showed no trace of the request.
|
|
1215
|
+
// Status 'canceled' is neither success nor failure — every stats/
|
|
1216
|
+
// scoring query excludes it — and this row is also the parent the
|
|
1217
|
+
// attempt-trace batch below keys to.
|
|
1218
|
+
logRequest(route.platform, route.modelId, route.keyId, 'canceled', 0, 0, elapsedMs, `client disconnected after ${(elapsedMs / 1000).toFixed(1)}s; upstream request canceled`);
|
|
1219
|
+
traceAttempt('client_abort');
|
|
1220
|
+
traceCanceled(route.platform, route.modelId, attempt);
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
// Time-budget hedge abort: the wall-clock retry budget expired while this
|
|
1224
|
+
// attempt was still in flight, and the surface aborted the composed fetch
|
|
1225
|
+
// signal (see newHedgeAbortError). The budget is spent either way, so we
|
|
1226
|
+
// render timedOut exhaustion exactly like the loop-top budget check does.
|
|
1227
|
+
//
|
|
1228
|
+
// Whether it also counts as a provider-health signal depends on how much
|
|
1229
|
+
// of the silence belongs to THIS attempt. Sharing a budget that earlier
|
|
1230
|
+
// attempts already mostly spent says nothing about this route — it was
|
|
1231
|
+
// simply last in line, and cancelling it is a scheduling accident. But an
|
|
1232
|
+
// attempt that stayed silent for most of the operator's whole declared
|
|
1233
|
+
// patience on its own produced no first byte over that entire window,
|
|
1234
|
+
// and that is a stalled upstream. Left unbenched it stays at the head of the route
|
|
1235
|
+
// order and re-stalls every subsequent request, burning the full budget
|
|
1236
|
+
// each time and starving the healthy routes queued behind it — observed
|
|
1237
|
+
// in production as one dead free-tier route 45s-ing every request for
|
|
1238
|
+
// hours. Benching costs a short transient cooldown (a timeout is not a
|
|
1239
|
+
// rate-limit signal, so cooldownDecisionForError keeps it light) plus the
|
|
1240
|
+
// gradual model-level sink, both of which decay on their own if the route
|
|
1241
|
+
// recovers.
|
|
1242
|
+
if (isHedgeAbortError(err)) {
|
|
1243
|
+
const elapsedMs = Date.now() - startedAt;
|
|
1244
|
+
const attemptElapsedMs = Date.now() - attemptStartedAt;
|
|
1245
|
+
const ownedWholeBudget = budgetMs > 0
|
|
1246
|
+
&& attemptElapsedMs >= budgetMs * HEDGE_BENCH_MIN_SILENT_FRACTION;
|
|
1247
|
+
if (ownedWholeBudget) {
|
|
1248
|
+
hooks.logFailure(route, err, attempt);
|
|
1249
|
+
recordRetryableFailure(route, err, hooks.state);
|
|
1250
|
+
}
|
|
1251
|
+
console.log(`[FallbackLoop] retry time budget expired mid-attempt on ${route.platform}/${route.modelId} after ${(elapsedMs / 1000).toFixed(1)}s — rendering timedOut exhaustion ${ownedWholeBudget ? `and benching the route (silent for the whole ${budgetMs}ms budget)` : 'without benching'}`);
|
|
1252
|
+
hooks.onExhausted(exhaustedRetryError(lastError, maxRetries, { attempts, timedOut: true, budgetMs }), { attempts, timedOut: true });
|
|
1253
|
+
traceAttempt('timeout', err);
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
hooks.logFailure(route, err, attempt);
|
|
1257
|
+
if (isKeyAuthError(err)) {
|
|
1258
|
+
// KEY-fatal, not request-fatal: rotate past the bad key and revalidate
|
|
1259
|
+
// it immediately instead of 502-ing while healthy routes sit idle.
|
|
1260
|
+
recordAuthFailure(route, hooks.state);
|
|
1261
|
+
attempts.push({ platform: route.platform, modelId: route.modelId, keyOrdinal: keyOrdinal(route), errorClass: 'auth' });
|
|
1262
|
+
traceAttempt('auth', err);
|
|
1263
|
+
lastError = err;
|
|
1264
|
+
if (stopIfBreakerTripped(route, attempt))
|
|
1265
|
+
return;
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
if (isRetryableError(err)) {
|
|
1269
|
+
const exempt = recordRetryableFailure(route, err, hooks.state);
|
|
1270
|
+
// An in-band provider error that arrives only after the attempt has
|
|
1271
|
+
// silently consumed most of the operator's whole budget (#1218 Gap 3:
|
|
1272
|
+
// nvidia ran 140.7s before surfacing "Service temporarily overloaded",
|
|
1273
|
+
// leaving scraps for the next hop) behaved like a stall for its entire
|
|
1274
|
+
// window — the hedge-abort bench would have fired had the abort landed
|
|
1275
|
+
// first. Give the late error the same treatment the hedge abort gets:
|
|
1276
|
+
// bench the route so the ladder's next hop keeps a usable budget,
|
|
1277
|
+
// instead of re-stalling on this route every request. Errors that
|
|
1278
|
+
// arrive EARLY (the common Groq tool_use_failed shape) stay unbenced —
|
|
1279
|
+
// a fast verdict costs the ladder nothing.
|
|
1280
|
+
const errStr = err?.message ?? '';
|
|
1281
|
+
if (budgetMs > 0
|
|
1282
|
+
&& typeof errStr === 'string' && errStr.includes('in-band provider error')
|
|
1283
|
+
&& Date.now() - attemptStartedAt >= budgetMs * HEDGE_BENCH_MIN_SILENT_FRACTION) {
|
|
1284
|
+
setCooldown(route.platform, route.modelId, route.keyId, TRUNCATION_BENCH_MS, 'heuristic');
|
|
1285
|
+
console.warn(`[FallbackLoop] ${route.platform}/${route.modelId}: in-band provider error after ${((Date.now() - attemptStartedAt) / 1000).toFixed(1)}s silent — benching the route ${Math.round(TRUNCATION_BENCH_MS / 1000)}s (#1218 Gap 3)`);
|
|
1286
|
+
}
|
|
1287
|
+
const errorClass = classifyAttemptError(err);
|
|
1288
|
+
attempts.push({ platform: route.platform, modelId: route.modelId, keyOrdinal: keyOrdinal(route), errorClass });
|
|
1289
|
+
traceAttempt(errorClass, err);
|
|
1290
|
+
lastError = err;
|
|
1291
|
+
// A still-exempt skipBench failure (format ignored, hidden-reasoning
|
|
1292
|
+
// truncation under the streak limit) is model behavior, not provider
|
|
1293
|
+
// health — recordRetryableFailure skipped the cooldown/penalty for it,
|
|
1294
|
+
// and it must not count toward the "pool looks unhealthy" breaker
|
|
1295
|
+
// either: three prose answers to a json_schema request say nothing
|
|
1296
|
+
// about whether candidate four is up. Once the empty-completion streak
|
|
1297
|
+
// lifts the exemption (#751), the failure counts everywhere.
|
|
1298
|
+
if (!exempt && stopIfBreakerTripped(route, attempt))
|
|
1299
|
+
return;
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
traceAttempt(classifyAttemptError(err), err);
|
|
1303
|
+
hooks.onFatal(route, err, attempt);
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
// Enforce the dispatch contract: 'done'/'committed' mean the response is
|
|
1307
|
+
// finished. Anything else (a stray `return` in an adapter) would silently
|
|
1308
|
+
// swallow the request, so fail loudly. Deliberately OUTSIDE the try/catch:
|
|
1309
|
+
// the violation message embeds route.modelId, and a model id containing a
|
|
1310
|
+
// digit run like "2503" would match a retryable-error substring and make
|
|
1311
|
+
// the loop re-dispatch the buggy adapter until exhaustion. Routing straight
|
|
1312
|
+
// to onFatal renders an immediate 502 with no retryability classification.
|
|
1313
|
+
if (outcome !== 'done' && outcome !== 'committed') {
|
|
1314
|
+
const violation = new Error(`fallback-loop dispatch contract violation on ${route.platform}/${route.modelId}: ` +
|
|
1315
|
+
`expected 'done' or 'committed', got ${JSON.stringify(outcome)}`);
|
|
1316
|
+
console.error('[FallbackLoop]', violation.message);
|
|
1317
|
+
hooks.logFailure(route, violation, attempt);
|
|
1318
|
+
traceAttempt('error', violation);
|
|
1319
|
+
hooks.onFatal(route, violation, attempt);
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
// Terminal attempt: 'done' produced the response; 'committed' means the
|
|
1323
|
+
// stream had already flushed bytes when the attempt ended (mid-stream
|
|
1324
|
+
// error or pre-commit disconnect) — the parent row carries the specifics.
|
|
1325
|
+
traceAttempt(outcome === 'done' ? 'ok' : 'committed');
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
finally {
|
|
1329
|
+
if (hedgeTimer)
|
|
1330
|
+
clearTimeout(hedgeTimer);
|
|
1331
|
+
route.release?.();
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
hooks.onExhausted(exhaustedRetryError(lastError, maxRetries, { attempts }), { attempts, timedOut: false });
|
|
1335
|
+
}
|
|
1336
|
+
//# sourceMappingURL=fallback-loop.js.map
|