@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.
Files changed (1027) hide show
  1. package/LICENSE +21 -0
  2. package/client/dist/assets/am-TQ7Jmdqe.js +1 -0
  3. package/client/dist/assets/ar-Bf5a7yfC.js +1 -0
  4. package/client/dist/assets/az-CrosE1xH.js +1 -0
  5. package/client/dist/assets/bg-BvWnmcUz.js +1 -0
  6. package/client/dist/assets/bn-ams5TsJp.js +1 -0
  7. package/client/dist/assets/cs-Dh67CaJ_.js +1 -0
  8. package/client/dist/assets/da-9WlT8Iq7.js +1 -0
  9. package/client/dist/assets/de-lWhJJzz7.js +1 -0
  10. package/client/dist/assets/el-Cu7_GhNq.js +1 -0
  11. package/client/dist/assets/es-BZuzsBcP.js +1 -0
  12. package/client/dist/assets/fa-Dwdn-jKS.js +1 -0
  13. package/client/dist/assets/fi-BFrFyOTy.js +1 -0
  14. package/client/dist/assets/fr-XhbtmPpj.js +1 -0
  15. package/client/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  16. package/client/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  17. package/client/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  18. package/client/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  19. package/client/dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
  20. package/client/dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
  21. package/client/dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
  22. package/client/dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
  23. package/client/dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
  24. package/client/dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
  25. package/client/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  26. package/client/dist/assets/gu-D0uhLUB8.js +1 -0
  27. package/client/dist/assets/ha-CVlm9pqp.js +1 -0
  28. package/client/dist/assets/he-CMIFeoWr.js +1 -0
  29. package/client/dist/assets/hi-ENTwhBpn.js +1 -0
  30. package/client/dist/assets/hr-fc03vMy_.js +1 -0
  31. package/client/dist/assets/hu-YBo2BIYt.js +1 -0
  32. package/client/dist/assets/id-BEWXYX69.js +1 -0
  33. package/client/dist/assets/ig-BKLZFKka.js +1 -0
  34. package/client/dist/assets/index-D9I4KtvR.js +145 -0
  35. package/client/dist/assets/index-txhEmCdb.css +2 -0
  36. package/client/dist/assets/it-Bzmei1Y2.js +1 -0
  37. package/client/dist/assets/ja-BfcpuqEl.js +1 -0
  38. package/client/dist/assets/ka-CnWjRgLo.js +1 -0
  39. package/client/dist/assets/km-BAjPR3Qj.js +1 -0
  40. package/client/dist/assets/kn-B15WKU5m.js +1 -0
  41. package/client/dist/assets/ko-C61fAB-w.js +1 -0
  42. package/client/dist/assets/lt-CYTr4_YW.js +1 -0
  43. package/client/dist/assets/ml-CtqxgyH3.js +1 -0
  44. package/client/dist/assets/mr-X5TMY7aR.js +1 -0
  45. package/client/dist/assets/ms-DSg8RqPS.js +1 -0
  46. package/client/dist/assets/my-D7RqR8uE.js +1 -0
  47. package/client/dist/assets/ne-DaflxkDj.js +1 -0
  48. package/client/dist/assets/nl-BOqqtsC8.js +1 -0
  49. package/client/dist/assets/no-BSHp_StB.js +1 -0
  50. package/client/dist/assets/or-BkLR9Vub.js +1 -0
  51. package/client/dist/assets/pa-CarJ2XgI.js +1 -0
  52. package/client/dist/assets/pl-C2n0V-0k.js +1 -0
  53. package/client/dist/assets/pt-BR-CqJdNhg2.js +1 -0
  54. package/client/dist/assets/pt-PT-C_HpMyKz.js +1 -0
  55. package/client/dist/assets/ro-DVRZqSAS.js +1 -0
  56. package/client/dist/assets/ru-DwAJG7VC.js +1 -0
  57. package/client/dist/assets/si-BxUezXm0.js +1 -0
  58. package/client/dist/assets/sk-eI1VcIfY.js +1 -0
  59. package/client/dist/assets/sr-CJF3auCr.js +1 -0
  60. package/client/dist/assets/srouter-logo-C6ZfjGIi.svg +14 -0
  61. package/client/dist/assets/sv-nqvwHAq_.js +1 -0
  62. package/client/dist/assets/sw-DWynE2pW.js +1 -0
  63. package/client/dist/assets/ta-BVM12knC.js +1 -0
  64. package/client/dist/assets/te-oyzEkF7_.js +1 -0
  65. package/client/dist/assets/th-COVeE0ZQ.js +1 -0
  66. package/client/dist/assets/tl-DsJANnQz.js +1 -0
  67. package/client/dist/assets/tr-B-Fl-urO.js +1 -0
  68. package/client/dist/assets/uk-C5qketAO.js +1 -0
  69. package/client/dist/assets/ur-QU9KugfV.js +1 -0
  70. package/client/dist/assets/uz-DL9cG_mY.js +1 -0
  71. package/client/dist/assets/vi-CQiVy3Pu.js +1 -0
  72. package/client/dist/assets/yo-DqazMdrZ.js +1 -0
  73. package/client/dist/assets/zh-CN-DnYL_Kio.js +1 -0
  74. package/client/dist/assets/zh-TW-5bdbosKj.js +1 -0
  75. package/client/dist/favicon.svg +14 -0
  76. package/client/dist/icons.svg +24 -0
  77. package/client/dist/index.html +37 -0
  78. package/dist/app.d.ts +4 -0
  79. package/dist/app.d.ts.map +1 -0
  80. package/dist/app.js +372 -0
  81. package/dist/app.js.map +1 -0
  82. package/dist/db/index.d.ts +49 -0
  83. package/dist/db/index.d.ts.map +1 -0
  84. package/dist/db/index.js +203 -0
  85. package/dist/db/index.js.map +1 -0
  86. package/dist/db/migrate/TEMPLATE.d.ts +4 -0
  87. package/dist/db/migrate/TEMPLATE.d.ts.map +1 -0
  88. package/dist/db/migrate/TEMPLATE.js +18 -0
  89. package/dist/db/migrate/TEMPLATE.js.map +1 -0
  90. package/dist/db/migrate/cli.d.ts +2 -0
  91. package/dist/db/migrate/cli.d.ts.map +1 -0
  92. package/dist/db/migrate/cli.js +177 -0
  93. package/dist/db/migrate/cli.js.map +1 -0
  94. package/dist/db/migrate/defaults.d.ts +52 -0
  95. package/dist/db/migrate/defaults.d.ts.map +1 -0
  96. package/dist/db/migrate/defaults.js +126 -0
  97. package/dist/db/migrate/defaults.js.map +1 -0
  98. package/dist/db/migrate/runner.d.ts +16 -0
  99. package/dist/db/migrate/runner.d.ts.map +1 -0
  100. package/dist/db/migrate/runner.js +178 -0
  101. package/dist/db/migrate/runner.js.map +1 -0
  102. package/dist/db/migrations/20260101_000000_legacy_baseline.d.ts +4 -0
  103. package/dist/db/migrations/20260101_000000_legacy_baseline.d.ts.map +1 -0
  104. package/dist/db/migrations/20260101_000000_legacy_baseline.js +2240 -0
  105. package/dist/db/migrations/20260101_000000_legacy_baseline.js.map +1 -0
  106. package/dist/db/migrations/20260627_000001_custom_provider_modalities.d.ts +4 -0
  107. package/dist/db/migrations/20260627_000001_custom_provider_modalities.d.ts.map +1 -0
  108. package/dist/db/migrations/20260627_000001_custom_provider_modalities.js +27 -0
  109. package/dist/db/migrations/20260627_000001_custom_provider_modalities.js.map +1 -0
  110. package/dist/db/migrations/20260627_000002_catalog_model_state.d.ts +4 -0
  111. package/dist/db/migrations/20260627_000002_catalog_model_state.d.ts.map +1 -0
  112. package/dist/db/migrations/20260627_000002_catalog_model_state.js +30 -0
  113. package/dist/db/migrations/20260627_000002_catalog_model_state.js.map +1 -0
  114. package/dist/db/migrations/20260628_120000_request_aggregates.d.ts +6 -0
  115. package/dist/db/migrations/20260628_120000_request_aggregates.d.ts.map +1 -0
  116. package/dist/db/migrations/20260628_120000_request_aggregates.js +104 -0
  117. package/dist/db/migrations/20260628_120000_request_aggregates.js.map +1 -0
  118. package/dist/db/migrations/20260630_000001_github_gpt41_context.d.ts +9 -0
  119. package/dist/db/migrations/20260630_000001_github_gpt41_context.d.ts.map +1 -0
  120. package/dist/db/migrations/20260630_000001_github_gpt41_context.js +22 -0
  121. package/dist/db/migrations/20260630_000001_github_gpt41_context.js.map +1 -0
  122. package/dist/db/migrations/20260706_000001_request_client_info.d.ts +4 -0
  123. package/dist/db/migrations/20260706_000001_request_client_info.d.ts.map +1 -0
  124. package/dist/db/migrations/20260706_000001_request_client_info.js +30 -0
  125. package/dist/db/migrations/20260706_000001_request_client_info.js.map +1 -0
  126. package/dist/db/migrations/20260706_000002_custom_model_tool_support.d.ts +17 -0
  127. package/dist/db/migrations/20260706_000002_custom_model_tool_support.d.ts.map +1 -0
  128. package/dist/db/migrations/20260706_000002_custom_model_tool_support.js +20 -0
  129. package/dist/db/migrations/20260706_000002_custom_model_tool_support.js.map +1 -0
  130. package/dist/db/migrations/20260714_000001_profile_chain_backfill.d.ts +12 -0
  131. package/dist/db/migrations/20260714_000001_profile_chain_backfill.d.ts.map +1 -0
  132. package/dist/db/migrations/20260714_000001_profile_chain_backfill.js +45 -0
  133. package/dist/db/migrations/20260714_000001_profile_chain_backfill.js.map +1 -0
  134. package/dist/db/migrations/20260720_000001_key_health_error.d.ts +5 -0
  135. package/dist/db/migrations/20260720_000001_key_health_error.d.ts.map +1 -0
  136. package/dist/db/migrations/20260720_000001_key_health_error.js +16 -0
  137. package/dist/db/migrations/20260720_000001_key_health_error.js.map +1 -0
  138. package/dist/db/migrations/20260726_000001_cooldown_probe_provenance.d.ts +4 -0
  139. package/dist/db/migrations/20260726_000001_cooldown_probe_provenance.d.ts.map +1 -0
  140. package/dist/db/migrations/20260726_000001_cooldown_probe_provenance.js +38 -0
  141. package/dist/db/migrations/20260726_000001_cooldown_probe_provenance.js.map +1 -0
  142. package/dist/db/migrations/20260726_000002_request_attempts.d.ts +4 -0
  143. package/dist/db/migrations/20260726_000002_request_attempts.d.ts.map +1 -0
  144. package/dist/db/migrations/20260726_000002_request_attempts.js +43 -0
  145. package/dist/db/migrations/20260726_000002_request_attempts.js.map +1 -0
  146. package/dist/db/migrations/20260726_000003_model_source_provenance.d.ts +4 -0
  147. package/dist/db/migrations/20260726_000003_model_source_provenance.d.ts.map +1 -0
  148. package/dist/db/migrations/20260726_000003_model_source_provenance.js +80 -0
  149. package/dist/db/migrations/20260726_000003_model_source_provenance.js.map +1 -0
  150. package/dist/db/migrations/20260726_000004_media_model_meta.d.ts +4 -0
  151. package/dist/db/migrations/20260726_000004_media_model_meta.d.ts.map +1 -0
  152. package/dist/db/migrations/20260726_000004_media_model_meta.js +34 -0
  153. package/dist/db/migrations/20260726_000004_media_model_meta.js.map +1 -0
  154. package/dist/db/migrations/20260726_000005_request_served_model.d.ts +4 -0
  155. package/dist/db/migrations/20260726_000005_request_served_model.d.ts.map +1 -0
  156. package/dist/db/migrations/20260726_000005_request_served_model.js +31 -0
  157. package/dist/db/migrations/20260726_000005_request_served_model.js.map +1 -0
  158. package/dist/db/migrations/20260726_000006_attempt_error_summary.d.ts +4 -0
  159. package/dist/db/migrations/20260726_000006_attempt_error_summary.d.ts.map +1 -0
  160. package/dist/db/migrations/20260726_000006_attempt_error_summary.js +29 -0
  161. package/dist/db/migrations/20260726_000006_attempt_error_summary.js.map +1 -0
  162. package/dist/db/migrations/20260727_000001_agent_compatibility.d.ts +4 -0
  163. package/dist/db/migrations/20260727_000001_agent_compatibility.d.ts.map +1 -0
  164. package/dist/db/migrations/20260727_000001_agent_compatibility.js +41 -0
  165. package/dist/db/migrations/20260727_000001_agent_compatibility.js.map +1 -0
  166. package/dist/db/migrations/20260728_000001_tombstone_provenance.d.ts +12 -0
  167. package/dist/db/migrations/20260728_000001_tombstone_provenance.d.ts.map +1 -0
  168. package/dist/db/migrations/20260728_000001_tombstone_provenance.js +37 -0
  169. package/dist/db/migrations/20260728_000001_tombstone_provenance.js.map +1 -0
  170. package/dist/db/migrations/20260729_000001_custom_model_endpoint_identity.d.ts +4 -0
  171. package/dist/db/migrations/20260729_000001_custom_model_endpoint_identity.d.ts.map +1 -0
  172. package/dist/db/migrations/20260729_000001_custom_model_endpoint_identity.js +145 -0
  173. package/dist/db/migrations/20260729_000001_custom_model_endpoint_identity.js.map +1 -0
  174. package/dist/db/migrations/20260802_000001_custom_endpoint_host_labels.d.ts +6 -0
  175. package/dist/db/migrations/20260802_000001_custom_endpoint_host_labels.d.ts.map +1 -0
  176. package/dist/db/migrations/20260802_000001_custom_endpoint_host_labels.js +52 -0
  177. package/dist/db/migrations/20260802_000001_custom_endpoint_host_labels.js.map +1 -0
  178. package/dist/db/migrations/20260805_000001_key_model_scope.d.ts +6 -0
  179. package/dist/db/migrations/20260805_000001_key_model_scope.d.ts.map +1 -0
  180. package/dist/db/migrations/20260805_000001_key_model_scope.js +17 -0
  181. package/dist/db/migrations/20260805_000001_key_model_scope.js.map +1 -0
  182. package/dist/db/migrations/20260805_000002_client_profiles.d.ts +4 -0
  183. package/dist/db/migrations/20260805_000002_client_profiles.d.ts.map +1 -0
  184. package/dist/db/migrations/20260805_000002_client_profiles.js +33 -0
  185. package/dist/db/migrations/20260805_000002_client_profiles.js.map +1 -0
  186. package/dist/db/migrations/20260810_000001_api_key_proxy.d.ts +4 -0
  187. package/dist/db/migrations/20260810_000001_api_key_proxy.d.ts.map +1 -0
  188. package/dist/db/migrations/20260810_000001_api_key_proxy.js +33 -0
  189. package/dist/db/migrations/20260810_000001_api_key_proxy.js.map +1 -0
  190. package/dist/db/migrations/20260819_000001_custom_model_tombstones.d.ts +4 -0
  191. package/dist/db/migrations/20260819_000001_custom_model_tombstones.d.ts.map +1 -0
  192. package/dist/db/migrations/20260819_000001_custom_model_tombstones.js +19 -0
  193. package/dist/db/migrations/20260819_000001_custom_model_tombstones.js.map +1 -0
  194. package/dist/db/migrations/20260820_000001_playground_conversations.d.ts +4 -0
  195. package/dist/db/migrations/20260820_000001_playground_conversations.d.ts.map +1 -0
  196. package/dist/db/migrations/20260820_000001_playground_conversations.js +47 -0
  197. package/dist/db/migrations/20260820_000001_playground_conversations.js.map +1 -0
  198. package/dist/db/migrations/20260823_000001_server_logs.d.ts +4 -0
  199. package/dist/db/migrations/20260823_000001_server_logs.d.ts.map +1 -0
  200. package/dist/db/migrations/20260823_000001_server_logs.js +53 -0
  201. package/dist/db/migrations/20260823_000001_server_logs.js.map +1 -0
  202. package/dist/db/migrations/20260823_000002_backups_table.d.ts +8 -0
  203. package/dist/db/migrations/20260823_000002_backups_table.d.ts.map +1 -0
  204. package/dist/db/migrations/20260823_000002_backups_table.js +22 -0
  205. package/dist/db/migrations/20260823_000002_backups_table.js.map +1 -0
  206. package/dist/db/migrations/20260823_000003_attempt_key_label.d.ts +19 -0
  207. package/dist/db/migrations/20260823_000003_attempt_key_label.d.ts.map +1 -0
  208. package/dist/db/migrations/20260823_000003_attempt_key_label.js +28 -0
  209. package/dist/db/migrations/20260823_000003_attempt_key_label.js.map +1 -0
  210. package/dist/db/migrations/20260823_000004_profile_auto_include.d.ts +14 -0
  211. package/dist/db/migrations/20260823_000004_profile_auto_include.d.ts.map +1 -0
  212. package/dist/db/migrations/20260823_000004_profile_auto_include.js +25 -0
  213. package/dist/db/migrations/20260823_000004_profile_auto_include.js.map +1 -0
  214. package/dist/db/migrations/20260901_000001_idempotency_claims.d.ts +4 -0
  215. package/dist/db/migrations/20260901_000001_idempotency_claims.d.ts.map +1 -0
  216. package/dist/db/migrations/20260901_000001_idempotency_claims.js +46 -0
  217. package/dist/db/migrations/20260901_000001_idempotency_claims.js.map +1 -0
  218. package/dist/db/migrations/20260901_000002_quota_observation_lookup.d.ts +4 -0
  219. package/dist/db/migrations/20260901_000002_quota_observation_lookup.d.ts.map +1 -0
  220. package/dist/db/migrations/20260901_000002_quota_observation_lookup.js +37 -0
  221. package/dist/db/migrations/20260901_000002_quota_observation_lookup.js.map +1 -0
  222. package/dist/db/migrations/20260901_000003_request_caller.d.ts +4 -0
  223. package/dist/db/migrations/20260901_000003_request_caller.d.ts.map +1 -0
  224. package/dist/db/migrations/20260901_000003_request_caller.js +27 -0
  225. package/dist/db/migrations/20260901_000003_request_caller.js.map +1 -0
  226. package/dist/db/migrations/20260902_000001_analytics_latency_percentile_index.d.ts +4 -0
  227. package/dist/db/migrations/20260902_000001_analytics_latency_percentile_index.d.ts.map +1 -0
  228. package/dist/db/migrations/20260902_000001_analytics_latency_percentile_index.js +35 -0
  229. package/dist/db/migrations/20260902_000001_analytics_latency_percentile_index.js.map +1 -0
  230. package/dist/db/migrations/20260903_000001_mcp_enabled_default.d.ts +4 -0
  231. package/dist/db/migrations/20260903_000001_mcp_enabled_default.d.ts.map +1 -0
  232. package/dist/db/migrations/20260903_000001_mcp_enabled_default.js +37 -0
  233. package/dist/db/migrations/20260903_000001_mcp_enabled_default.js.map +1 -0
  234. package/dist/db/migrations/20260903_000002_response_cache.d.ts +4 -0
  235. package/dist/db/migrations/20260903_000002_response_cache.d.ts.map +1 -0
  236. package/dist/db/migrations/20260903_000002_response_cache.js +53 -0
  237. package/dist/db/migrations/20260903_000002_response_cache.js.map +1 -0
  238. package/dist/db/migrations/20260904_000001_key_monthly_budget.d.ts +13 -0
  239. package/dist/db/migrations/20260904_000001_key_monthly_budget.d.ts.map +1 -0
  240. package/dist/db/migrations/20260904_000001_key_monthly_budget.js +35 -0
  241. package/dist/db/migrations/20260904_000001_key_monthly_budget.js.map +1 -0
  242. package/dist/db/migrations/20260913_000001_request_model_attribution.d.ts +4 -0
  243. package/dist/db/migrations/20260913_000001_request_model_attribution.d.ts.map +1 -0
  244. package/dist/db/migrations/20260913_000001_request_model_attribution.js +68 -0
  245. package/dist/db/migrations/20260913_000001_request_model_attribution.js.map +1 -0
  246. package/dist/db/migrations/20260914_000001_key_monthly_usage.d.ts +5 -0
  247. package/dist/db/migrations/20260914_000001_key_monthly_usage.d.ts.map +1 -0
  248. package/dist/db/migrations/20260914_000001_key_monthly_usage.js +37 -0
  249. package/dist/db/migrations/20260914_000001_key_monthly_usage.js.map +1 -0
  250. package/dist/db/migrations/20260915_000001_quota_snapshot_freshness.d.ts +4 -0
  251. package/dist/db/migrations/20260915_000001_quota_snapshot_freshness.d.ts.map +1 -0
  252. package/dist/db/migrations/20260915_000001_quota_snapshot_freshness.js +28 -0
  253. package/dist/db/migrations/20260915_000001_quota_snapshot_freshness.js.map +1 -0
  254. package/dist/db/migrations/20261004_000001_unified_key_sr_prefix.d.ts +4 -0
  255. package/dist/db/migrations/20261004_000001_unified_key_sr_prefix.d.ts.map +1 -0
  256. package/dist/db/migrations/20261004_000001_unified_key_sr_prefix.js +25 -0
  257. package/dist/db/migrations/20261004_000001_unified_key_sr_prefix.js.map +1 -0
  258. package/dist/db/migrations/20261005_000001_key_budget_period.d.ts +10 -0
  259. package/dist/db/migrations/20261005_000001_key_budget_period.d.ts.map +1 -0
  260. package/dist/db/migrations/20261005_000001_key_budget_period.js +78 -0
  261. package/dist/db/migrations/20261005_000001_key_budget_period.js.map +1 -0
  262. package/dist/db/migrations/20261005_000002_key_budget_period_auto.d.ts +21 -0
  263. package/dist/db/migrations/20261005_000002_key_budget_period_auto.d.ts.map +1 -0
  264. package/dist/db/migrations/20261005_000002_key_budget_period_auto.js +41 -0
  265. package/dist/db/migrations/20261005_000002_key_budget_period_auto.js.map +1 -0
  266. package/dist/db/model-pricing.d.ts +39 -0
  267. package/dist/db/model-pricing.d.ts.map +1 -0
  268. package/dist/db/model-pricing.js +274 -0
  269. package/dist/db/model-pricing.js.map +1 -0
  270. package/dist/db/node-sqlite.d.ts +9 -0
  271. package/dist/db/node-sqlite.d.ts.map +1 -0
  272. package/dist/db/node-sqlite.js +87 -0
  273. package/dist/db/node-sqlite.js.map +1 -0
  274. package/dist/db/types.d.ts +22 -0
  275. package/dist/db/types.d.ts.map +1 -0
  276. package/dist/db/types.js +2 -0
  277. package/dist/db/types.js.map +1 -0
  278. package/dist/docs/docs-page.d.ts +2 -0
  279. package/dist/docs/docs-page.d.ts.map +1 -0
  280. package/dist/docs/docs-page.js +319 -0
  281. package/dist/docs/docs-page.js.map +1 -0
  282. package/dist/docs/openapi.d.ts +1896 -0
  283. package/dist/docs/openapi.d.ts.map +1 -0
  284. package/dist/docs/openapi.js +1096 -0
  285. package/dist/docs/openapi.js.map +1 -0
  286. package/dist/env.d.ts +2 -0
  287. package/dist/env.d.ts.map +1 -0
  288. package/dist/env.js +9 -0
  289. package/dist/env.js.map +1 -0
  290. package/dist/index.d.ts +2 -0
  291. package/dist/index.d.ts.map +1 -0
  292. package/dist/index.js +144 -0
  293. package/dist/index.js.map +1 -0
  294. package/dist/lib/anthropic-documents.d.ts +41 -0
  295. package/dist/lib/anthropic-documents.d.ts.map +1 -0
  296. package/dist/lib/anthropic-documents.js +132 -0
  297. package/dist/lib/anthropic-documents.js.map +1 -0
  298. package/dist/lib/app-version.d.ts +5 -0
  299. package/dist/lib/app-version.d.ts.map +1 -0
  300. package/dist/lib/app-version.js +61 -0
  301. package/dist/lib/app-version.js.map +1 -0
  302. package/dist/lib/attempt-trace.d.ts +23 -0
  303. package/dist/lib/attempt-trace.d.ts.map +1 -0
  304. package/dist/lib/attempt-trace.js +32 -0
  305. package/dist/lib/attempt-trace.js.map +1 -0
  306. package/dist/lib/budget.d.ts +6 -0
  307. package/dist/lib/budget.d.ts.map +1 -0
  308. package/dist/lib/budget.js +40 -0
  309. package/dist/lib/budget.js.map +1 -0
  310. package/dist/lib/client-classifier.d.ts +10 -0
  311. package/dist/lib/client-classifier.d.ts.map +1 -0
  312. package/dist/lib/client-classifier.js +126 -0
  313. package/dist/lib/client-classifier.js.map +1 -0
  314. package/dist/lib/client-context.d.ts +10 -0
  315. package/dist/lib/client-context.d.ts.map +1 -0
  316. package/dist/lib/client-context.js +39 -0
  317. package/dist/lib/client-context.js.map +1 -0
  318. package/dist/lib/config.d.ts +33 -0
  319. package/dist/lib/config.d.ts.map +1 -0
  320. package/dist/lib/config.js +94 -0
  321. package/dist/lib/config.js.map +1 -0
  322. package/dist/lib/content.d.ts +33 -0
  323. package/dist/lib/content.d.ts.map +1 -0
  324. package/dist/lib/content.js +201 -0
  325. package/dist/lib/content.js.map +1 -0
  326. package/dist/lib/credential.d.ts +11 -0
  327. package/dist/lib/credential.d.ts.map +1 -0
  328. package/dist/lib/credential.js +18 -0
  329. package/dist/lib/credential.js.map +1 -0
  330. package/dist/lib/crypto.d.ts +31 -0
  331. package/dist/lib/crypto.d.ts.map +1 -0
  332. package/dist/lib/crypto.js +206 -0
  333. package/dist/lib/crypto.js.map +1 -0
  334. package/dist/lib/custom-provider-cleanup.d.ts +13 -0
  335. package/dist/lib/custom-provider-cleanup.d.ts.map +1 -0
  336. package/dist/lib/custom-provider-cleanup.js +43 -0
  337. package/dist/lib/custom-provider-cleanup.js.map +1 -0
  338. package/dist/lib/db-backup.d.ts +31 -0
  339. package/dist/lib/db-backup.d.ts.map +1 -0
  340. package/dist/lib/db-backup.js +255 -0
  341. package/dist/lib/db-backup.js.map +1 -0
  342. package/dist/lib/endpoint-scope.d.ts +42 -0
  343. package/dist/lib/endpoint-scope.d.ts.map +1 -0
  344. package/dist/lib/endpoint-scope.js +95 -0
  345. package/dist/lib/endpoint-scope.js.map +1 -0
  346. package/dist/lib/env-drift.d.ts +17 -0
  347. package/dist/lib/env-drift.d.ts.map +1 -0
  348. package/dist/lib/env-drift.js +108 -0
  349. package/dist/lib/env-drift.js.map +1 -0
  350. package/dist/lib/error-classify.d.ts +40 -0
  351. package/dist/lib/error-classify.d.ts.map +1 -0
  352. package/dist/lib/error-classify.js +687 -0
  353. package/dist/lib/error-classify.js.map +1 -0
  354. package/dist/lib/error-redaction.d.ts +8 -0
  355. package/dist/lib/error-redaction.d.ts.map +1 -0
  356. package/dist/lib/error-redaction.js +45 -0
  357. package/dist/lib/error-redaction.js.map +1 -0
  358. package/dist/lib/fallback-loop.d.ts +306 -0
  359. package/dist/lib/fallback-loop.d.ts.map +1 -0
  360. package/dist/lib/fallback-loop.js +1336 -0
  361. package/dist/lib/fallback-loop.js.map +1 -0
  362. package/dist/lib/file-permissions.d.ts +98 -0
  363. package/dist/lib/file-permissions.d.ts.map +1 -0
  364. package/dist/lib/file-permissions.js +159 -0
  365. package/dist/lib/file-permissions.js.map +1 -0
  366. package/dist/lib/gemini-wire.d.ts +76 -0
  367. package/dist/lib/gemini-wire.d.ts.map +1 -0
  368. package/dist/lib/gemini-wire.js +392 -0
  369. package/dist/lib/gemini-wire.js.map +1 -0
  370. package/dist/lib/guardrails.d.ts +37 -0
  371. package/dist/lib/guardrails.d.ts.map +1 -0
  372. package/dist/lib/guardrails.js +106 -0
  373. package/dist/lib/guardrails.js.map +1 -0
  374. package/dist/lib/header-value.d.ts +8 -0
  375. package/dist/lib/header-value.d.ts.map +1 -0
  376. package/dist/lib/header-value.js +55 -0
  377. package/dist/lib/header-value.js.map +1 -0
  378. package/dist/lib/image-normalize.d.ts +21 -0
  379. package/dist/lib/image-normalize.d.ts.map +1 -0
  380. package/dist/lib/image-normalize.js +218 -0
  381. package/dist/lib/image-normalize.js.map +1 -0
  382. package/dist/lib/inbound-chat.d.ts +48 -0
  383. package/dist/lib/inbound-chat.d.ts.map +1 -0
  384. package/dist/lib/inbound-chat.js +406 -0
  385. package/dist/lib/inbound-chat.js.map +1 -0
  386. package/dist/lib/key-parser.d.ts +79 -0
  387. package/dist/lib/key-parser.d.ts.map +1 -0
  388. package/dist/lib/key-parser.js +701 -0
  389. package/dist/lib/key-parser.js.map +1 -0
  390. package/dist/lib/key-proxy.d.ts +49 -0
  391. package/dist/lib/key-proxy.d.ts.map +1 -0
  392. package/dist/lib/key-proxy.js +92 -0
  393. package/dist/lib/key-proxy.js.map +1 -0
  394. package/dist/lib/log-redaction.d.ts +28 -0
  395. package/dist/lib/log-redaction.d.ts.map +1 -0
  396. package/dist/lib/log-redaction.js +166 -0
  397. package/dist/lib/log-redaction.js.map +1 -0
  398. package/dist/lib/model-scope.d.ts +9 -0
  399. package/dist/lib/model-scope.d.ts.map +1 -0
  400. package/dist/lib/model-scope.js +29 -0
  401. package/dist/lib/model-scope.js.map +1 -0
  402. package/dist/lib/one-time-code.d.ts +15 -0
  403. package/dist/lib/one-time-code.d.ts.map +1 -0
  404. package/dist/lib/one-time-code.js +41 -0
  405. package/dist/lib/one-time-code.js.map +1 -0
  406. package/dist/lib/output-cap.d.ts +24 -0
  407. package/dist/lib/output-cap.d.ts.map +1 -0
  408. package/dist/lib/output-cap.js +80 -0
  409. package/dist/lib/output-cap.js.map +1 -0
  410. package/dist/lib/password.d.ts +3 -0
  411. package/dist/lib/password.d.ts.map +1 -0
  412. package/dist/lib/password.js +27 -0
  413. package/dist/lib/password.js.map +1 -0
  414. package/dist/lib/process-safety-net.d.ts +32 -0
  415. package/dist/lib/process-safety-net.d.ts.map +1 -0
  416. package/dist/lib/process-safety-net.js +123 -0
  417. package/dist/lib/process-safety-net.js.map +1 -0
  418. package/dist/lib/provider-identity.d.ts +37 -0
  419. package/dist/lib/provider-identity.d.ts.map +1 -0
  420. package/dist/lib/provider-identity.js +110 -0
  421. package/dist/lib/provider-identity.js.map +1 -0
  422. package/dist/lib/provider-size-parser.d.ts +6 -0
  423. package/dist/lib/provider-size-parser.d.ts.map +1 -0
  424. package/dist/lib/provider-size-parser.js +72 -0
  425. package/dist/lib/provider-size-parser.js.map +1 -0
  426. package/dist/lib/provider-timeout.d.ts +17 -0
  427. package/dist/lib/provider-timeout.d.ts.map +1 -0
  428. package/dist/lib/provider-timeout.js +70 -0
  429. package/dist/lib/provider-timeout.js.map +1 -0
  430. package/dist/lib/proxy.d.ts +180 -0
  431. package/dist/lib/proxy.d.ts.map +1 -0
  432. package/dist/lib/proxy.js +1002 -0
  433. package/dist/lib/proxy.js.map +1 -0
  434. package/dist/lib/request-log.d.ts +4 -0
  435. package/dist/lib/request-log.d.ts.map +1 -0
  436. package/dist/lib/request-log.js +161 -0
  437. package/dist/lib/request-log.js.map +1 -0
  438. package/dist/lib/reset-code.d.ts +5 -0
  439. package/dist/lib/reset-code.d.ts.map +1 -0
  440. package/dist/lib/reset-code.js +39 -0
  441. package/dist/lib/reset-code.js.map +1 -0
  442. package/dist/lib/retry-hint.d.ts +14 -0
  443. package/dist/lib/retry-hint.d.ts.map +1 -0
  444. package/dist/lib/retry-hint.js +36 -0
  445. package/dist/lib/retry-hint.js.map +1 -0
  446. package/dist/lib/route-live.d.ts +29 -0
  447. package/dist/lib/route-live.d.ts.map +1 -0
  448. package/dist/lib/route-live.js +97 -0
  449. package/dist/lib/route-live.js.map +1 -0
  450. package/dist/lib/sampling-params.d.ts +183 -0
  451. package/dist/lib/sampling-params.d.ts.map +1 -0
  452. package/dist/lib/sampling-params.js +386 -0
  453. package/dist/lib/sampling-params.js.map +1 -0
  454. package/dist/lib/scheduler.d.ts +13 -0
  455. package/dist/lib/scheduler.d.ts.map +1 -0
  456. package/dist/lib/scheduler.js +11 -0
  457. package/dist/lib/scheduler.js.map +1 -0
  458. package/dist/lib/served-model.d.ts +16 -0
  459. package/dist/lib/served-model.d.ts.map +1 -0
  460. package/dist/lib/served-model.js +0 -0
  461. package/dist/lib/served-model.js.map +1 -0
  462. package/dist/lib/server-logs.d.ts +132 -0
  463. package/dist/lib/server-logs.d.ts.map +1 -0
  464. package/dist/lib/server-logs.js +473 -0
  465. package/dist/lib/server-logs.js.map +1 -0
  466. package/dist/lib/setup-code.d.ts +5 -0
  467. package/dist/lib/setup-code.d.ts.map +1 -0
  468. package/dist/lib/setup-code.js +35 -0
  469. package/dist/lib/setup-code.js.map +1 -0
  470. package/dist/lib/structured-output.d.ts +9 -0
  471. package/dist/lib/structured-output.d.ts.map +1 -0
  472. package/dist/lib/structured-output.js +72 -0
  473. package/dist/lib/structured-output.js.map +1 -0
  474. package/dist/lib/system-prompt.d.ts +30 -0
  475. package/dist/lib/system-prompt.d.ts.map +1 -0
  476. package/dist/lib/system-prompt.js +66 -0
  477. package/dist/lib/system-prompt.js.map +1 -0
  478. package/dist/lib/task-type.d.ts +18 -0
  479. package/dist/lib/task-type.d.ts.map +1 -0
  480. package/dist/lib/task-type.js +92 -0
  481. package/dist/lib/task-type.js.map +1 -0
  482. package/dist/lib/think-tags.d.ts +37 -0
  483. package/dist/lib/think-tags.d.ts.map +1 -0
  484. package/dist/lib/think-tags.js +203 -0
  485. package/dist/lib/think-tags.js.map +1 -0
  486. package/dist/lib/tool-args.d.ts +36 -0
  487. package/dist/lib/tool-args.d.ts.map +1 -0
  488. package/dist/lib/tool-args.js +190 -0
  489. package/dist/lib/tool-args.js.map +1 -0
  490. package/dist/lib/tool-call-rescue.d.ts +62 -0
  491. package/dist/lib/tool-call-rescue.d.ts.map +1 -0
  492. package/dist/lib/tool-call-rescue.js +258 -0
  493. package/dist/lib/tool-call-rescue.js.map +1 -0
  494. package/dist/lib/tool-capability.d.ts +13 -0
  495. package/dist/lib/tool-capability.d.ts.map +1 -0
  496. package/dist/lib/tool-capability.js +70 -0
  497. package/dist/lib/tool-capability.js.map +1 -0
  498. package/dist/lib/tool-validate.d.ts +44 -0
  499. package/dist/lib/tool-validate.d.ts.map +1 -0
  500. package/dist/lib/tool-validate.js +165 -0
  501. package/dist/lib/tool-validate.js.map +1 -0
  502. package/dist/lib/ttfb-budget.d.ts +13 -0
  503. package/dist/lib/ttfb-budget.d.ts.map +1 -0
  504. package/dist/lib/ttfb-budget.js +153 -0
  505. package/dist/lib/ttfb-budget.js.map +1 -0
  506. package/dist/lib/url-guard.d.ts +47 -0
  507. package/dist/lib/url-guard.d.ts.map +1 -0
  508. package/dist/lib/url-guard.js +229 -0
  509. package/dist/lib/url-guard.js.map +1 -0
  510. package/dist/lib/wake-detect.d.ts +12 -0
  511. package/dist/lib/wake-detect.d.ts.map +1 -0
  512. package/dist/lib/wake-detect.js +99 -0
  513. package/dist/lib/wake-detect.js.map +1 -0
  514. package/dist/middleware/errorHandler.d.ts +3 -0
  515. package/dist/middleware/errorHandler.d.ts.map +1 -0
  516. package/dist/middleware/errorHandler.js +45 -0
  517. package/dist/middleware/errorHandler.js.map +1 -0
  518. package/dist/middleware/rateLimit.d.ts +4 -0
  519. package/dist/middleware/rateLimit.d.ts.map +1 -0
  520. package/dist/middleware/rateLimit.js +125 -0
  521. package/dist/middleware/rateLimit.js.map +1 -0
  522. package/dist/middleware/requireAuth.d.ts +3 -0
  523. package/dist/middleware/requireAuth.d.ts.map +1 -0
  524. package/dist/middleware/requireAuth.js +17 -0
  525. package/dist/middleware/requireAuth.js.map +1 -0
  526. package/dist/providers/aclide.d.ts +18 -0
  527. package/dist/providers/aclide.d.ts.map +1 -0
  528. package/dist/providers/aclide.js +174 -0
  529. package/dist/providers/aclide.js.map +1 -0
  530. package/dist/providers/aihorde.d.ts +42 -0
  531. package/dist/providers/aihorde.d.ts.map +1 -0
  532. package/dist/providers/aihorde.js +192 -0
  533. package/dist/providers/aihorde.js.map +1 -0
  534. package/dist/providers/airforce.d.ts +11 -0
  535. package/dist/providers/airforce.d.ts.map +1 -0
  536. package/dist/providers/airforce.js +32 -0
  537. package/dist/providers/airforce.js.map +1 -0
  538. package/dist/providers/base.d.ts +177 -0
  539. package/dist/providers/base.d.ts.map +1 -0
  540. package/dist/providers/base.js +354 -0
  541. package/dist/providers/base.js.map +1 -0
  542. package/dist/providers/blaze.d.ts +11 -0
  543. package/dist/providers/blaze.d.ts.map +1 -0
  544. package/dist/providers/blaze.js +33 -0
  545. package/dist/providers/blaze.js.map +1 -0
  546. package/dist/providers/blockrun.d.ts +14 -0
  547. package/dist/providers/blockrun.d.ts.map +1 -0
  548. package/dist/providers/blockrun.js +36 -0
  549. package/dist/providers/blockrun.js.map +1 -0
  550. package/dist/providers/clod.d.ts +11 -0
  551. package/dist/providers/clod.d.ts.map +1 -0
  552. package/dist/providers/clod.js +39 -0
  553. package/dist/providers/clod.js.map +1 -0
  554. package/dist/providers/cloudflare.d.ts +15 -0
  555. package/dist/providers/cloudflare.d.ts.map +1 -0
  556. package/dist/providers/cloudflare.js +175 -0
  557. package/dist/providers/cloudflare.js.map +1 -0
  558. package/dist/providers/cohere.d.ts +11 -0
  559. package/dist/providers/cohere.d.ts.map +1 -0
  560. package/dist/providers/cohere.js +117 -0
  561. package/dist/providers/cohere.js.map +1 -0
  562. package/dist/providers/dreamprompting.d.ts +11 -0
  563. package/dist/providers/dreamprompting.d.ts.map +1 -0
  564. package/dist/providers/dreamprompting.js +41 -0
  565. package/dist/providers/dreamprompting.js.map +1 -0
  566. package/dist/providers/electronhub.d.ts +10 -0
  567. package/dist/providers/electronhub.d.ts.map +1 -0
  568. package/dist/providers/electronhub.js +56 -0
  569. package/dist/providers/electronhub.js.map +1 -0
  570. package/dist/providers/experiential.d.ts +10 -0
  571. package/dist/providers/experiential.d.ts.map +1 -0
  572. package/dist/providers/experiential.js +33 -0
  573. package/dist/providers/experiential.js.map +1 -0
  574. package/dist/providers/gizmo.d.ts +15 -0
  575. package/dist/providers/gizmo.d.ts.map +1 -0
  576. package/dist/providers/gizmo.js +54 -0
  577. package/dist/providers/gizmo.js.map +1 -0
  578. package/dist/providers/google.d.ts +30 -0
  579. package/dist/providers/google.d.ts.map +1 -0
  580. package/dist/providers/google.js +766 -0
  581. package/dist/providers/google.js.map +1 -0
  582. package/dist/providers/index.d.ts +13 -0
  583. package/dist/providers/index.d.ts.map +1 -0
  584. package/dist/providers/index.js +561 -0
  585. package/dist/providers/index.js.map +1 -0
  586. package/dist/providers/llmtr.d.ts +15 -0
  587. package/dist/providers/llmtr.d.ts.map +1 -0
  588. package/dist/providers/llmtr.js +51 -0
  589. package/dist/providers/llmtr.js.map +1 -0
  590. package/dist/providers/logfare.d.ts +11 -0
  591. package/dist/providers/logfare.d.ts.map +1 -0
  592. package/dist/providers/logfare.js +33 -0
  593. package/dist/providers/logfare.js.map +1 -0
  594. package/dist/providers/lucidity.d.ts +11 -0
  595. package/dist/providers/lucidity.d.ts.map +1 -0
  596. package/dist/providers/lucidity.js +37 -0
  597. package/dist/providers/lucidity.js.map +1 -0
  598. package/dist/providers/modelscope.d.ts +50 -0
  599. package/dist/providers/modelscope.d.ts.map +1 -0
  600. package/dist/providers/modelscope.js +143 -0
  601. package/dist/providers/modelscope.js.map +1 -0
  602. package/dist/providers/moondream.d.ts +17 -0
  603. package/dist/providers/moondream.d.ts.map +1 -0
  604. package/dist/providers/moondream.js +140 -0
  605. package/dist/providers/moondream.js.map +1 -0
  606. package/dist/providers/openai-compat.d.ts +140 -0
  607. package/dist/providers/openai-compat.d.ts.map +1 -0
  608. package/dist/providers/openai-compat.js +537 -0
  609. package/dist/providers/openai-compat.js.map +1 -0
  610. package/dist/providers/opencode-free.d.ts +24 -0
  611. package/dist/providers/opencode-free.d.ts.map +1 -0
  612. package/dist/providers/opencode-free.js +262 -0
  613. package/dist/providers/opencode-free.js.map +1 -0
  614. package/dist/providers/pollinations.d.ts +32 -0
  615. package/dist/providers/pollinations.d.ts.map +1 -0
  616. package/dist/providers/pollinations.js +65 -0
  617. package/dist/providers/pollinations.js.map +1 -0
  618. package/dist/providers/router9.d.ts +16 -0
  619. package/dist/providers/router9.d.ts.map +1 -0
  620. package/dist/providers/router9.js +42 -0
  621. package/dist/providers/router9.js.map +1 -0
  622. package/dist/providers/sail.d.ts +44 -0
  623. package/dist/providers/sail.d.ts.map +1 -0
  624. package/dist/providers/sail.js +302 -0
  625. package/dist/providers/sail.js.map +1 -0
  626. package/dist/providers/septor.d.ts +10 -0
  627. package/dist/providers/septor.d.ts.map +1 -0
  628. package/dist/providers/septor.js +36 -0
  629. package/dist/providers/septor.js.map +1 -0
  630. package/dist/providers/speechify.d.ts +14 -0
  631. package/dist/providers/speechify.d.ts.map +1 -0
  632. package/dist/providers/speechify.js +28 -0
  633. package/dist/providers/speechify.js.map +1 -0
  634. package/dist/providers/speka.d.ts +12 -0
  635. package/dist/providers/speka.d.ts.map +1 -0
  636. package/dist/providers/speka.js +37 -0
  637. package/dist/providers/speka.js.map +1 -0
  638. package/dist/providers/waterfall.d.ts +11 -0
  639. package/dist/providers/waterfall.d.ts.map +1 -0
  640. package/dist/providers/waterfall.js +33 -0
  641. package/dist/providers/waterfall.js.map +1 -0
  642. package/dist/providers/xkiro.d.ts +71 -0
  643. package/dist/providers/xkiro.d.ts.map +1 -0
  644. package/dist/providers/xkiro.js +119 -0
  645. package/dist/providers/xkiro.js.map +1 -0
  646. package/dist/providers/zhipu.d.ts +43 -0
  647. package/dist/providers/zhipu.d.ts.map +1 -0
  648. package/dist/providers/zhipu.js +95 -0
  649. package/dist/providers/zhipu.js.map +1 -0
  650. package/dist/routes/analytics.d.ts +2 -0
  651. package/dist/routes/analytics.d.ts.map +1 -0
  652. package/dist/routes/analytics.js +738 -0
  653. package/dist/routes/analytics.js.map +1 -0
  654. package/dist/routes/anthropic.d.ts +7 -0
  655. package/dist/routes/anthropic.d.ts.map +1 -0
  656. package/dist/routes/anthropic.js +1076 -0
  657. package/dist/routes/anthropic.js.map +1 -0
  658. package/dist/routes/auth.d.ts +2 -0
  659. package/dist/routes/auth.d.ts.map +1 -0
  660. package/dist/routes/auth.js +310 -0
  661. package/dist/routes/auth.js.map +1 -0
  662. package/dist/routes/backups.d.ts +2 -0
  663. package/dist/routes/backups.d.ts.map +1 -0
  664. package/dist/routes/backups.js +127 -0
  665. package/dist/routes/backups.js.map +1 -0
  666. package/dist/routes/cache.d.ts +2 -0
  667. package/dist/routes/cache.d.ts.map +1 -0
  668. package/dist/routes/cache.js +40 -0
  669. package/dist/routes/cache.js.map +1 -0
  670. package/dist/routes/client-profiles.d.ts +2 -0
  671. package/dist/routes/client-profiles.d.ts.map +1 -0
  672. package/dist/routes/client-profiles.js +128 -0
  673. package/dist/routes/client-profiles.js.map +1 -0
  674. package/dist/routes/compression.d.ts +2 -0
  675. package/dist/routes/compression.d.ts.map +1 -0
  676. package/dist/routes/compression.js +82 -0
  677. package/dist/routes/compression.js.map +1 -0
  678. package/dist/routes/conversations.d.ts +3 -0
  679. package/dist/routes/conversations.d.ts.map +1 -0
  680. package/dist/routes/conversations.js +215 -0
  681. package/dist/routes/conversations.js.map +1 -0
  682. package/dist/routes/docs.d.ts +2 -0
  683. package/dist/routes/docs.d.ts.map +1 -0
  684. package/dist/routes/docs.js +21 -0
  685. package/dist/routes/docs.js.map +1 -0
  686. package/dist/routes/embeddings.d.ts +2 -0
  687. package/dist/routes/embeddings.d.ts.map +1 -0
  688. package/dist/routes/embeddings.js +255 -0
  689. package/dist/routes/embeddings.js.map +1 -0
  690. package/dist/routes/fallback.d.ts +2 -0
  691. package/dist/routes/fallback.d.ts.map +1 -0
  692. package/dist/routes/fallback.js +631 -0
  693. package/dist/routes/fallback.js.map +1 -0
  694. package/dist/routes/free-tier.d.ts +22 -0
  695. package/dist/routes/free-tier.d.ts.map +1 -0
  696. package/dist/routes/free-tier.js +175 -0
  697. package/dist/routes/free-tier.js.map +1 -0
  698. package/dist/routes/gemini.d.ts +2 -0
  699. package/dist/routes/gemini.d.ts.map +1 -0
  700. package/dist/routes/gemini.js +218 -0
  701. package/dist/routes/gemini.js.map +1 -0
  702. package/dist/routes/health.d.ts +2 -0
  703. package/dist/routes/health.d.ts.map +1 -0
  704. package/dist/routes/health.js +72 -0
  705. package/dist/routes/health.js.map +1 -0
  706. package/dist/routes/keys.d.ts +17 -0
  707. package/dist/routes/keys.d.ts.map +1 -0
  708. package/dist/routes/keys.js +1787 -0
  709. package/dist/routes/keys.js.map +1 -0
  710. package/dist/routes/logs.d.ts +2 -0
  711. package/dist/routes/logs.d.ts.map +1 -0
  712. package/dist/routes/logs.js +88 -0
  713. package/dist/routes/logs.js.map +1 -0
  714. package/dist/routes/mcp.d.ts +4 -0
  715. package/dist/routes/mcp.d.ts.map +1 -0
  716. package/dist/routes/mcp.js +563 -0
  717. package/dist/routes/mcp.js.map +1 -0
  718. package/dist/routes/media.d.ts +2 -0
  719. package/dist/routes/media.d.ts.map +1 -0
  720. package/dist/routes/media.js +180 -0
  721. package/dist/routes/media.js.map +1 -0
  722. package/dist/routes/models.d.ts +2 -0
  723. package/dist/routes/models.d.ts.map +1 -0
  724. package/dist/routes/models.js +439 -0
  725. package/dist/routes/models.js.map +1 -0
  726. package/dist/routes/ollama.d.ts +7 -0
  727. package/dist/routes/ollama.d.ts.map +1 -0
  728. package/dist/routes/ollama.js +595 -0
  729. package/dist/routes/ollama.js.map +1 -0
  730. package/dist/routes/profiles.d.ts +3 -0
  731. package/dist/routes/profiles.d.ts.map +1 -0
  732. package/dist/routes/profiles.js +431 -0
  733. package/dist/routes/profiles.js.map +1 -0
  734. package/dist/routes/proxy.d.ts +32 -0
  735. package/dist/routes/proxy.d.ts.map +1 -0
  736. package/dist/routes/proxy.js +2793 -0
  737. package/dist/routes/proxy.js.map +1 -0
  738. package/dist/routes/responses.d.ts +1161 -0
  739. package/dist/routes/responses.d.ts.map +1 -0
  740. package/dist/routes/responses.js +1521 -0
  741. package/dist/routes/responses.js.map +1 -0
  742. package/dist/routes/settings.d.ts +2 -0
  743. package/dist/routes/settings.d.ts.map +1 -0
  744. package/dist/routes/settings.js +549 -0
  745. package/dist/routes/settings.js.map +1 -0
  746. package/dist/routes/status.d.ts +3 -0
  747. package/dist/routes/status.d.ts.map +1 -0
  748. package/dist/routes/status.js +214 -0
  749. package/dist/routes/status.js.map +1 -0
  750. package/dist/routes/update.d.ts +31 -0
  751. package/dist/routes/update.d.ts.map +1 -0
  752. package/dist/routes/update.js +536 -0
  753. package/dist/routes/update.js.map +1 -0
  754. package/dist/routes/url-tokens.d.ts +2 -0
  755. package/dist/routes/url-tokens.d.ts.map +1 -0
  756. package/dist/routes/url-tokens.js +31 -0
  757. package/dist/routes/url-tokens.js.map +1 -0
  758. package/dist/scripts/export-catalog.d.ts +2 -0
  759. package/dist/scripts/export-catalog.d.ts.map +1 -0
  760. package/dist/scripts/export-catalog.js +114 -0
  761. package/dist/scripts/export-catalog.js.map +1 -0
  762. package/dist/scripts/rotate-encryption-key.d.ts +81 -0
  763. package/dist/scripts/rotate-encryption-key.d.ts.map +1 -0
  764. package/dist/scripts/rotate-encryption-key.js +232 -0
  765. package/dist/scripts/rotate-encryption-key.js.map +1 -0
  766. package/dist/scripts/routing-sim.d.ts +2 -0
  767. package/dist/scripts/routing-sim.d.ts.map +1 -0
  768. package/dist/scripts/routing-sim.js +130 -0
  769. package/dist/scripts/routing-sim.js.map +1 -0
  770. package/dist/scripts/test-all-models.d.ts +2 -0
  771. package/dist/scripts/test-all-models.d.ts.map +1 -0
  772. package/dist/scripts/test-all-models.js +56 -0
  773. package/dist/scripts/test-all-models.js.map +1 -0
  774. package/dist/services/anthropic-map.d.ts +39 -0
  775. package/dist/services/anthropic-map.d.ts.map +1 -0
  776. package/dist/services/anthropic-map.js +138 -0
  777. package/dist/services/anthropic-map.js.map +1 -0
  778. package/dist/services/auth.d.ts +30 -0
  779. package/dist/services/auth.d.ts.map +1 -0
  780. package/dist/services/auth.js +124 -0
  781. package/dist/services/auth.js.map +1 -0
  782. package/dist/services/auto-discover.d.ts +77 -0
  783. package/dist/services/auto-discover.d.ts.map +1 -0
  784. package/dist/services/auto-discover.js +1047 -0
  785. package/dist/services/auto-discover.js.map +1 -0
  786. package/dist/services/backups.d.ts +67 -0
  787. package/dist/services/backups.d.ts.map +1 -0
  788. package/dist/services/backups.js +444 -0
  789. package/dist/services/backups.js.map +1 -0
  790. package/dist/services/builtin-model-discovery.d.ts +107 -0
  791. package/dist/services/builtin-model-discovery.d.ts.map +1 -0
  792. package/dist/services/builtin-model-discovery.js +296 -0
  793. package/dist/services/builtin-model-discovery.js.map +1 -0
  794. package/dist/services/cache.d.ts +190 -0
  795. package/dist/services/cache.d.ts.map +1 -0
  796. package/dist/services/cache.js +617 -0
  797. package/dist/services/cache.js.map +1 -0
  798. package/dist/services/catalog-sync.d.ts +257 -0
  799. package/dist/services/catalog-sync.d.ts.map +1 -0
  800. package/dist/services/catalog-sync.js +1041 -0
  801. package/dist/services/catalog-sync.js.map +1 -0
  802. package/dist/services/compression/config.d.ts +33 -0
  803. package/dist/services/compression/config.d.ts.map +1 -0
  804. package/dist/services/compression/config.js +158 -0
  805. package/dist/services/compression/config.js.map +1 -0
  806. package/dist/services/compression/engines/aging.d.ts +2 -0
  807. package/dist/services/compression/engines/aging.d.ts.map +1 -0
  808. package/dist/services/compression/engines/aging.js +53 -0
  809. package/dist/services/compression/engines/aging.js.map +1 -0
  810. package/dist/services/compression/engines/custom-filters.d.ts +4 -0
  811. package/dist/services/compression/engines/custom-filters.d.ts.map +1 -0
  812. package/dist/services/compression/engines/custom-filters.js +49 -0
  813. package/dist/services/compression/engines/custom-filters.js.map +1 -0
  814. package/dist/services/compression/engines/dedup.d.ts +2 -0
  815. package/dist/services/compression/engines/dedup.d.ts.map +1 -0
  816. package/dist/services/compression/engines/dedup.js +61 -0
  817. package/dist/services/compression/engines/dedup.js.map +1 -0
  818. package/dist/services/compression/engines/filter-definitions.d.ts +75 -0
  819. package/dist/services/compression/engines/filter-definitions.d.ts.map +1 -0
  820. package/dist/services/compression/engines/filter-definitions.js +45 -0
  821. package/dist/services/compression/engines/filter-definitions.js.map +1 -0
  822. package/dist/services/compression/engines/hard-budget.d.ts +2 -0
  823. package/dist/services/compression/engines/hard-budget.d.ts.map +1 -0
  824. package/dist/services/compression/engines/hard-budget.js +52 -0
  825. package/dist/services/compression/engines/hard-budget.js.map +1 -0
  826. package/dist/services/compression/engines/index.d.ts +9 -0
  827. package/dist/services/compression/engines/index.d.ts.map +1 -0
  828. package/dist/services/compression/engines/index.js +9 -0
  829. package/dist/services/compression/engines/index.js.map +1 -0
  830. package/dist/services/compression/engines/jsoncompact.d.ts +3 -0
  831. package/dist/services/compression/engines/jsoncompact.d.ts.map +1 -0
  832. package/dist/services/compression/engines/jsoncompact.js +145 -0
  833. package/dist/services/compression/engines/jsoncompact.js.map +1 -0
  834. package/dist/services/compression/engines/lite.d.ts +2 -0
  835. package/dist/services/compression/engines/lite.d.ts.map +1 -0
  836. package/dist/services/compression/engines/lite.js +34 -0
  837. package/dist/services/compression/engines/lite.js.map +1 -0
  838. package/dist/services/compression/engines/read-lifecycle.d.ts +2 -0
  839. package/dist/services/compression/engines/read-lifecycle.d.ts.map +1 -0
  840. package/dist/services/compression/engines/read-lifecycle.js +70 -0
  841. package/dist/services/compression/engines/read-lifecycle.js.map +1 -0
  842. package/dist/services/compression/engines/relevance.d.ts +2 -0
  843. package/dist/services/compression/engines/relevance.d.ts.map +1 -0
  844. package/dist/services/compression/engines/relevance.js +74 -0
  845. package/dist/services/compression/engines/relevance.js.map +1 -0
  846. package/dist/services/compression/engines/toolfilter.d.ts +2 -0
  847. package/dist/services/compression/engines/toolfilter.d.ts.map +1 -0
  848. package/dist/services/compression/engines/toolfilter.js +195 -0
  849. package/dist/services/compression/engines/toolfilter.js.map +1 -0
  850. package/dist/services/compression/fidelity-gate.d.ts +12 -0
  851. package/dist/services/compression/fidelity-gate.d.ts.map +1 -0
  852. package/dist/services/compression/fidelity-gate.js +90 -0
  853. package/dist/services/compression/fidelity-gate.js.map +1 -0
  854. package/dist/services/compression/helpers.d.ts +9 -0
  855. package/dist/services/compression/helpers.d.ts.map +1 -0
  856. package/dist/services/compression/helpers.js +52 -0
  857. package/dist/services/compression/helpers.js.map +1 -0
  858. package/dist/services/compression/pipeline.d.ts +7 -0
  859. package/dist/services/compression/pipeline.d.ts.map +1 -0
  860. package/dist/services/compression/pipeline.js +245 -0
  861. package/dist/services/compression/pipeline.js.map +1 -0
  862. package/dist/services/compression/preservation.d.ts +27 -0
  863. package/dist/services/compression/preservation.d.ts.map +1 -0
  864. package/dist/services/compression/preservation.js +109 -0
  865. package/dist/services/compression/preservation.js.map +1 -0
  866. package/dist/services/compression/registry.d.ts +6 -0
  867. package/dist/services/compression/registry.d.ts.map +1 -0
  868. package/dist/services/compression/registry.js +16 -0
  869. package/dist/services/compression/registry.js.map +1 -0
  870. package/dist/services/compression/stats.d.ts +29 -0
  871. package/dist/services/compression/stats.d.ts.map +1 -0
  872. package/dist/services/compression/stats.js +55 -0
  873. package/dist/services/compression/stats.js.map +1 -0
  874. package/dist/services/compression/types.d.ts +90 -0
  875. package/dist/services/compression/types.d.ts.map +1 -0
  876. package/dist/services/compression/types.js +2 -0
  877. package/dist/services/compression/types.js.map +1 -0
  878. package/dist/services/context-handoff.d.ts +22 -0
  879. package/dist/services/context-handoff.d.ts.map +1 -0
  880. package/dist/services/context-handoff.js +164 -0
  881. package/dist/services/context-handoff.js.map +1 -0
  882. package/dist/services/cooldown-probe.d.ts +24 -0
  883. package/dist/services/cooldown-probe.d.ts.map +1 -0
  884. package/dist/services/cooldown-probe.js +181 -0
  885. package/dist/services/cooldown-probe.js.map +1 -0
  886. package/dist/services/custom-endpoint.d.ts +58 -0
  887. package/dist/services/custom-endpoint.d.ts.map +1 -0
  888. package/dist/services/custom-endpoint.js +167 -0
  889. package/dist/services/custom-endpoint.js.map +1 -0
  890. package/dist/services/custom-media-register.d.ts +22 -0
  891. package/dist/services/custom-media-register.d.ts.map +1 -0
  892. package/dist/services/custom-media-register.js +45 -0
  893. package/dist/services/custom-media-register.js.map +1 -0
  894. package/dist/services/custom-model-register.d.ts +50 -0
  895. package/dist/services/custom-model-register.d.ts.map +1 -0
  896. package/dist/services/custom-model-register.js +103 -0
  897. package/dist/services/custom-model-register.js.map +1 -0
  898. package/dist/services/custom-model-seed.d.ts +18 -0
  899. package/dist/services/custom-model-seed.d.ts.map +1 -0
  900. package/dist/services/custom-model-seed.js +42 -0
  901. package/dist/services/custom-model-seed.js.map +1 -0
  902. package/dist/services/custom-model-sync.d.ts +37 -0
  903. package/dist/services/custom-model-sync.d.ts.map +1 -0
  904. package/dist/services/custom-model-sync.js +118 -0
  905. package/dist/services/custom-model-sync.js.map +1 -0
  906. package/dist/services/custom-model-tombstone.d.ts +5 -0
  907. package/dist/services/custom-model-tombstone.d.ts.map +1 -0
  908. package/dist/services/custom-model-tombstone.js +30 -0
  909. package/dist/services/custom-model-tombstone.js.map +1 -0
  910. package/dist/services/declarative-config.d.ts +356 -0
  911. package/dist/services/declarative-config.d.ts.map +1 -0
  912. package/dist/services/declarative-config.js +427 -0
  913. package/dist/services/declarative-config.js.map +1 -0
  914. package/dist/services/degradation.d.ts +40 -0
  915. package/dist/services/degradation.d.ts.map +1 -0
  916. package/dist/services/degradation.js +119 -0
  917. package/dist/services/degradation.js.map +1 -0
  918. package/dist/services/embeddings.d.ts +76 -0
  919. package/dist/services/embeddings.d.ts.map +1 -0
  920. package/dist/services/embeddings.js +319 -0
  921. package/dist/services/embeddings.js.map +1 -0
  922. package/dist/services/fusion.d.ts +162 -0
  923. package/dist/services/fusion.d.ts.map +1 -0
  924. package/dist/services/fusion.js +789 -0
  925. package/dist/services/fusion.js.map +1 -0
  926. package/dist/services/gemini-map.d.ts +14 -0
  927. package/dist/services/gemini-map.d.ts.map +1 -0
  928. package/dist/services/gemini-map.js +78 -0
  929. package/dist/services/gemini-map.js.map +1 -0
  930. package/dist/services/health.d.ts +52 -0
  931. package/dist/services/health.d.ts.map +1 -0
  932. package/dist/services/health.js +352 -0
  933. package/dist/services/health.js.map +1 -0
  934. package/dist/services/idempotency.d.ts +49 -0
  935. package/dist/services/idempotency.d.ts.map +1 -0
  936. package/dist/services/idempotency.js +140 -0
  937. package/dist/services/idempotency.js.map +1 -0
  938. package/dist/services/key-budget.d.ts +74 -0
  939. package/dist/services/key-budget.d.ts.map +1 -0
  940. package/dist/services/key-budget.js +200 -0
  941. package/dist/services/key-budget.js.map +1 -0
  942. package/dist/services/media.d.ts +123 -0
  943. package/dist/services/media.d.ts.map +1 -0
  944. package/dist/services/media.js +954 -0
  945. package/dist/services/media.js.map +1 -0
  946. package/dist/services/model-discovery.d.ts +126 -0
  947. package/dist/services/model-discovery.d.ts.map +1 -0
  948. package/dist/services/model-discovery.js +662 -0
  949. package/dist/services/model-discovery.js.map +1 -0
  950. package/dist/services/model-groups.d.ts +149 -0
  951. package/dist/services/model-groups.d.ts.map +1 -0
  952. package/dist/services/model-groups.js +314 -0
  953. package/dist/services/model-groups.js.map +1 -0
  954. package/dist/services/model-listing.d.ts +18 -0
  955. package/dist/services/model-listing.d.ts.map +1 -0
  956. package/dist/services/model-listing.js +94 -0
  957. package/dist/services/model-listing.js.map +1 -0
  958. package/dist/services/model-retirement.d.ts +29 -0
  959. package/dist/services/model-retirement.d.ts.map +1 -0
  960. package/dist/services/model-retirement.js +86 -0
  961. package/dist/services/model-retirement.js.map +1 -0
  962. package/dist/services/model-state.d.ts +90 -0
  963. package/dist/services/model-state.d.ts.map +1 -0
  964. package/dist/services/model-state.js +342 -0
  965. package/dist/services/model-state.js.map +1 -0
  966. package/dist/services/model-weight-overrides.d.ts +48 -0
  967. package/dist/services/model-weight-overrides.d.ts.map +1 -0
  968. package/dist/services/model-weight-overrides.js +127 -0
  969. package/dist/services/model-weight-overrides.js.map +1 -0
  970. package/dist/services/notification-emitter.d.ts +50 -0
  971. package/dist/services/notification-emitter.d.ts.map +1 -0
  972. package/dist/services/notification-emitter.js +177 -0
  973. package/dist/services/notification-emitter.js.map +1 -0
  974. package/dist/services/opencode-free-sync.d.ts +29 -0
  975. package/dist/services/opencode-free-sync.d.ts.map +1 -0
  976. package/dist/services/opencode-free-sync.js +206 -0
  977. package/dist/services/opencode-free-sync.js.map +1 -0
  978. package/dist/services/penalty-inspector.d.ts +56 -0
  979. package/dist/services/penalty-inspector.d.ts.map +1 -0
  980. package/dist/services/penalty-inspector.js +167 -0
  981. package/dist/services/penalty-inspector.js.map +1 -0
  982. package/dist/services/profile-models.d.ts +5 -0
  983. package/dist/services/profile-models.d.ts.map +1 -0
  984. package/dist/services/profile-models.js +62 -0
  985. package/dist/services/profile-models.js.map +1 -0
  986. package/dist/services/provider-credential.d.ts +19 -0
  987. package/dist/services/provider-credential.d.ts.map +1 -0
  988. package/dist/services/provider-credential.js +51 -0
  989. package/dist/services/provider-credential.js.map +1 -0
  990. package/dist/services/provider-quota.d.ts +64 -0
  991. package/dist/services/provider-quota.d.ts.map +1 -0
  992. package/dist/services/provider-quota.js +563 -0
  993. package/dist/services/provider-quota.js.map +1 -0
  994. package/dist/services/quirks.d.ts +0 -0
  995. package/dist/services/quirks.d.ts.map +1 -0
  996. package/dist/services/quirks.js +0 -0
  997. package/dist/services/quirks.js.map +1 -0
  998. package/dist/services/quota-forecast.d.ts +50 -0
  999. package/dist/services/quota-forecast.d.ts.map +1 -0
  1000. package/dist/services/quota-forecast.js +150 -0
  1001. package/dist/services/quota-forecast.js.map +1 -0
  1002. package/dist/services/quota-outlook.d.ts +4 -0
  1003. package/dist/services/quota-outlook.d.ts.map +1 -0
  1004. package/dist/services/quota-outlook.js +92 -0
  1005. package/dist/services/quota-outlook.js.map +1 -0
  1006. package/dist/services/ratelimit.d.ts +249 -0
  1007. package/dist/services/ratelimit.d.ts.map +1 -0
  1008. package/dist/services/ratelimit.js +1182 -0
  1009. package/dist/services/ratelimit.js.map +1 -0
  1010. package/dist/services/request-retention.d.ts +60 -0
  1011. package/dist/services/request-retention.d.ts.map +1 -0
  1012. package/dist/services/request-retention.js +229 -0
  1013. package/dist/services/request-retention.js.map +1 -0
  1014. package/dist/services/router.d.ts +384 -0
  1015. package/dist/services/router.d.ts.map +1 -0
  1016. package/dist/services/router.js +1986 -0
  1017. package/dist/services/router.js.map +1 -0
  1018. package/dist/services/scoring.d.ts +156 -0
  1019. package/dist/services/scoring.d.ts.map +1 -0
  1020. package/dist/services/scoring.js +435 -0
  1021. package/dist/services/scoring.js.map +1 -0
  1022. package/dist/services/url-tokens.d.ts +15 -0
  1023. package/dist/services/url-tokens.d.ts.map +1 -0
  1024. package/dist/services/url-tokens.js +55 -0
  1025. package/dist/services/url-tokens.js.map +1 -0
  1026. package/package.json +77 -4
  1027. 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