@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,1787 @@
1
+ import { Router } from 'express';
2
+ import { z } from 'zod';
3
+ import multer from 'multer';
4
+ import path from 'path';
5
+ import { getDb } from '../db/index.js';
6
+ import { resolveProvider, getAllProviders } from '../providers/index.js';
7
+ import { OpenAICompatProvider, isAnonymousCredential } from '../providers/openai-compat.js';
8
+ import { encrypt, decrypt, maskKey } from '../lib/crypto.js';
9
+ import { parseKeysFromFile, stripJsoncComments, stripTrailingCommas } from '../lib/key-parser.js';
10
+ import { assessProviderUrl } from '../lib/url-guard.js';
11
+ import { verifyCredentials } from '../services/auth.js';
12
+ import { getActiveCooldownsForKeys, clearCooldownsForKey } from '../services/ratelimit.js';
13
+ import { getMonthlyBudgetCaps, getMonthlyUsage, nextResetAt, resolveAutoPeriod, syncAutoBudgetPeriod } from '../services/key-budget.js';
14
+ import { resolveCustomEndpointKey, customEndpointKeyIds, siblingEndpointKeyId, endpointHasCredential } from '../services/custom-endpoint.js';
15
+ import { registerCustomModels, registerCustomChatModels } from '../services/custom-model-register.js';
16
+ import { registerCustomMediaModel } from '../services/custom-media-register.js';
17
+ import { discoverEndpointModels, probeEndpointModel, classifyModelId, ModelDiscoveryError } from '../services/model-discovery.js';
18
+ import { autoDiscoverModels } from '../services/auto-discover.js';
19
+ import { syncCatalog } from '../services/catalog-sync.js';
20
+ import { probeEmbeddingDimensions, registerCustomEmbeddingModel } from '../services/embeddings.js';
21
+ import { endpointScopeForBaseUrl, normalizeBaseUrl } from '../lib/endpoint-scope.js';
22
+ import { recordCustomModelTombstone } from '../services/custom-model-tombstone.js';
23
+ import { builtinDiscoveryEligibility, builtinDiscoveryMode, discoverBuiltinModels, ineligibleMessage, isBuiltinDiscoveryEligible, registerDiscoveredModels, registeredModelIds, triggerBuiltinModelDiscovery, } from '../services/builtin-model-discovery.js';
24
+ import { parseModelScope } from '../lib/model-scope.js';
25
+ import { KEY_PROXY_URL_ERROR, KEY_PROXY_URL_MAX, decryptProxyUrl, encryptProxyUrl, isValidKeyProxyUrl, maskProxyUrl } from '../lib/key-proxy.js';
26
+ export const keysRouter = Router();
27
+ // Active providers — must match providers/index.ts registrations + shared/types.ts Platform.
28
+ // Moonshot and MiniMax direct integrations were dropped in V4. HuggingFace
29
+ // was dropped in V4 and re-added in V13 via the router.huggingface.co route.
30
+ // SambaNova was dropped in V23 (free tier permanently retired).
31
+ const PLATFORMS = [
32
+ 'aclide',
33
+ 'speka',
34
+ 'llmtr',
35
+ 'gizmo',
36
+ 'blockrun',
37
+ 'moondream',
38
+ 'google', 'groq', 'xai', 'cerebras', 'sail', 'electronhub', 'experiential', 'router9', 'septor', 'clod', 'speechify', 'blaze', 'lucidity', 'airforce', 'dreamprompting', 'waterfall', 'logfare', 'bai', 'radeon', 'nvidia', 'mistral',
39
+ 'openrouter', 'github', 'cohere', 'cloudflare', 'zhipu', 'ollama',
40
+ 'kilo', 'pollinations', 'llm7', 'huggingface', 'opencode', 'opencode-free', 'ovh', 'agnes', 'reka', 'siliconflow',
41
+ 'routeway', 'bazaarlink', 'ainative', 'aion', 'anyapi', 'requesty', 'navy', 'nara', 'sealion', 'orcarouter', 'unorouter', 'xkiro', 'modelscope',
42
+ 'qianfan', 'volcengine', 'longcat', 'xfyun', 'aihorde', 'opencode-go', 'custom',
43
+ ];
44
+ const ALLOWED_IMPORT_EXTENSIONS = new Set(['.env', '.json', '.jsonc', '.md', '.txt', '.csv']);
45
+ const upload = multer({
46
+ storage: multer.memoryStorage(),
47
+ limits: { fileSize: 5 * 1024 * 1024, files: 10 },
48
+ fileFilter: (_req, file, cb) => {
49
+ const ext = path.extname(file.originalname).toLowerCase();
50
+ if (!ALLOWED_IMPORT_EXTENSIONS.has(ext)) {
51
+ cb(new Error('Unsupported file type'));
52
+ return;
53
+ }
54
+ cb(null, true);
55
+ },
56
+ });
57
+ // `key` is optional so keyless providers (Kilo's anonymous gateway) can be added
58
+ // without one; the handler enforces a non-empty key for everyone else.
59
+ // #590 (per-key proxy): an optional per-key proxy override. Only schemes the
60
+ // proxy layer can actually dispatch through are accepted — an unvalidated
61
+ // string would be stored, encrypted, and only surface as a failed dispatcher
62
+ // at request time, one attempt at a time. '' = no override (global proxy).
63
+ const proxyUrlSchema = z.string().max(KEY_PROXY_URL_MAX).refine(isValidKeyProxyUrl, { message: KEY_PROXY_URL_ERROR });
64
+ const addKeySchema = z.object({
65
+ platform: z.enum(PLATFORMS),
66
+ key: z.string().optional(),
67
+ label: z.string().optional(),
68
+ proxyUrl: proxyUrlSchema.optional(),
69
+ });
70
+ // `modelScope` (#657): the model_id list this key may serve — relay stations
71
+ // scope each key to a model group. null (or an empty array) clears the scope,
72
+ // returning the key to serving every model of its platform.
73
+ const updateKeySchema = z.object({
74
+ enabled: z.boolean().optional(),
75
+ label: z.string().optional(),
76
+ modelScope: z.array(z.string().trim().min(1).max(200)).max(100).nullable().optional(),
77
+ // #590: '' clears the per-key proxy; absent leaves it unchanged.
78
+ proxyUrl: proxyUrlSchema.optional(),
79
+ // Monthly budget caps (#1158): 0 clears the cap (unlimited). The period
80
+ // names the UTC window the caps count against (provider reset cycle).
81
+ monthlyRequestCap: z.number().int().min(0).max(1_000_000_000).optional(),
82
+ monthlyTokenCap: z.number().int().min(0).max(1_000_000_000_000).optional(),
83
+ budgetPeriod: z.enum(['auto', 'daily', 'weekly', 'monthly']).optional(),
84
+ // An absent credential leaves the encrypted key untouched.
85
+ key: z.string().trim().min(1).optional(),
86
+ }).refine(data => data.enabled !== undefined || data.label !== undefined || data.modelScope !== undefined || data.proxyUrl !== undefined || data.key !== undefined || data.monthlyRequestCap !== undefined || data.monthlyTokenCap !== undefined || data.budgetPeriod !== undefined, {
87
+ message: 'At least one of enabled, label, modelScope, proxyUrl, key, monthlyRequestCap, monthlyTokenCap or budgetPeriod must be provided',
88
+ });
89
+ const importKeySchema = z.object({
90
+ keyName: z.string().optional(),
91
+ keyValue: z.string().min(1),
92
+ platform: z.enum(PLATFORMS),
93
+ // A custom row names an ENDPOINT, so it only means something with the URL
94
+ // the export file carried alongside it (#687).
95
+ baseUrl: z.string().optional(),
96
+ // Models declared beside a custom endpoint in the paste (#382). Ignored for
97
+ // catalog platforms — their model lists come from the catalog, not the file.
98
+ models: z.array(z.object({
99
+ id: z.string().min(1),
100
+ supportsTools: z.boolean().optional(),
101
+ supportsVision: z.boolean().optional(),
102
+ })).max(200).optional(),
103
+ });
104
+ function handleUploadError(err, res, next) {
105
+ if (!err)
106
+ return false;
107
+ if (err.code === 'LIMIT_FILE_SIZE') {
108
+ res.status(413).json({ error: { message: 'File too large. Maximum size is 5MB' } });
109
+ return true;
110
+ }
111
+ if (err.code === 'LIMIT_FILE_COUNT') {
112
+ res.status(413).json({ error: { message: 'Too many files. Maximum is 10' } });
113
+ return true;
114
+ }
115
+ if (err.message?.includes('Unsupported file type')) {
116
+ res.status(400).json({ error: { message: 'Unsupported file type' } });
117
+ return true;
118
+ }
119
+ next(err);
120
+ return true;
121
+ }
122
+ function parseUpload(file) {
123
+ const content = file.buffer.toString('utf8');
124
+ if (!content.trim()) {
125
+ throw Object.assign(new Error('File contains no data'), { status: 400 });
126
+ }
127
+ if (/\.jsonc?$/i.test(file.originalname)) {
128
+ try {
129
+ JSON.parse(stripTrailingCommas(stripJsoncComments(content)));
130
+ }
131
+ catch {
132
+ throw Object.assign(new Error('Invalid JSON format'), { status: 400 });
133
+ }
134
+ }
135
+ return parseKeysFromFile(content, file.originalname);
136
+ }
137
+ /** Name/value halves of a parsed key. A format that carries a label (CSV,
138
+ * export JSON) names it outright: splitting `label=value` on the first '='
139
+ * would cut a label like `team=alpha` short and prepend its tail to the
140
+ * stored secret. */
141
+ function splitParsedKey(parsedKey) {
142
+ const { rawKey, label } = parsedKey;
143
+ if (label !== undefined && rawKey.startsWith(`${label}=`)) {
144
+ return { keyName: label, keyValue: rawKey.slice(label.length + 1) };
145
+ }
146
+ return splitRawKey(rawKey);
147
+ }
148
+ function splitRawKey(rawKey) {
149
+ const eqIndex = rawKey.indexOf('=');
150
+ return {
151
+ keyName: eqIndex === -1 ? rawKey : rawKey.slice(0, eqIndex),
152
+ keyValue: eqIndex === -1 ? '' : rawKey.slice(eqIndex + 1),
153
+ };
154
+ }
155
+ function insertImportedKey(platform, keyName, keyValue) {
156
+ if (platform === 'custom') {
157
+ throw new Error('Custom providers must be added with a base URL');
158
+ }
159
+ if (!resolveProvider(platform)) {
160
+ throw new Error(`Unsupported platform: ${platform}`);
161
+ }
162
+ const db = getDb();
163
+ const { encrypted, iv, authTag } = encrypt(keyValue.trim());
164
+ db.prepare(`
165
+ INSERT INTO api_keys (platform, label, encrypted_key, iv, auth_tag, status, enabled)
166
+ VALUES (?, ?, ?, ?, ?, 'unknown', 1)
167
+ `).run(platform, keyName, encrypted, iv, authTag);
168
+ }
169
+ // Count enabled catalog models for a platform. Used to warn when a key is
170
+ // added for a provider that has zero models in the operator's current catalog
171
+ // tier — the Agnes case (#438): the provider is registered and selectable, but
172
+ // its models ship in the catalog and only appear for installs
173
+ // once they age into the monthly catalog, so a fresh install adds the
174
+ // key and silently sees nothing.
175
+ /**
176
+ * Whether a stored row belongs in an export file. Kept in one place because the
177
+ * export dialog shows a count before downloading, and computing that count from
178
+ * a different rule than the export itself made it lie: it promised 40 keys and
179
+ * wrote 39 whenever a no-auth custom endpoint was in the list (#687).
180
+ *
181
+ * A custom endpoint is worth exporting even when it holds only the `no-key`
182
+ * placeholder — the endpoint IS the thing being backed up, and its base_url
183
+ * restores it. Anything else needs a real secret to be worth a line.
184
+ */
185
+ export function isExportableKey(row) {
186
+ if (row.platform === 'custom')
187
+ return Boolean(row.baseUrl);
188
+ const v = row.key.trim();
189
+ return v.length > 0 && v !== 'no-key';
190
+ }
191
+ // Every model a platform's key can serve: chat rows, plus embedding and
192
+ // generative-media rows. Media-only providers (Speechify is TTS-only) never
193
+ // have a chat row, so counting `models` alone reported "no models" — and fired
194
+ // the no-catalog notice — for them even on a fully synced
195
+ // install
196
+ // (#1327).
197
+ function enabledModelCount(platform) {
198
+ const db = getDb();
199
+ const row = db.prepare(`
200
+ SELECT (SELECT COUNT(*) FROM models WHERE platform = ? AND enabled = 1)
201
+ + (SELECT COUNT(*) FROM embedding_models WHERE platform = ? AND enabled = 1)
202
+ + (SELECT COUNT(*) FROM media_models WHERE platform = ? AND enabled = 1) AS c
203
+ `).get(platform, platform, platform);
204
+ return row.c;
205
+ }
206
+ // The public API root the custom-provider workaround needs, or null when the
207
+ // platform does not speak the OpenAI-compatible protocol that path speaks
208
+ // (e.g. Speechify's own TTS API), in which case that advice is a dead end.
209
+ function openAICompatBaseUrl(platform) {
210
+ const provider = resolveProvider(platform);
211
+ if (!(provider instanceof OpenAICompatProvider))
212
+ return null;
213
+ return provider.modelsUrl.replace(/\/models\/?$/, '');
214
+ }
215
+ // Non-null when the just-added key has no usable models yet, so the client can
216
+ // explain the silence instead of leaving the user staring at an empty list.
217
+ //
218
+ // #1327: the old copy told users to "add a license key", and
219
+ // told everyone to add the provider as a custom OpenAI-compatible endpoint
220
+ // "with its base URL" without naming it — even for providers with no such
221
+ // endpoint. The notice now names the base URL, and only offers the
222
+ // custom-provider route where it works.
223
+ function noModelsNotice(platform) {
224
+ if (enabledModelCount(platform) > 0)
225
+ return undefined;
226
+ // #1348: a built-in provider the catalog does not carry fills its model list
227
+ // from its own /models, so the Premium and custom-provider advice below
228
+ // would send the operator the wrong way.
229
+ if (isBuiltinDiscoveryEligible(getDb(), platform)) {
230
+ return builtinDiscoveryMode() === 'auto'
231
+ ? `Key saved. The catalog does not list ${platform} models, so they are being fetched from ${platform}'s own model list. ` +
232
+ `Use Fetch models on the key to review them or add more.`
233
+ : `Key saved. The catalog does not list ${platform} models. Use Fetch models on the key to pick which ones to use.`;
234
+ }
235
+ const baseUrl = openAICompatBaseUrl(platform);
236
+ const customRoute = baseUrl
237
+ ? `add ${platform} as a custom OpenAI-compatible provider with base URL ${baseUrl}`
238
+ : null;
239
+ return (`Key saved, but no ${platform} models could be discovered automatically. ` +
240
+ `The provider's /v1/models endpoint may be unavailable or returned no chat models. ` +
241
+ `Try refreshing later, or ${customRoute ?? 'check the provider\'s documentation'}.`);
242
+ }
243
+ // Provider checklist (#543): every registered provider with whether the user
244
+ // has added at least one key yet, so the dashboard can show what's still
245
+ // missing without enumerating the whole list by hand. `custom` is excluded —
246
+ // it's a per-key user-defined placeholder, not a fixed free-tier provider to
247
+ // "check off".
248
+ keysRouter.get('/providers', (_req, res) => {
249
+ const db = getDb();
250
+ const countRows = db.prepare(`
251
+ SELECT
252
+ platform,
253
+ COUNT(*) AS total_keys,
254
+ SUM(CASE WHEN enabled = 1 THEN 1 ELSE 0 END) AS enabled_keys
255
+ FROM api_keys
256
+ GROUP BY platform
257
+ `).all();
258
+ const countsByPlatform = new Map(countRows.map(r => [r.platform, r]));
259
+ const providers = getAllProviders()
260
+ .filter(p => p.platform !== 'custom')
261
+ .map(p => {
262
+ const counts = countsByPlatform.get(p.platform);
263
+ const keyCount = counts?.total_keys ?? 0;
264
+ return {
265
+ platform: p.platform,
266
+ name: p.name,
267
+ keyless: p.keyless,
268
+ configured: keyCount > 0,
269
+ keyCount,
270
+ enabledKeyCount: counts?.enabled_keys ?? 0,
271
+ };
272
+ })
273
+ .sort((a, b) => a.name.localeCompare(b.name));
274
+ const configured = providers.filter(p => p.configured).length;
275
+ res.json({
276
+ providers,
277
+ summary: {
278
+ total: providers.length,
279
+ configured,
280
+ unconfigured: providers.length - configured,
281
+ },
282
+ });
283
+ });
284
+ // List all keys (masked)
285
+ keysRouter.get('/', (_req, res) => {
286
+ const db = getDb();
287
+ const rows = db.prepare('SELECT * FROM api_keys ORDER BY created_at DESC').all();
288
+ const customModels = [
289
+ ...db.prepare(`
290
+ SELECT key_id, id, 'chat' AS kind, model_id, display_name, NULL AS family
291
+ FROM models
292
+ WHERE platform = 'custom' AND key_id IS NOT NULL
293
+ `).all(),
294
+ ...db.prepare(`
295
+ SELECT key_id, id, 'embedding' AS kind, model_id, display_name, family
296
+ FROM embedding_models
297
+ WHERE platform = 'custom' AND key_id IS NOT NULL
298
+ `).all(),
299
+ ...db.prepare(`
300
+ SELECT key_id, id, modality AS kind, model_id, display_name, NULL AS family
301
+ FROM media_models
302
+ WHERE platform = 'custom' AND key_id IS NOT NULL
303
+ `).all(),
304
+ ];
305
+ // Models are grouped by ENDPOINT, not by key row: an endpoint can hold
306
+ // several credentials (#619) while each model binds to just one of them, and
307
+ // every one of those keys serves the endpoint's whole model list.
308
+ const endpointOfKey = new Map();
309
+ for (const row of rows) {
310
+ if (row.platform === 'custom' && row.base_url)
311
+ endpointOfKey.set(Number(row.id), row.base_url);
312
+ }
313
+ const endpointOf = (keyId) => endpointOfKey.get(keyId) ?? `key:${keyId}`;
314
+ const modelsByEndpoint = new Map();
315
+ for (const m of customModels) {
316
+ const keyId = Number(m.key_id);
317
+ if (!Number.isInteger(keyId))
318
+ continue;
319
+ const list = modelsByEndpoint.get(endpointOf(keyId)) ?? [];
320
+ list.push({
321
+ id: m.id,
322
+ kind: m.kind,
323
+ modelId: m.model_id,
324
+ displayName: m.display_name,
325
+ family: m.family ?? null,
326
+ });
327
+ modelsByEndpoint.set(endpointOf(keyId), list);
328
+ }
329
+ for (const list of modelsByEndpoint.values()) {
330
+ list.sort((a, b) => {
331
+ const ka = ['chat', 'embedding', 'image', 'audio', 'transcription'].indexOf(a.kind);
332
+ const kb = ['chat', 'embedding', 'image', 'audio', 'transcription'].indexOf(b.kind);
333
+ return (ka - kb) || String(a.displayName).localeCompare(String(b.displayName));
334
+ });
335
+ }
336
+ // A cooling-down key reads as healthy and enabled while the router skips it,
337
+ // so surface the cooldowns that explain the idleness. (#P0-7)
338
+ const cooldownsByKeyId = getActiveCooldownsForKeys(rows.map(row => Number(row.id)));
339
+ // #1348: which built-in platforms may fill their model list from their own
340
+ // /models, so the dashboard offers Fetch models on those key rows only.
341
+ const discoverable = new Map();
342
+ const canDiscover = (platform) => {
343
+ if (platform === 'custom')
344
+ return false;
345
+ if (!discoverable.has(platform))
346
+ discoverable.set(platform, isBuiltinDiscoveryEligible(db, platform));
347
+ return discoverable.get(platform);
348
+ };
349
+ const keys = rows.map(row => {
350
+ let maskedKey = '****';
351
+ let realKey = '';
352
+ try {
353
+ realKey = decrypt(row.encrypted_key, row.iv, row.auth_tag);
354
+ maskedKey = maskKey(realKey);
355
+ }
356
+ catch {
357
+ maskedKey = '[decrypt failed]';
358
+ }
359
+ const cooldowns = cooldownsByKeyId.get(Number(row.id)) ?? [];
360
+ const scope = parseModelScope(row.model_scope_json);
361
+ const budgetCaps = getMonthlyBudgetCaps(Number(row.id));
362
+ // Usage alongside the caps so the dashboard can show "342 of 1,000 this
363
+ // month" instead of only the cap (OpenRouter's GET /api/v1/key returns
364
+ // limit_remaining for the same reason). The bucket and reset boundary
365
+ // follow the key's budget period (daily/weekly/monthly, UTC).
366
+ const monthlyUsage = getMonthlyUsage(Number(row.id), budgetCaps.period);
367
+ return {
368
+ id: row.id,
369
+ platform: row.platform,
370
+ label: row.label,
371
+ maskedKey,
372
+ baseUrl: row.base_url ?? null,
373
+ monthlyRequestCap: budgetCaps.requestCap,
374
+ monthlyTokenCap: budgetCaps.tokenCap,
375
+ budgetPeriod: budgetCaps.mode === 'auto' ? 'auto' : budgetCaps.period,
376
+ resolvedBudgetPeriod: budgetCaps.period,
377
+ budgetPeriodSource: budgetCaps.source,
378
+ monthlyUsage: {
379
+ requests: monthlyUsage.requests,
380
+ tokens: monthlyUsage.tokens,
381
+ resetsAt: nextResetAt(budgetCaps.period),
382
+ },
383
+ status: row.status,
384
+ enabled: row.enabled === 1,
385
+ // `keyOptional`: the platform works with or without a key (Kilo, OVH,
386
+ // AI Horde). `keyless`: THIS row is the anonymous sentinel, so there is
387
+ // no credential to copy, scope or reveal. A real key saved on a
388
+ // key-optional platform is an ordinary key row (#1331).
389
+ keyOptional: resolveProvider(row.platform)?.keyless === true,
390
+ keyless: resolveProvider(row.platform)?.keyless === true && isAnonymousCredential(realKey),
391
+ // Lets the export dialog count exactly what the export will write.
392
+ exportable: isExportableKey({ platform: row.platform, baseUrl: row.base_url ?? null, key: realKey }),
393
+ createdAt: row.created_at,
394
+ lastCheckedAt: row.last_checked_at,
395
+ lastHealthError: row.last_health_error ?? null,
396
+ // The model_id list this key is limited to; null = serves everything (#657).
397
+ modelScope: scope ? [...scope] : null,
398
+ // The per-key proxy override with its password masked (#590). '' = no
399
+ // override. Enough for the dashboard to show that a key routes through
400
+ // its own exit, without handing the proxy credentials back out.
401
+ maskedProxyUrl: maskProxyUrl(decryptProxyUrl(row)),
402
+ models: row.platform === 'custom' ? (modelsByEndpoint.get(endpointOf(Number(row.id))) ?? []) : undefined,
403
+ modelDiscovery: canDiscover(row.platform),
404
+ cooldowns: cooldowns.map(c => ({
405
+ modelId: c.modelId,
406
+ expiresAtMs: c.expiresAtMs,
407
+ remainingMs: c.remainingMs,
408
+ })),
409
+ };
410
+ });
411
+ res.json(keys);
412
+ });
413
+ // Clear every active cooldown for one key. An escalated cooldown can bench a key
414
+ // for up to 24h from a single bad window; once the operator has fixed the cause
415
+ // there is otherwise no way back short of restarting and waiting it out.
416
+ keysRouter.delete('/:id/cooldowns', (req, res) => {
417
+ const id = Number(req.params.id);
418
+ if (!Number.isInteger(id)) {
419
+ res.status(400).json({ error: 'Invalid key id' });
420
+ return;
421
+ }
422
+ const db = getDb();
423
+ const exists = db.prepare('SELECT 1 FROM api_keys WHERE id = ?').get(id);
424
+ if (!exists) {
425
+ res.status(404).json({ error: 'Key not found' });
426
+ return;
427
+ }
428
+ const cleared = clearCooldownsForKey(id);
429
+ res.json({ cleared });
430
+ });
431
+ // Is the caller connecting from this machine? Read from the real socket peer
432
+ // address, NOT req.ip or X-Forwarded-For: those are caller-controlled, so
433
+ // trusting them here would let a remote caller claim to be local.
434
+ function isLoopbackRemote(req) {
435
+ let addr = req.socket?.remoteAddress ?? '';
436
+ // Node reports IPv4 loopback over a dual-stack socket as "::ffff:127.0.0.1".
437
+ if (addr.startsWith('::ffff:'))
438
+ addr = addr.slice(7);
439
+ if (addr === '::1')
440
+ return true;
441
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(addr);
442
+ }
443
+ // #786: the desktop build has no user-set password (its machine user's password
444
+ // is random and never shown), so re-verification would lock desktop users out of
445
+ // reading their own keys. The embedder marks the process
446
+ // (desktop/src/server-host.ts) and the two plaintext-key endpoints below skip
447
+ // re-auth for it — but ONLY for a request from this machine. The desktop app can
448
+ // bind 0.0.0.0 through its LAN-access toggle, and a remote viewer on that LAN
449
+ // still has to prove the password before it can lift a key.
450
+ function skipsReauth(req) {
451
+ return process.env.SROUTER_DESKTOP === '1' && isLoopbackRemote(req);
452
+ }
453
+ // Export keys — returns plaintext keys in the requested format.
454
+ // GET /api/keys/export?format=json|env|csv&healthy=true
455
+ // The response is the raw file download (Content-Type varies by format).
456
+ // Password re-verification via x-reauth-password header is required, except for
457
+ // a local request on the desktop build (see skipsReauth).
458
+ keysRouter.get('/export', (req, res) => {
459
+ const user = req.user;
460
+ if (!skipsReauth(req)) {
461
+ const password = req.headers['x-reauth-password'];
462
+ if (!password || !verifyCredentials(user.email, password)) {
463
+ res.status(403).json({ error: { message: 'Password verification required to export keys', type: 'authentication_error' } });
464
+ return;
465
+ }
466
+ }
467
+ const db = getDb();
468
+ const format = req.query.format ?? 'json';
469
+ const healthyOnly = req.query.healthy === 'true';
470
+ let whereClause = '';
471
+ if (healthyOnly) {
472
+ whereClause = "WHERE status = 'healthy'";
473
+ }
474
+ const rows = db.prepare(`SELECT * FROM api_keys ${whereClause} ORDER BY platform, created_at ASC`).all();
475
+ // Decrypt and filter — only export keys with a real value
476
+ const decryptedKeys = rows
477
+ .map(row => {
478
+ let key = '';
479
+ try {
480
+ key = decrypt(row.encrypted_key, row.iv, row.auth_tag);
481
+ }
482
+ catch {
483
+ key = '';
484
+ }
485
+ return {
486
+ platform: row.platform,
487
+ key,
488
+ label: row.label || '',
489
+ baseUrl: row.base_url || undefined,
490
+ };
491
+ })
492
+ .filter(k => isExportableKey({ platform: k.platform, baseUrl: k.baseUrl ?? null, key: k.key }));
493
+ if (decryptedKeys.length === 0) {
494
+ res.status(404).json({ error: { message: 'No keys to export' } });
495
+ return;
496
+ }
497
+ if (format === 'env') {
498
+ // .env format: GOOGLE_KEY=xxx\nGROQ_KEY=yyy
499
+ //
500
+ // Names have to stay unique. A .env is read back into a map keyed by name,
501
+ // so two rows sharing one name collapse into a single imported key — every
502
+ // second Google key was silently lost on a round trip. Repeats therefore
503
+ // take a numeric suffix (GOOGLE_KEY_2), which still resolves to the same
504
+ // platform through PREFIX_MAP.
505
+ //
506
+ // Custom endpoints get an indexed PAIR, because a custom key without its
507
+ // base_url cannot be restored — there is no endpoint to attach it to (#687).
508
+ const seenPerPlatform = new Map();
509
+ let customIndex = 0;
510
+ const lines = decryptedKeys.map(k => {
511
+ if (k.platform === 'custom' && k.baseUrl) {
512
+ customIndex++;
513
+ return [
514
+ `# ${k.label || `custom endpoint ${customIndex}`}`,
515
+ `CUSTOM_${customIndex}_BASE_URL=${k.baseUrl}`,
516
+ `CUSTOM_${customIndex}_KEY=${k.key}`,
517
+ ].join('\n');
518
+ }
519
+ const n = (seenPerPlatform.get(k.platform) ?? 0) + 1;
520
+ seenPerPlatform.set(k.platform, n);
521
+ const envKey = `${k.platform.toUpperCase()}_KEY${n > 1 ? `_${n}` : ''}=${k.key}`;
522
+ return k.label ? `# ${k.label}\n${envKey}` : envKey;
523
+ });
524
+ const content = lines.join('\n\n') + '\n';
525
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
526
+ res.setHeader('Content-Disposition', 'attachment; filename="srouter-keys.env"');
527
+ res.send(content);
528
+ return;
529
+ }
530
+ if (format === 'csv') {
531
+ // CSV format: platform,key,label
532
+ const escCsv = (v) => `"${v.replace(/"/g, '""')}"`;
533
+ // CSV formula-injection guard: a spreadsheet treats a cell that starts with
534
+ // =, +, -, @, tab or CR as a live formula, so a label like `=HYPERLINK(...)`
535
+ // would execute on open. Prefix such cells with a single quote to force them
536
+ // to be read as text. Applied only to free-text fields the user controls
537
+ // (labels); the key value must round-trip verbatim for re-import, and the
538
+ // platform is one of our own fixed enum values.
539
+ const neutralize = (v) => (/^[=+\-@\t\r]/.test(v) ? `'${v}` : v);
540
+ // base_url is the fourth column: a custom row is an ENDPOINT, and without
541
+ // its URL the key cannot be re-imported (#687). Blank for every other
542
+ // platform. The importer tolerates the three-column form too, so files
543
+ // written by older builds still load.
544
+ const header = 'platform,key,label,base_url';
545
+ const lines = decryptedKeys.map(k => [escCsv(k.platform), escCsv(k.key), escCsv(neutralize(k.label)), escCsv(k.baseUrl ?? '')].join(','));
546
+ const content = [header, ...lines].join('\n') + '\n';
547
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8');
548
+ res.setHeader('Content-Disposition', 'attachment; filename="srouter-keys.csv"');
549
+ res.send(content);
550
+ return;
551
+ }
552
+ // Default: JSON format (round-trip safe — can be imported directly)
553
+ const jsonExport = {
554
+ version: 1,
555
+ exportedAt: new Date().toISOString(),
556
+ source: 'srouter',
557
+ keys: decryptedKeys,
558
+ };
559
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
560
+ res.setHeader('Content-Disposition', 'attachment; filename="srouter-keys.json"');
561
+ res.json(jsonExport);
562
+ });
563
+ // Reveal ONE key in plaintext, so the dashboard can offer a copy action for a
564
+ // credential it otherwise only ever shows masked (#705). Exporting every key to
565
+ // a file was the only way to read one back, which is a poor trade for "what is
566
+ // the key on this row again?". Gated exactly like the export it narrows: the
567
+ // session alone is not enough, the password has to be re-entered — except for a
568
+ // local request on the desktop build, which has no password to re-enter (see
569
+ // skipsReauth; a LAN client of that same desktop server still needs one).
570
+ keysRouter.post('/:id/reveal', (req, res) => {
571
+ const user = req.user;
572
+ if (!skipsReauth(req)) {
573
+ const password = req.headers['x-reauth-password'];
574
+ if (!password || !verifyCredentials(user.email, password)) {
575
+ res.status(403).json({ error: { message: 'Password verification required to reveal a key', type: 'authentication_error' } });
576
+ return;
577
+ }
578
+ }
579
+ const id = parseInt(req.params.id, 10);
580
+ if (isNaN(id)) {
581
+ res.status(400).json({ error: { message: 'Invalid key ID' } });
582
+ return;
583
+ }
584
+ const row = getDb().prepare('SELECT encrypted_key, iv, auth_tag FROM api_keys WHERE id = ?')
585
+ .get(id);
586
+ if (!row) {
587
+ res.status(404).json({ error: { message: 'Key not found' } });
588
+ return;
589
+ }
590
+ try {
591
+ res.json({ key: decrypt(row.encrypted_key, row.iv, row.auth_tag) });
592
+ }
593
+ catch {
594
+ res.status(500).json({ error: { message: 'This key could not be decrypted. It was stored with a different ENCRYPTION_KEY.' } });
595
+ }
596
+ });
597
+ // Add a key — auto-discovers models when the catalog hasn't shipped them yet
598
+ keysRouter.post('/', async (req, res) => {
599
+ const parsed = addKeySchema.safeParse(req.body);
600
+ if (!parsed.success) {
601
+ res.status(400).json({ error: { message: parsed.error.errors.map(e => e.message).join(', ') } });
602
+ return;
603
+ }
604
+ const { platform, label } = parsed.data;
605
+ const isKeyless = resolveProvider(platform)?.keyless === true;
606
+ const rawKey = parsed.data.key?.trim() ?? '';
607
+ if (!isKeyless && !rawKey) {
608
+ res.status(400).json({ error: { message: 'key is required' } });
609
+ return;
610
+ }
611
+ // Key-optional providers (Kilo, OVH, AI Horde) store a sentinel when no key
612
+ // is given so routing sees the platform as configured; the provider then
613
+ // calls upstream anonymously. A real key is stored encrypted like any other
614
+ // and sent as the bearer (#1331).
615
+ const keyToStore = isKeyless ? (rawKey || 'no-key') : rawKey;
616
+ const proxyUrl = parsed.data.proxyUrl?.trim() ?? '';
617
+ const db = getDb();
618
+ if (isKeyless) {
619
+ const rows = db.prepare('SELECT id, encrypted_key, iv, auth_tag FROM api_keys WHERE platform = ? ORDER BY id')
620
+ .all(platform);
621
+ const plaintext = (r) => {
622
+ try {
623
+ return decrypt(r.encrypted_key, r.iv, r.auth_tag);
624
+ }
625
+ catch {
626
+ return null;
627
+ }
628
+ };
629
+ const sentinel = rows.find(r => isAnonymousCredential(plaintext(r)));
630
+ const respond = async (id, maskedKey) => {
631
+ // Auto-discover models if the catalog hasn't shipped them for this provider
632
+ if (enabledModelCount(platform) === 0) {
633
+ try {
634
+ await autoDiscoverModels(platform);
635
+ }
636
+ catch { }
637
+ }
638
+ return res.status(200).json({
639
+ id,
640
+ platform,
641
+ label: label ?? '',
642
+ maskedKey,
643
+ status: 'unknown',
644
+ enabled: true,
645
+ modelsAvailable: enabledModelCount(platform),
646
+ notice: noModelsNotice(platform),
647
+ });
648
+ };
649
+ if (!rawKey) {
650
+ // Only one sentinel row is needed: re-enable an existing row instead of
651
+ // piling up duplicates each time the user clicks "Enable".
652
+ const existing = sentinel ?? rows[0];
653
+ if (existing) {
654
+ db.prepare("UPDATE api_keys SET enabled = 1, status = 'unknown' WHERE id = ?").run(existing.id);
655
+ await respond(existing.id, maskKey(plaintext(existing) ?? keyToStore));
656
+ return;
657
+ }
658
+ }
659
+ else if (sentinel) {
660
+ // A real key upgrades the anonymous row in place rather than leaving a
661
+ // second, anonymous row competing with it in the pool.
662
+ const { encrypted, iv, authTag } = encrypt(rawKey);
663
+ const updates = ['encrypted_key = ?', 'iv = ?', 'auth_tag = ?', 'enabled = 1', "status = 'unknown'", 'last_checked_at = NULL', 'last_health_error = NULL'];
664
+ const values = [encrypted, iv, authTag];
665
+ if (label !== undefined) {
666
+ updates.push('label = ?');
667
+ values.push(label);
668
+ }
669
+ if (parsed.data.proxyUrl !== undefined) {
670
+ const proxy = encryptProxyUrl(proxyUrl);
671
+ updates.push('proxy_encrypted = ?', 'proxy_iv = ?', 'proxy_auth_tag = ?');
672
+ values.push(proxy.encrypted, proxy.iv, proxy.authTag);
673
+ }
674
+ db.prepare(`UPDATE api_keys SET ${updates.join(', ')} WHERE id = ?`).run(...values, sentinel.id);
675
+ clearCooldownsForKey(sentinel.id);
676
+ await respond(sentinel.id, maskKey(rawKey));
677
+ return;
678
+ }
679
+ }
680
+ const { encrypted, iv, authTag } = encrypt(keyToStore);
681
+ // #590: the proxy URL is encrypted like the key itself — it usually embeds
682
+ // `user:pass@` credentials. Absent/'' stores NULLs = no override.
683
+ const proxy = encryptProxyUrl(proxyUrl);
684
+ const result = db.prepare(`
685
+ INSERT INTO api_keys (platform, label, encrypted_key, iv, auth_tag, status, enabled, proxy_encrypted, proxy_iv, proxy_auth_tag)
686
+ VALUES (?, ?, ?, ?, ?, 'unknown', 1, ?, ?, ?)
687
+ `).run(platform, label ?? '', encrypted, iv, authTag, proxy.encrypted, proxy.iv, proxy.authTag);
688
+ // Auto-discover models if the catalog hasn't shipped them for this provider
689
+ let modelsAvailable = enabledModelCount(platform);
690
+ if (modelsAvailable === 0) {
691
+ try {
692
+ await autoDiscoverModels(platform);
693
+ }
694
+ catch { }
695
+ modelsAvailable = enabledModelCount(platform);
696
+ }
697
+ // #1348: fetch the provider's models in the background when the catalog
698
+ // carries none for it. No-op for every other platform.
699
+ triggerBuiltinModelDiscovery(db, platform, 'key_added');
700
+ res.status(201).json({
701
+ id: result.lastInsertRowid,
702
+ platform,
703
+ label: label ?? '',
704
+ maskedProxyUrl: maskProxyUrl(proxyUrl),
705
+ maskedKey: maskKey(keyToStore),
706
+ status: 'unknown',
707
+ enabled: true,
708
+ modelsAvailable,
709
+ notice: modelsAvailable > 0 ? undefined : noModelsNotice(platform),
710
+ });
711
+ });
712
+ // ── Custom OpenAI-compatible providers (#117, #212) ───────────────────────
713
+ // User-configured endpoints (llama.cpp / LM Studio / vLLM / Ollama / any
714
+ // OpenAI-compatible base_url). Each DISTINCT base_url gets its own 'custom'
715
+ // api_keys row, and every registered model binds to its endpoint's key via
716
+ // models.key_id — so several custom providers coexist without overwriting
717
+ // each other (#212). Re-submitting an existing base_url updates its key/label;
718
+ // re-registering an existing model id re-binds it to the submitted endpoint.
719
+ // A model can be given as a bare id ("qwen3:4b") or as {model, displayName}.
720
+ // `model`/`displayName` (singular) stay supported for older clients; `models`
721
+ // (plural) lets one submit bind several model ids to the same endpoint. (#281)
722
+ // A custom model can declare its capabilities at registration. `supportsTools`
723
+ // defaults to 1 (modern OpenAI-compatible servers — Ollama, vLLM, LM Studio —
724
+ // all emit tool calls), `supportsVision` defaults to 0 unless declared. Leaving
725
+ // a flag unset keeps the DB default on insert and preserves the stored value on
726
+ // re-registration, so a capability the user later toggled isn't clobbered. (#470)
727
+ const modelEntrySchema = z.union([
728
+ z.string().min(1),
729
+ z.object({
730
+ model: z.string().min(1),
731
+ displayName: z.string().optional(),
732
+ supportsTools: z.boolean().optional(),
733
+ supportsVision: z.boolean().optional(),
734
+ }),
735
+ ]);
736
+ // `baseUrl` and `keyId` are both optional but at least one is required: the
737
+ // bulk registration that follows model discovery (#488) already holds the
738
+ // api_keys row it fetched the list with, and naming that row keeps the new
739
+ // models on the same credential of the endpoint's pool (#619/#640).
740
+ const customProviderSchema = z.object({
741
+ baseUrl: z.string().url('baseUrl must be a valid URL').optional(),
742
+ keyId: z.number().int().positive().optional(),
743
+ model: z.string().optional(),
744
+ models: z.array(modelEntrySchema).optional(),
745
+ displayName: z.string().optional(),
746
+ apiKey: z.string().optional(),
747
+ label: z.string().optional(),
748
+ // Top-level defaults applied to every model in this submit; a per-entry flag
749
+ // (object form) overrides them for that one model.
750
+ supportsTools: z.boolean().optional(),
751
+ supportsVision: z.boolean().optional(),
752
+ }).refine(d => d.baseUrl !== undefined || d.keyId !== undefined, { message: 'baseUrl or keyId is required' });
753
+ /**
754
+ * Turn a `{ keyId?, baseUrl? }` reference into the endpoint it names. A keyId
755
+ * is the stronger reference (it identifies one credential of the pool); a bare
756
+ * baseUrl falls back to the endpoint's first stored key, which is how the rest
757
+ * of the custom-endpoint machinery addresses an endpoint. Throws a
758
+ * `{ status, message }` for a reference that names nothing usable.
759
+ */
760
+ function resolveEndpointRef(ref) {
761
+ const db = getDb();
762
+ const requestedBaseUrl = ref.baseUrl === undefined ? undefined : normalizeBaseUrl(ref.baseUrl);
763
+ if (ref.keyId !== undefined) {
764
+ const row = db.prepare('SELECT id, platform, base_url, encrypted_key, iv, auth_tag FROM api_keys WHERE id = ?')
765
+ .get(ref.keyId);
766
+ if (!row || row.platform !== 'custom' || !row.base_url) {
767
+ throw Object.assign(new Error('keyId does not name a custom endpoint'), { status: 400 });
768
+ }
769
+ if (requestedBaseUrl !== undefined && requestedBaseUrl !== row.base_url) {
770
+ throw Object.assign(new Error('baseUrl does not match the endpoint keyId belongs to'), { status: 400 });
771
+ }
772
+ let storedKey = null;
773
+ try {
774
+ storedKey = decrypt(row.encrypted_key, row.iv, row.auth_tag);
775
+ }
776
+ catch { /* an undecryptable row still names the endpoint */ }
777
+ return { baseUrl: row.base_url, keyId: row.id, storedKey };
778
+ }
779
+ if (!requestedBaseUrl) {
780
+ throw Object.assign(new Error('baseUrl or keyId is required'), { status: 400 });
781
+ }
782
+ // Any key of this base_url serves the whole endpoint (#619), so the first one
783
+ // is as good a representative as any.
784
+ const rows = db.prepare(`
785
+ SELECT id, encrypted_key, iv, auth_tag FROM api_keys
786
+ WHERE platform = 'custom' AND base_url = ? ORDER BY id
787
+ `).all(requestedBaseUrl);
788
+ for (const row of rows) {
789
+ try {
790
+ return { baseUrl: requestedBaseUrl, keyId: row.id, storedKey: decrypt(row.encrypted_key, row.iv, row.auth_tag) };
791
+ }
792
+ catch { /* try the next credential */ }
793
+ }
794
+ return { baseUrl: requestedBaseUrl, keyId: rows[0]?.id ?? null, storedKey: null };
795
+ }
796
+ /**
797
+ * Models attached to an imported custom endpoint (#382). Ids that look like
798
+ * embedding models go through the embeddings path — it needs a dimension, so
799
+ * reuse the stored one when the model is already registered and probe the
800
+ * endpoint (as POST /api/embeddings/custom does) only for a new id. Everything
801
+ * else registers as a chat model. A failure registers as a per-model error and
802
+ * skips that model only: the key import itself has already succeeded.
803
+ */
804
+ async function registerImportedModels(db, baseUrl, keyId, apiKey, keyName, models, errors) {
805
+ // #1051: classify by id, not just /embedding/. A whisper, diffusion or video
806
+ // id registered as a chat model 404s in every chain; they go to their own
807
+ // tables (or, for video, are skipped — nothing can serve a custom video
808
+ // model yet).
809
+ const kindOf = (id) => /embedding/i.test(id) ? 'embedding' : classifyModelId(id);
810
+ const chat = models.filter(m => kindOf(m.id) === undefined);
811
+ const embeds = models.filter(m => kindOf(m.id) === 'embedding');
812
+ const media = models.filter(m => {
813
+ const kind = kindOf(m.id);
814
+ return kind === 'image' || kind === 'audio' || kind === 'transcription';
815
+ });
816
+ const video = models.filter(m => kindOf(m.id) === 'video');
817
+ let registered = 0;
818
+ if (chat.length > 0) {
819
+ const entries = chat.map(m => ({
820
+ modelId: m.id,
821
+ displayName: null,
822
+ supportsTools: m.supportsTools,
823
+ supportsVision: m.supportsVision,
824
+ }));
825
+ registered += db.transaction(() => registerCustomChatModels(db, baseUrl, keyId, entries))().length;
826
+ }
827
+ for (const m of media) {
828
+ try {
829
+ registerCustomMediaModel(db, keyId, {
830
+ modelId: m.id,
831
+ displayName: null,
832
+ modality: kindOf(m.id),
833
+ });
834
+ registered++;
835
+ }
836
+ catch (err) {
837
+ errors.push({ key: `${keyName}: ${m.id}`, error: err?.message ?? 'media registration failed' });
838
+ }
839
+ }
840
+ for (const m of video) {
841
+ errors.push({ key: `${keyName}: ${m.id}`, error: 'video generation models are not supported on custom endpoints yet; skipped' });
842
+ }
843
+ for (const m of embeds) {
844
+ try {
845
+ const existing = db.prepare("SELECT dimensions FROM embedding_models WHERE platform = 'custom' AND model_id = ?").get(m.id);
846
+ const dimensions = existing?.dimensions ?? await probeEmbeddingDimensions(baseUrl, apiKey, m.id);
847
+ registerCustomEmbeddingModel(db, {
848
+ keyId,
849
+ modelId: m.id,
850
+ displayName: null,
851
+ family: m.id,
852
+ dimensions,
853
+ maxInputTokens: null,
854
+ quotaLabel: 'custom endpoint',
855
+ });
856
+ registered++;
857
+ }
858
+ catch (err) {
859
+ errors.push({ key: `${keyName}: ${m.id}`, error: err?.message ?? 'embedding registration failed' });
860
+ }
861
+ }
862
+ return registered;
863
+ }
864
+ // SSRF guard (#440): a base_url is the one user-controlled outbound target.
865
+ // Cloud metadata / link-local addresses are rejected outright; private ranges
866
+ // too when SROUTER_BLOCK_PRIVATE_PROVIDER_URLS is set. Re-checked at request
867
+ // time in proxyFetch for URLs already in the DB.
868
+ async function rejectUnsafeBaseUrl(baseUrl, res) {
869
+ const verdict = await assessProviderUrl(baseUrl);
870
+ if (verdict.allowed)
871
+ return false;
872
+ res.status(400).json({ error: { message: `baseUrl rejected: ${verdict.reason}` } });
873
+ return true;
874
+ }
875
+ // Ask a configured custom endpoint what models it currently serves (#488).
876
+ // Relays add and drop models weekly, so re-typing the ids by hand goes stale
877
+ // immediately. This reads ONLY the operator's own base_url with the operator's
878
+ // own key — it never reads or refreshes the published provider catalog. Nothing
879
+ // is written: the picked ids come back through POST /custom to be registered.
880
+ const discoverModelsSchema = z.object({
881
+ baseUrl: z.string().url('baseUrl must be a valid URL').optional(),
882
+ keyId: z.number().int().positive().optional(),
883
+ // Lets the Keys page fetch a list for an endpoint the user is still typing in,
884
+ // before it has been saved. Falls back to the endpoint's stored credential.
885
+ apiKey: z.string().optional(),
886
+ }).refine(d => d.baseUrl !== undefined || d.keyId !== undefined, { message: 'baseUrl or keyId is required' });
887
+ keysRouter.post('/custom/discover-models', async (req, res) => {
888
+ const parsed = discoverModelsSchema.safeParse(req.body);
889
+ if (!parsed.success) {
890
+ res.status(400).json({ error: { message: parsed.error.errors.map(e => e.message).join(', ') } });
891
+ return;
892
+ }
893
+ // #1348: a keyId naming a BUILT-IN key asks that provider's own /models,
894
+ // through its registered adapter, for platforms the catalog does not carry.
895
+ if (parsed.data.keyId !== undefined && parsed.data.baseUrl === undefined) {
896
+ const builtin = builtinKeyRow(parsed.data.keyId);
897
+ if (builtin) {
898
+ await discoverForBuiltinKey(builtin, parsed.data.apiKey, res);
899
+ return;
900
+ }
901
+ }
902
+ let endpoint;
903
+ try {
904
+ endpoint = resolveEndpointRef(parsed.data);
905
+ }
906
+ catch (err) {
907
+ res.status(err.status ?? 400).json({ error: { message: err.message } });
908
+ return;
909
+ }
910
+ if (await rejectUnsafeBaseUrl(endpoint.baseUrl, res))
911
+ return;
912
+ // A submitted key wins (the user may be rotating it); otherwise use what the
913
+ // endpoint already has. Local servers with auth off keep the 'no-key'
914
+ // sentinel, which the bearer header carries harmlessly.
915
+ const apiKey = parsed.data.apiKey?.trim() || endpoint.storedKey || 'no-key';
916
+ try {
917
+ const discovered = await discoverEndpointModels(endpoint.baseUrl, apiKey);
918
+ // "Already registered" means bound to THIS endpoint — any key of the pool
919
+ // counts, since they all serve the same model list (#619).
920
+ const db = getDb();
921
+ const registeredIds = new Set();
922
+ if (endpoint.keyId != null) {
923
+ const poolIds = [...customEndpointKeyIds(db, endpoint.keyId)];
924
+ const placeholders = poolIds.map(() => '?').join(', ');
925
+ const rows = db.prepare(`SELECT model_id FROM models WHERE platform = 'custom' AND key_id IN (${placeholders})`).all(...poolIds);
926
+ for (const row of rows)
927
+ registeredIds.add(row.model_id);
928
+ }
929
+ const models = discovered.map(m => ({ ...m, registered: registeredIds.has(m.id) }));
930
+ res.json({
931
+ baseUrl: endpoint.baseUrl,
932
+ keyId: endpoint.keyId,
933
+ models,
934
+ total: models.length,
935
+ registeredCount: models.filter(m => m.registered).length,
936
+ });
937
+ }
938
+ catch (err) {
939
+ if (err instanceof ModelDiscoveryError) {
940
+ // `upstream_error`, never `authentication_error`: this status is relayed
941
+ // from the operator's own endpoint, and a client that reads a bare 401 as
942
+ // "session expired" would sign the operator out for testing a bad key.
943
+ res.status(err.status).json({ error: { message: err.message, type: 'upstream_error' } });
944
+ return;
945
+ }
946
+ res.status(502).json({ error: { message: `Model discovery failed: ${err?.message ?? 'unknown error'}` } });
947
+ }
948
+ });
949
+ function builtinKeyRow(keyId) {
950
+ const row = getDb().prepare(`
951
+ SELECT id, platform, encrypted_key, iv, auth_tag, proxy_encrypted, proxy_iv, proxy_auth_tag
952
+ FROM api_keys WHERE id = ?
953
+ `).get(keyId);
954
+ return row && row.platform !== 'custom' ? row : undefined;
955
+ }
956
+ async function discoverForBuiltinKey(key, apiKey, res) {
957
+ const db = getDb();
958
+ const eligibility = builtinDiscoveryEligibility(db, key.platform);
959
+ if (!eligibility.eligible || !eligibility.provider) {
960
+ res.status(400).json({ error: { message: ineligibleMessage(key.platform, eligibility.reason) } });
961
+ return;
962
+ }
963
+ try {
964
+ const discovered = await discoverBuiltinModels(eligibility.provider, key, apiKey);
965
+ const registeredIds = registeredModelIds(db, key.platform);
966
+ const models = discovered.map(m => ({ ...m, registered: registeredIds.has(m.id) }));
967
+ res.json({
968
+ platform: key.platform,
969
+ baseUrl: eligibility.provider.modelsUrl.replace(/\/models\/?$/, ''),
970
+ keyId: key.id,
971
+ models,
972
+ total: models.length,
973
+ registeredCount: models.filter(m => m.registered).length,
974
+ });
975
+ }
976
+ catch (err) {
977
+ if (err instanceof ModelDiscoveryError) {
978
+ // upstream_error, never authentication_error: see the custom route.
979
+ res.status(err.status).json({ error: { message: err.message, type: 'upstream_error' } });
980
+ return;
981
+ }
982
+ res.status(502).json({ error: { message: `Model discovery failed: ${err?.message ?? 'unknown error'}` } });
983
+ }
984
+ }
985
+ const registerDiscoveredSchema = z.object({
986
+ keyId: z.number().int().positive(),
987
+ models: z.array(z.string().trim().min(1).max(256)).min(1).max(500),
988
+ });
989
+ // Register the models the operator ticked in Fetch models for a built-in key.
990
+ // Chat models only: the built-in adapters route media and embeddings through
991
+ // the catalog's own tables, so a non-chat id is reported back, not stored.
992
+ keysRouter.post('/discovered-models', (req, res) => {
993
+ const parsed = registerDiscoveredSchema.safeParse(req.body);
994
+ if (!parsed.success) {
995
+ res.status(400).json({ error: { message: parsed.error.errors.map(e => e.message).join(', ') } });
996
+ return;
997
+ }
998
+ const key = builtinKeyRow(parsed.data.keyId);
999
+ if (!key) {
1000
+ res.status(400).json({ error: { message: 'keyId does not name a built-in provider key' } });
1001
+ return;
1002
+ }
1003
+ const db = getDb();
1004
+ const eligibility = builtinDiscoveryEligibility(db, key.platform);
1005
+ if (!eligibility.eligible) {
1006
+ res.status(400).json({ error: { message: ineligibleMessage(key.platform, eligibility.reason) } });
1007
+ return;
1008
+ }
1009
+ const ids = [...new Set(parsed.data.models)];
1010
+ const nonChat = ids.filter(id => classifyModelId(id) !== undefined);
1011
+ const chat = ids.filter(id => classifyModelId(id) === undefined);
1012
+ const result = registerDiscoveredModels(db, key.platform, chat.map(modelId => ({ modelId })), { explicit: true });
1013
+ res.json({
1014
+ platform: key.platform,
1015
+ created: result.created.length,
1016
+ registered: result.created,
1017
+ existing: result.existing,
1018
+ skippedNonChat: nonChat,
1019
+ });
1020
+ });
1021
+ // POST /custom/probe — fire one minimal real chat request at the endpoint so an
1022
+ // unmeasured model gains a reliability/speed sample without waiting for natural
1023
+ // traffic (#685 follow-up). Interactive: the operator is watching, so a bounded
1024
+ // timeout and a clean error beat a silent 120s hang. Only a SUCCESSFUL probe
1025
+ // writes a `requests` row (which the stats cache folds into the bandit); a
1026
+ // failure (401 / timeout / unreachable) surfaces the reason and records nothing.
1027
+ keysRouter.post('/custom/probe', async (req, res) => {
1028
+ const parsed = discoverModelsSchema.safeParse(req.body);
1029
+ if (!parsed.success) {
1030
+ res.status(400).json({ error: { message: parsed.error.errors.map(e => e.message).join(', ') } });
1031
+ return;
1032
+ }
1033
+ let endpoint;
1034
+ try {
1035
+ endpoint = resolveEndpointRef(parsed.data);
1036
+ }
1037
+ catch (err) {
1038
+ res.status(err.status ?? 400).json({ error: { message: err.message } });
1039
+ return;
1040
+ }
1041
+ if (await rejectUnsafeBaseUrl(endpoint.baseUrl, res))
1042
+ return;
1043
+ const apiKey = parsed.data.apiKey?.trim() || endpoint.storedKey || 'no-key';
1044
+ // Probe a model actually REGISTERED on this endpoint when there is one — the
1045
+ // sample must feed the stats of a model the router can pick, not whatever id
1046
+ // happens to lead the upstream /models list. Any key of the pool counts
1047
+ // (#619), and knowing the id up front also skips the discovery round-trip.
1048
+ // Only an endpoint with nothing registered falls back to discovery.
1049
+ let registeredModelId = null;
1050
+ // The endpoint's key pool, hoisted so the capability write-back below can
1051
+ // scope its UPDATE to the same keys (an unscoped model_id match would touch
1052
+ // every unrelated custom endpoint that happens to serve the same id).
1053
+ let poolIds = [];
1054
+ if (endpoint.keyId != null) {
1055
+ const db = getDb();
1056
+ poolIds = [...customEndpointKeyIds(db, endpoint.keyId)];
1057
+ const placeholders = poolIds.map(() => '?').join(', ');
1058
+ const row = db.prepare(`SELECT model_id FROM models WHERE platform = 'custom' AND key_id IN (${placeholders}) ORDER BY id LIMIT 1`).get(...poolIds);
1059
+ registeredModelId = row?.model_id ?? null;
1060
+ }
1061
+ try {
1062
+ const probe = await probeEndpointModel(endpoint.baseUrl, apiKey, registeredModelId);
1063
+ // Only a successful probe records a sample. The row mirrors what the proxy
1064
+ // writes on a real request so the decay-weighted stats cache picks it up.
1065
+ if (endpoint.keyId != null) {
1066
+ getDb().prepare(`
1067
+ INSERT INTO requests (platform, model_id, key_id, status, input_tokens, output_tokens, latency_ms, ttfb_ms, request_type)
1068
+ VALUES ('custom', ?, ?, 'success', ?, ?, ?, ?, 'chat')
1069
+ `).run(probe.modelId, endpoint.keyId, probe.inputTokens, probe.outputTokens, probe.latencyMs, probe.latencyMs);
1070
+ // A real completion just succeeded, which is stronger evidence than any
1071
+ // health ping — lift whatever cooldown was still holding the key back.
1072
+ clearCooldownsForKey(endpoint.keyId);
1073
+ }
1074
+ // Phase 1 (#874): a successful tool-call probe is positive evidence the
1075
+ // model speaks the OpenAI tool-call protocol — write it back to the models
1076
+ // row so the tool-aware router can pick it. Only a POSITIVE result writes
1077
+ // (the "only write success samples" philosophy); a negative/unknown result
1078
+ // leaves the existing flag untouched.
1079
+ //
1080
+ // Scoped to THIS endpoint's key pool: `model_id` alone is not unique across
1081
+ // custom endpoints (two relays both serving 'gpt-4o-mini' are two different
1082
+ // upstreams), so an unscoped UPDATE would claim tool support for endpoints
1083
+ // that were never probed.
1084
+ if (probe.toolCalls && poolIds.length > 0) {
1085
+ const placeholders = poolIds.map(() => '?').join(', ');
1086
+ getDb().prepare(`UPDATE models SET supports_tools = 1
1087
+ WHERE platform = 'custom' AND model_id = ? AND key_id IN (${placeholders})`).run(probe.modelId, ...poolIds);
1088
+ }
1089
+ // Response stays { modelId, latencyMs } for backward compat; capability
1090
+ // flags ride along as optional fields the client can opt to render.
1091
+ res.json({
1092
+ modelId: probe.modelId,
1093
+ latencyMs: probe.latencyMs,
1094
+ ...(probe.reasoning !== undefined ? { reasoning: probe.reasoning } : {}),
1095
+ ...(probe.toolCalls !== undefined ? { toolCalls: probe.toolCalls } : {}),
1096
+ });
1097
+ }
1098
+ catch (err) {
1099
+ if (err instanceof ModelDiscoveryError) {
1100
+ // `upstream_error`, never `authentication_error`: this status is relayed
1101
+ // from the operator's own endpoint, and a client that reads a bare 401 as
1102
+ // "session expired" would sign the operator out for testing a bad key.
1103
+ res.status(err.status).json({ error: { message: err.message, type: 'upstream_error' } });
1104
+ return;
1105
+ }
1106
+ res.status(502).json({ error: { message: `Probe failed: ${err?.message ?? 'unknown error'}` } });
1107
+ }
1108
+ });
1109
+ keysRouter.post('/custom', async (req, res) => {
1110
+ const parsed = customProviderSchema.safeParse(req.body);
1111
+ if (!parsed.success) {
1112
+ res.status(400).json({ error: { message: parsed.error.errors.map(e => e.message).join(', ') } });
1113
+ return;
1114
+ }
1115
+ let endpoint;
1116
+ try {
1117
+ endpoint = resolveEndpointRef(parsed.data);
1118
+ }
1119
+ catch (err) {
1120
+ res.status(err.status ?? 400).json({ error: { message: err.message } });
1121
+ return;
1122
+ }
1123
+ const baseUrl = endpoint.baseUrl;
1124
+ if (await rejectUnsafeBaseUrl(baseUrl, res))
1125
+ return;
1126
+ // Local servers often need no key; keep a sentinel so there's always a bearer.
1127
+ const providedKey = parsed.data.apiKey?.trim() || undefined;
1128
+ const label = parsed.data.label?.trim() || undefined;
1129
+ // Flatten singular + plural inputs into one list, dedupe by model id, drop
1130
+ // blanks. Capability flags resolve per-entry first, then fall back to the
1131
+ // submit-level defaults, then to undefined (DB default). `displayName` is
1132
+ // optional and stays NULL when the submit did not name the model: the field
1133
+ // is genuinely optional, so an unnamed model takes its id on insert and keeps
1134
+ // whatever name it already has on re-registration (see the upsert below).
1135
+ const topTools = parsed.data.supportsTools;
1136
+ const topVision = parsed.data.supportsVision;
1137
+ const entries = [];
1138
+ const seen = new Set();
1139
+ const addEntry = (rawId, rawDisplay, tools, vision) => {
1140
+ const modelId = rawId.trim();
1141
+ if (!modelId || seen.has(modelId))
1142
+ return;
1143
+ seen.add(modelId);
1144
+ entries.push({
1145
+ modelId,
1146
+ displayName: rawDisplay?.trim() || null,
1147
+ supportsTools: tools ?? topTools,
1148
+ supportsVision: vision ?? topVision,
1149
+ });
1150
+ };
1151
+ if (parsed.data.model?.trim())
1152
+ addEntry(parsed.data.model, parsed.data.displayName);
1153
+ for (const m of parsed.data.models ?? []) {
1154
+ if (typeof m === 'string')
1155
+ addEntry(m);
1156
+ else
1157
+ addEntry(m.model, m.displayName, m.supportsTools, m.supportsVision);
1158
+ }
1159
+ // A lone model submitted through the plural `models` also carries its name in
1160
+ // the top-level `displayName` — that is exactly what the dashboard's custom
1161
+ // form sends, and binding the field to the singular `model` alone silently
1162
+ // dropped the name the user typed, leaving the row named after its model id
1163
+ // (#704). Only a submit that resolves to ONE unnamed model may take it: a
1164
+ // single name cannot fan out across several ids, which is why the form
1165
+ // disables the input as soon as a second id is entered.
1166
+ const soleName = parsed.data.displayName?.trim();
1167
+ if (soleName && !parsed.data.model?.trim() && entries.length === 1 && entries[0].displayName === null) {
1168
+ entries[0].displayName = soleName;
1169
+ }
1170
+ const db = getDb();
1171
+ // Credential-only add (#702): a relay hands out one key per plan, and pooling
1172
+ // them is the point of #619, but every route into this handler used to demand
1173
+ // a model alongside the key. On an endpoint whose models are all registered
1174
+ // there was nothing left to name, so operators invented a throwaway model id
1175
+ // to get past the check, and deleting it afterwards took the new key with it.
1176
+ if (entries.length === 0) {
1177
+ if (!providedKey) {
1178
+ res.status(400).json({ error: { message: 'model or models is required' } });
1179
+ return;
1180
+ }
1181
+ if (endpoint.keyId === null) {
1182
+ res.status(400).json({ error: { message: 'model or models is required to register a new endpoint' } });
1183
+ return;
1184
+ }
1185
+ if (endpointHasCredential(db, baseUrl, providedKey)) {
1186
+ res.status(409).json({ error: { message: 'this endpoint already has that key' } });
1187
+ return;
1188
+ }
1189
+ const added = resolveCustomEndpointKey(db, baseUrl, providedKey, label, endpoint.keyId);
1190
+ res.status(added.created ? 201 : 200).json({
1191
+ success: true,
1192
+ keyId: added.keyId,
1193
+ platform: 'custom',
1194
+ baseUrl,
1195
+ models: [],
1196
+ created: 0,
1197
+ alreadyRegistered: 0,
1198
+ maskedKey: maskKey(added.storedKey),
1199
+ });
1200
+ return;
1201
+ }
1202
+ // #1051: a whisper, diffusion or video id submitted here used to register as
1203
+ // a chat model and then 404 in every chain. Classify by id and route each
1204
+ // entry to the table that can actually serve it. An explicit capability flag
1205
+ // on the entry is an operator override: someone who says "this is a chat
1206
+ // model with vision/tools" wins over the keyword guess.
1207
+ const overridden = (e) => e.supportsTools !== undefined || e.supportsVision !== undefined;
1208
+ const kindOf = (e) => overridden(e) ? undefined : classifyModelId(e.modelId);
1209
+ const chatEntries = entries.filter(e => kindOf(e) === undefined || kindOf(e) === 'embedding');
1210
+ const embedEntries = chatEntries.filter(e => kindOf(e) === 'embedding');
1211
+ const pureChatEntries = chatEntries.filter(e => kindOf(e) !== 'embedding');
1212
+ const mediaEntries = entries.filter(e => {
1213
+ const kind = kindOf(e);
1214
+ return kind === 'image' || kind === 'audio' || kind === 'transcription';
1215
+ });
1216
+ const videoEntries = entries.filter(e => kindOf(e) === 'video');
1217
+ const { keyId, storedKey, registered } = registerCustomModels(db, baseUrl, providedKey, label, endpoint.keyId ?? undefined, pureChatEntries);
1218
+ const mediaRegistered = [];
1219
+ const perModelErrors = [];
1220
+ for (const e of mediaEntries) {
1221
+ try {
1222
+ mediaRegistered.push(db.transaction(() => registerCustomMediaModel(db, keyId, {
1223
+ modelId: e.modelId,
1224
+ displayName: e.displayName,
1225
+ modality: kindOf(e),
1226
+ }))());
1227
+ }
1228
+ catch (err) {
1229
+ perModelErrors.push({ model: e.modelId, error: err?.message ?? 'media registration failed' });
1230
+ }
1231
+ }
1232
+ const embeddingsRegistered = [];
1233
+ for (const e of embedEntries) {
1234
+ try {
1235
+ const existing = db.prepare("SELECT dimensions FROM embedding_models WHERE platform = 'custom' AND model_id = ?").get(e.modelId);
1236
+ const dimensions = existing?.dimensions ?? await probeEmbeddingDimensions(baseUrl, providedKey ?? storedKey, e.modelId);
1237
+ registerCustomEmbeddingModel(db, {
1238
+ keyId,
1239
+ modelId: e.modelId,
1240
+ displayName: e.displayName,
1241
+ family: e.modelId,
1242
+ dimensions,
1243
+ maxInputTokens: null,
1244
+ quotaLabel: 'custom endpoint',
1245
+ });
1246
+ embeddingsRegistered.push({ model: e.modelId });
1247
+ }
1248
+ catch (err) {
1249
+ perModelErrors.push({ model: e.modelId, error: err?.message ?? 'embedding registration failed' });
1250
+ }
1251
+ }
1252
+ // `model`/`displayName`/`modelDbId` echo the first model for older clients;
1253
+ // `models` carries the full set registered in this call.
1254
+ const first = registered[0];
1255
+ res.status(201).json({
1256
+ success: true,
1257
+ keyId,
1258
+ ...(first ? {
1259
+ modelDbId: first.modelDbId,
1260
+ model: first.model,
1261
+ displayName: first.displayName,
1262
+ supportsTools: first.supportsTools,
1263
+ supportsVision: first.supportsVision,
1264
+ } : {}),
1265
+ platform: 'custom',
1266
+ baseUrl,
1267
+ models: registered,
1268
+ media: mediaRegistered,
1269
+ embeddings: embeddingsRegistered,
1270
+ // Video generation has no custom-endpoint serving path yet; saying so
1271
+ // beats registering a chat model that can never answer.
1272
+ videoSkipped: videoEntries.map(e => e.modelId),
1273
+ modelErrors: perModelErrors,
1274
+ // Bulk registration (#488) needs to tell the user what actually changed:
1275
+ // picking a whole discovered list re-submits ids that are already there.
1276
+ created: registered.filter(m => m.created).length
1277
+ + mediaRegistered.filter(m => m.created).length
1278
+ + embeddingsRegistered.length,
1279
+ alreadyRegistered: registered.filter(m => !m.created).length
1280
+ + mediaRegistered.filter(m => !m.created).length,
1281
+ maskedKey: maskKey(storedKey),
1282
+ });
1283
+ });
1284
+ keysRouter.post('/import', (req, res, next) => {
1285
+ upload.single('file')(req, res, async (err) => {
1286
+ if (handleUploadError(err, res, next))
1287
+ return;
1288
+ try {
1289
+ if (!req.file) {
1290
+ res.status(400).json({ error: { message: 'No file uploaded' } });
1291
+ return;
1292
+ }
1293
+ const result = parseUpload(req.file);
1294
+ const imported = [];
1295
+ const skipped = [...result.skipped];
1296
+ const errors = [];
1297
+ let modelsRegistered = 0;
1298
+ // One SSRF verdict per endpoint, not per key: a pooled endpoint (#619)
1299
+ // brings several keys to the same URL and each check costs a DNS lookup.
1300
+ const urlVerdicts = new Map();
1301
+ for (const parsedKey of result.keys) {
1302
+ const { keyName, keyValue } = splitParsedKey(parsedKey);
1303
+ if (!parsedKey.platform) {
1304
+ skipped.push(keyName);
1305
+ continue;
1306
+ }
1307
+ const platformParse = z.enum(PLATFORMS).safeParse(parsedKey.platform);
1308
+ if (!platformParse.success) {
1309
+ skipped.push(keyName);
1310
+ continue;
1311
+ }
1312
+ // Custom rows name an ENDPOINT. They used to be dropped outright, which
1313
+ // made every export/import round trip lose them (#687); they import now,
1314
+ // but only with the base_url that says where the endpoint is.
1315
+ if (platformParse.data === 'custom') {
1316
+ if (!parsedKey.baseUrl) {
1317
+ skipped.push(keyName);
1318
+ continue;
1319
+ }
1320
+ const baseUrl = normalizeBaseUrl(parsedKey.baseUrl);
1321
+ // Same SSRF guard the manual add path uses (#440) — an import file is
1322
+ // just as capable of naming a cloud metadata address.
1323
+ let verdict = urlVerdicts.get(baseUrl);
1324
+ if (!verdict) {
1325
+ verdict = await assessProviderUrl(baseUrl);
1326
+ urlVerdicts.set(baseUrl, verdict);
1327
+ }
1328
+ if (!verdict.allowed) {
1329
+ errors.push({ key: keyName, error: `baseUrl rejected: ${verdict.reason}` });
1330
+ continue;
1331
+ }
1332
+ try {
1333
+ // A placeholder value means "endpoint with auth off" — hand it to
1334
+ // the resolver as "no key submitted" so it stores the sentinel once
1335
+ // instead of treating 'no-key' as a real secret.
1336
+ const secret = keyValue.trim() === 'no-key' ? undefined : keyValue.trim() || undefined;
1337
+ const db = getDb();
1338
+ const resolved = resolveCustomEndpointKey(db, baseUrl, secret, keyName);
1339
+ imported.push({ keyName, platform: 'custom' });
1340
+ if (parsedKey.models?.length) {
1341
+ modelsRegistered += await registerImportedModels(db, baseUrl, resolved.keyId, resolved.storedKey, keyName, parsedKey.models, errors);
1342
+ }
1343
+ }
1344
+ catch (insertErr) {
1345
+ errors.push({ key: keyName, error: insertErr.message });
1346
+ }
1347
+ continue;
1348
+ }
1349
+ if (!keyValue.trim()) {
1350
+ errors.push({ key: keyName, error: 'keyValue must be at least 1 character' });
1351
+ continue;
1352
+ }
1353
+ try {
1354
+ insertImportedKey(platformParse.data, keyName, keyValue);
1355
+ imported.push({ keyName, platform: platformParse.data });
1356
+ }
1357
+ catch (insertErr) {
1358
+ errors.push({ key: keyName, error: insertErr.message });
1359
+ }
1360
+ }
1361
+ res.json({
1362
+ imported: imported.length,
1363
+ skipped,
1364
+ errors,
1365
+ total: result.keys.length + result.skipped.length,
1366
+ modelsRegistered,
1367
+ });
1368
+ }
1369
+ catch (handlerErr) {
1370
+ res.status(handlerErr.status ?? 500).json({ error: { message: handlerErr.message } });
1371
+ }
1372
+ });
1373
+ });
1374
+ keysRouter.post('/preview', (req, res, next) => {
1375
+ upload.array('files', 10)(req, res, (err) => {
1376
+ if (handleUploadError(err, res, next))
1377
+ return;
1378
+ try {
1379
+ const files = req.files;
1380
+ if (!files || files.length === 0) {
1381
+ res.status(400).json({ error: { message: 'No files uploaded' } });
1382
+ return;
1383
+ }
1384
+ const keys = [];
1385
+ const skipped = [];
1386
+ // Build a set of existing decrypted key values for duplicate detection
1387
+ const db = getDb();
1388
+ const existingRows = db.prepare('SELECT encrypted_key, iv, auth_tag FROM api_keys').all();
1389
+ const existingKeys = new Set();
1390
+ for (const row of existingRows) {
1391
+ try {
1392
+ existingKeys.add(decrypt(row.encrypted_key, row.iv, row.auth_tag));
1393
+ }
1394
+ catch { /* skip undecryptable rows */ }
1395
+ }
1396
+ let duplicateCount = 0;
1397
+ for (const file of files) {
1398
+ const result = parseUpload(file);
1399
+ for (const parsedKey of result.keys) {
1400
+ const { keyName, keyValue } = splitParsedKey(parsedKey);
1401
+ const isDuplicate = existingKeys.has(keyValue.trim());
1402
+ if (isDuplicate)
1403
+ duplicateCount++;
1404
+ keys.push({
1405
+ keyName,
1406
+ keyValue,
1407
+ detectedPlatform: parsedKey.platform,
1408
+ prefix: parsedKey.prefix,
1409
+ // Without this the dialog can show a custom row but never restore
1410
+ // it — the endpoint it belongs to would be lost between the two
1411
+ // calls the dashboard actually makes (#687). Models ride the same
1412
+ // round trip (#382).
1413
+ ...(parsedKey.baseUrl ? { baseUrl: parsedKey.baseUrl } : {}),
1414
+ ...(parsedKey.models?.length ? { models: parsedKey.models } : {}),
1415
+ isDuplicate,
1416
+ });
1417
+ }
1418
+ skipped.push(...result.skipped);
1419
+ }
1420
+ res.json({ keys, total: keys.length, skipped, duplicates: duplicateCount });
1421
+ }
1422
+ catch (handlerErr) {
1423
+ res.status(handlerErr.status ?? 500).json({ error: { message: handlerErr.message } });
1424
+ }
1425
+ });
1426
+ });
1427
+ keysRouter.post('/import-selected', async (req, res) => {
1428
+ const parsed = z.object({ keys: z.array(importKeySchema).max(100) }).safeParse(req.body);
1429
+ if (!parsed.success) {
1430
+ res.status(400).json({ error: { message: parsed.error.errors.map(e => e.message).join(', ') } });
1431
+ return;
1432
+ }
1433
+ let imported = 0;
1434
+ let modelsRegistered = 0;
1435
+ const errors = [];
1436
+ // Build a set of existing decrypted key values for duplicate detection
1437
+ const db = getDb();
1438
+ const existingRows = db.prepare('SELECT encrypted_key, iv, auth_tag FROM api_keys').all();
1439
+ const existingKeys = new Set();
1440
+ for (const row of existingRows) {
1441
+ try {
1442
+ existingKeys.add(decrypt(row.encrypted_key, row.iv, row.auth_tag));
1443
+ }
1444
+ catch { /* skip undecryptable rows */ }
1445
+ }
1446
+ // One SSRF verdict per endpoint, not per key — same reasoning as /import: a
1447
+ // pooled endpoint brings several keys to one URL and each check costs a DNS
1448
+ // lookup.
1449
+ const urlVerdicts = new Map();
1450
+ for (const key of parsed.data.keys) {
1451
+ const keyName = key.keyName?.trim() || key.platform;
1452
+ // This is the route the dashboard actually uses (preview → import-selected),
1453
+ // so a custom endpoint could not be restored from its own export file until
1454
+ // it handled one — /import alone was never reachable from the UI (#687).
1455
+ if (key.platform === 'custom') {
1456
+ if (!key.baseUrl) {
1457
+ errors.push({ key: keyName, error: 'Custom providers must be added with a base URL' });
1458
+ continue;
1459
+ }
1460
+ const baseUrl = normalizeBaseUrl(key.baseUrl);
1461
+ // An import file can name a cloud metadata address just as easily as the
1462
+ // manual add form can (#440), so it gets the same guard.
1463
+ let verdict = urlVerdicts.get(baseUrl);
1464
+ if (!verdict) {
1465
+ verdict = await assessProviderUrl(baseUrl);
1466
+ urlVerdicts.set(baseUrl, verdict);
1467
+ }
1468
+ if (!verdict.allowed) {
1469
+ errors.push({ key: keyName, error: `baseUrl rejected: ${verdict.reason}` });
1470
+ continue;
1471
+ }
1472
+ try {
1473
+ // 'no-key' is the auth-less-local-server placeholder, not a secret —
1474
+ // hand it over as "no key submitted" so the resolver stores the
1475
+ // sentinel once instead of encrypting the literal string. Going
1476
+ // through the resolver (not a raw INSERT) keeps endpoint pooling
1477
+ // intact, so re-importing does not duplicate the row (#619).
1478
+ const secret = key.keyValue.trim() === 'no-key' ? undefined : key.keyValue.trim() || undefined;
1479
+ const resolved = resolveCustomEndpointKey(db, baseUrl, secret, keyName);
1480
+ imported++;
1481
+ if (key.models?.length) {
1482
+ modelsRegistered += await registerImportedModels(db, baseUrl, resolved.keyId, resolved.storedKey, keyName, key.models, errors);
1483
+ }
1484
+ }
1485
+ catch (err) {
1486
+ errors.push({ key: keyName, error: err.message });
1487
+ }
1488
+ continue;
1489
+ }
1490
+ if (existingKeys.has(key.keyValue.trim())) {
1491
+ errors.push({ key: keyName, error: 'Duplicate key — already exists' });
1492
+ continue;
1493
+ }
1494
+ try {
1495
+ insertImportedKey(key.platform, keyName, key.keyValue);
1496
+ imported++;
1497
+ existingKeys.add(key.keyValue.trim());
1498
+ }
1499
+ catch (err) {
1500
+ errors.push({ key: keyName, error: err.message });
1501
+ }
1502
+ }
1503
+ res.json({
1504
+ imported,
1505
+ skipped: [],
1506
+ errors,
1507
+ total: parsed.data.keys.length,
1508
+ modelsRegistered,
1509
+ });
1510
+ });
1511
+ // Delete a key
1512
+ keysRouter.delete('/:id', (req, res) => {
1513
+ const id = parseInt(req.params.id, 10);
1514
+ if (isNaN(id)) {
1515
+ res.status(400).json({ error: { message: 'Invalid key ID' } });
1516
+ return;
1517
+ }
1518
+ const db = getDb();
1519
+ const row = db.prepare('SELECT platform, base_url FROM api_keys WHERE id = ?').get(id);
1520
+ if (!row) {
1521
+ res.status(404).json({ error: { message: 'Key not found' } });
1522
+ return;
1523
+ }
1524
+ // Another credential for the SAME endpoint keeps it alive — the models move
1525
+ // over to it instead of being cascaded away with this key (#619).
1526
+ const sibling = row.platform === 'custom' ? siblingEndpointKeyId(db, id, row.base_url) : null;
1527
+ const remove = db.transaction(() => {
1528
+ if (sibling != null) {
1529
+ for (const table of ['models', 'embedding_models', 'media_models']) {
1530
+ db.prepare(`UPDATE ${table} SET key_id = ? WHERE platform = 'custom' AND key_id = ?`).run(sibling, id);
1531
+ }
1532
+ db.prepare('DELETE FROM api_keys WHERE id = ?').run(id);
1533
+ return;
1534
+ }
1535
+ db.prepare('DELETE FROM api_keys WHERE id = ?').run(id);
1536
+ // Custom models exist only because POST /custom registered them alongside
1537
+ // their endpoint key (#117) — they can't route without it. Cascade away
1538
+ // the models bound to THIS endpoint (#212); other custom providers keep
1539
+ // theirs. Legacy rows (key_id NULL) are swept once no custom keys remain,
1540
+ // so they never linger in the fallback chain forever (#189).
1541
+ if (row.platform === 'custom') {
1542
+ const defaultEmbedding = db.prepare("SELECT value FROM settings WHERE key = 'embeddings_default_family'").get();
1543
+ // #926: the scheduled custom-model sync only knows a model by "is it in
1544
+ // the table yet". Without a tombstone, a key the operator deleted to get
1545
+ // rid of its models would have them all re-registered by the next pass.
1546
+ const scope = endpointScopeForBaseUrl(row.base_url);
1547
+ const doomed = db.prepare("SELECT model_id FROM models WHERE platform = 'custom' AND key_id = ?").all(id);
1548
+ for (const m of doomed)
1549
+ recordCustomModelTombstone(db, scope, m.model_id);
1550
+ db.prepare("DELETE FROM fallback_config WHERE model_db_id IN (SELECT id FROM models WHERE platform = 'custom' AND key_id = ?)").run(id);
1551
+ db.prepare("DELETE FROM models WHERE platform = 'custom' AND key_id = ?").run(id);
1552
+ db.prepare("DELETE FROM embedding_models WHERE platform = 'custom' AND key_id = ?").run(id);
1553
+ db.prepare("DELETE FROM media_models WHERE platform = 'custom' AND key_id = ?").run(id);
1554
+ const remaining = db.prepare("SELECT COUNT(*) AS n FROM api_keys WHERE platform = 'custom'").get();
1555
+ if (remaining.n === 0) {
1556
+ db.prepare("DELETE FROM fallback_config WHERE model_db_id IN (SELECT id FROM models WHERE platform = 'custom')").run();
1557
+ db.prepare("DELETE FROM models WHERE platform = 'custom'").run();
1558
+ db.prepare("DELETE FROM embedding_models WHERE platform = 'custom'").run();
1559
+ db.prepare("DELETE FROM media_models WHERE platform = 'custom'").run();
1560
+ }
1561
+ if (defaultEmbedding) {
1562
+ const stillExists = db.prepare('SELECT 1 FROM embedding_models WHERE family = ? LIMIT 1').get(defaultEmbedding.value);
1563
+ if (!stillExists) {
1564
+ const replacement = db.prepare('SELECT family FROM embedding_models ORDER BY family, priority LIMIT 1').get();
1565
+ if (replacement) {
1566
+ db.prepare("UPDATE settings SET value = ? WHERE key = 'embeddings_default_family'").run(replacement.family);
1567
+ }
1568
+ }
1569
+ }
1570
+ }
1571
+ });
1572
+ remove();
1573
+ res.json({ success: true });
1574
+ });
1575
+ // Manually re-verify a platform's discovered model roster against its live
1576
+ // upstream (the same sweep the twice-daily timer runs). This is the "Sync
1577
+ // models" button: rosters move, and a model that flipped behind a paid gate
1578
+ // upstream would otherwise stay in the routing chain until the next scheduled
1579
+ // sweep — the health check only probes keys, never the model list.
1580
+ keysRouter.post('/platform/:platform/refresh-models', async (req, res) => {
1581
+ const platform = req.params.platform;
1582
+ if (!PLATFORMS.includes(platform)) {
1583
+ res.status(400).json({ error: { message: `Invalid platform '${platform}'` } });
1584
+ return;
1585
+ }
1586
+ const db = getDb();
1587
+ const key = db.prepare('SELECT 1 FROM api_keys WHERE platform = ? AND enabled = 1 LIMIT 1').get(platform);
1588
+ if (!key) {
1589
+ res.status(409).json({ error: { message: `Platform '${platform}' has no enabled API key` } });
1590
+ return;
1591
+ }
1592
+ const modelsAvailableBefore = enabledModelCount(platform);
1593
+ let result;
1594
+ try {
1595
+ // refresh re-runs the sweep even though models already exist — that is the
1596
+ // entire point of a manual sync (adding, dropping and re-verifying rows).
1597
+ result = await autoDiscoverModels(platform, { refresh: true });
1598
+ }
1599
+ catch (err) {
1600
+ res.status(502).json({ error: { message: `Model refresh failed: ${err instanceof Error ? err.message : String(err)}` } });
1601
+ return;
1602
+ }
1603
+ res.json({ platform, registered: result.registered, refreshed: result.refreshed, retired: result.retired, modelsAvailable: enabledModelCount(platform), previousModelsAvailable: modelsAvailableBefore, newModels: result.newModels, updatedModels: result.updatedModels, retiredModels: result.retiredModels });
1604
+ });
1605
+ // Refresh model rosters for ALL platforms at once — the "Sync All" button.
1606
+ // Runs catalog sync first (pulls latest from central catalog), then
1607
+ // auto-discovers models from each platform's API (user-key models).
1608
+ // Per-platform error isolation ensures one failure doesn't block the rest.
1609
+ keysRouter.post('/refresh-all-models', async (_req, res) => {
1610
+ const db = getDb();
1611
+ // 1) Catalog sync — pull latest models from the central catalog
1612
+ let catalogSyncResult;
1613
+ try {
1614
+ const cs = await syncCatalog(true);
1615
+ catalogSyncResult = cs.counts;
1616
+ }
1617
+ catch (err) {
1618
+ console.warn(`[sync-all] catalog sync failed: ${err instanceof Error ? err.message : err}`);
1619
+ }
1620
+ // 2) Auto-discover — query each platform's API for user-key models
1621
+ const platforms = db.prepare(`
1622
+ SELECT DISTINCT platform FROM api_keys WHERE enabled = 1
1623
+ `).all();
1624
+ const results = [];
1625
+ let totalRegistered = 0;
1626
+ let totalRefreshed = 0;
1627
+ let totalRetired = 0;
1628
+ for (const { platform } of platforms) {
1629
+ if (!PLATFORMS.includes(platform))
1630
+ continue;
1631
+ const modelsAvailableBefore = enabledModelCount(platform);
1632
+ try {
1633
+ const r = await autoDiscoverModels(platform, { refresh: true });
1634
+ totalRegistered += r.registered;
1635
+ totalRefreshed += r.refreshed;
1636
+ totalRetired += r.retired;
1637
+ results.push({ platform, registered: r.registered, refreshed: r.refreshed, retired: r.retired, modelsAvailable: enabledModelCount(platform), previousModelsAvailable: modelsAvailableBefore, newModels: r.newModels, updatedModels: r.updatedModels, retiredModels: r.retiredModels });
1638
+ }
1639
+ catch (err) {
1640
+ results.push({ platform, registered: 0, refreshed: 0, retired: 0, modelsAvailable: enabledModelCount(platform), previousModelsAvailable: modelsAvailableBefore, newModels: [], updatedModels: [], retiredModels: [], error: err instanceof Error ? err.message : String(err) });
1641
+ }
1642
+ }
1643
+ res.json({ platforms: results.length, totalRegistered, totalRefreshed, totalRetired, catalogSync: catalogSyncResult, results });
1644
+ });
1645
+ // Toggle all keys for a platform
1646
+ keysRouter.patch('/platform/:platform', (req, res) => {
1647
+ const platform = req.params.platform;
1648
+ if (!PLATFORMS.includes(platform)) {
1649
+ res.status(400).json({ error: { message: `Invalid platform '${platform}'` } });
1650
+ return;
1651
+ }
1652
+ const { enabled } = req.body;
1653
+ if (typeof enabled !== 'boolean') {
1654
+ res.status(400).json({ error: { message: 'enabled must be a boolean' } });
1655
+ return;
1656
+ }
1657
+ const db = getDb();
1658
+ const result = db.prepare('UPDATE api_keys SET enabled = ? WHERE platform = ?').run(enabled ? 1 : 0, platform);
1659
+ res.json({ success: true, enabled, updatedKeys: result.changes });
1660
+ });
1661
+ // Update key (toggle enable/disable or edit label)
1662
+ keysRouter.patch('/:id', (req, res) => {
1663
+ const id = parseInt(req.params.id, 10);
1664
+ if (isNaN(id)) {
1665
+ res.status(400).json({ error: { message: 'Invalid key ID' } });
1666
+ return;
1667
+ }
1668
+ const parsed = updateKeySchema.safeParse(req.body);
1669
+ if (!parsed.success) {
1670
+ res.status(400).json({ error: { message: parsed.error.errors.map(e => e.message).join(', ') } });
1671
+ return;
1672
+ }
1673
+ const { enabled, label, modelScope, proxyUrl, key, monthlyRequestCap, monthlyTokenCap, budgetPeriod } = parsed.data;
1674
+ const updates = [];
1675
+ const values = [];
1676
+ let changedKey;
1677
+ if (key !== undefined) {
1678
+ const db = getDb();
1679
+ const stored = db.prepare('SELECT platform, encrypted_key, iv, auth_tag FROM api_keys WHERE id = ?')
1680
+ .get(id);
1681
+ if (!stored) {
1682
+ res.status(404).json({ error: { message: 'Key not found' } });
1683
+ return;
1684
+ }
1685
+ // Key-optional platforms (Kilo, OVH, AI Horde) accept a real key here like
1686
+ // any other provider: it replaces the anonymous sentinel (#1331).
1687
+ if (stored.platform === 'cloudflare') {
1688
+ const separator = key.indexOf(':');
1689
+ if (separator < 1 || !key.slice(0, separator).trim() || !key.slice(separator + 1).trim()) {
1690
+ res.status(400).json({ error: { message: 'Cloudflare key must be in format "account_id:api_token"' } });
1691
+ return;
1692
+ }
1693
+ }
1694
+ try {
1695
+ if (decrypt(stored.encrypted_key, stored.iv, stored.auth_tag) !== key)
1696
+ changedKey = key;
1697
+ }
1698
+ catch {
1699
+ // An undecryptable old credential is still replaceable.
1700
+ changedKey = key;
1701
+ }
1702
+ }
1703
+ if (enabled !== undefined) {
1704
+ updates.push('enabled = ?');
1705
+ values.push(enabled ? 1 : 0);
1706
+ }
1707
+ if (changedKey !== undefined) {
1708
+ const { encrypted, iv, authTag } = encrypt(changedKey);
1709
+ updates.push('encrypted_key = ?', 'iv = ?', 'auth_tag = ?', "status = 'unknown'", 'last_checked_at = NULL', 'last_health_error = NULL');
1710
+ values.push(encrypted, iv, authTag);
1711
+ }
1712
+ if (label !== undefined) {
1713
+ updates.push('label = ?');
1714
+ values.push(label);
1715
+ }
1716
+ // #590: stored encrypted (credentials), so a change rewrites all three
1717
+ // columns; '' clears them to NULL.
1718
+ if (proxyUrl !== undefined) {
1719
+ const proxy = encryptProxyUrl(proxyUrl);
1720
+ updates.push('proxy_encrypted = ?', 'proxy_iv = ?', 'proxy_auth_tag = ?');
1721
+ values.push(proxy.encrypted, proxy.iv, proxy.authTag);
1722
+ }
1723
+ // Monthly budget caps (#1158): 0 = unlimited.
1724
+ if (monthlyRequestCap !== undefined) {
1725
+ updates.push('monthly_request_cap = ?');
1726
+ values.push(monthlyRequestCap);
1727
+ }
1728
+ if (monthlyTokenCap !== undefined) {
1729
+ updates.push('monthly_token_cap = ?');
1730
+ values.push(monthlyTokenCap);
1731
+ }
1732
+ // Period switch starts the new counter at zero (buckets are namespaced).
1733
+ if (budgetPeriod !== undefined) {
1734
+ const platformRow = getDb().prepare('SELECT platform FROM api_keys WHERE id = ?').get(id);
1735
+ if (!platformRow) {
1736
+ res.status(404).json({ error: { message: 'Key not found' } });
1737
+ return;
1738
+ }
1739
+ if (budgetPeriod === 'auto') {
1740
+ const resolved = resolveAutoPeriod(platformRow.platform);
1741
+ updates.push('budget_period = ?', 'budget_period_mode = ?');
1742
+ values.push(resolved.period, 'auto');
1743
+ }
1744
+ else {
1745
+ updates.push('budget_period = ?', 'budget_period_mode = ?');
1746
+ values.push(budgetPeriod, 'manual');
1747
+ }
1748
+ }
1749
+ // Deduped; an empty result stores NULL, which the router reads as "unscoped".
1750
+ const scopeIds = modelScope == null ? [] : [...new Set(modelScope)];
1751
+ if (modelScope !== undefined) {
1752
+ updates.push('model_scope_json = ?');
1753
+ values.push(scopeIds.length > 0 ? JSON.stringify(scopeIds) : null);
1754
+ }
1755
+ values.push(id);
1756
+ const db = getDb();
1757
+ // Re-submitting the same key satisfies "something changed" but is a no-op;
1758
+ // it must not turn into an invalid UPDATE with an empty SET list.
1759
+ const result = updates.length === 0
1760
+ ? { changes: 1 }
1761
+ : db.transaction(() => db.prepare(`UPDATE api_keys SET ${updates.join(', ')} WHERE id = ?`).run(...values))();
1762
+ if (result.changes === 0) {
1763
+ res.status(404).json({ error: { message: 'Key not found' } });
1764
+ return;
1765
+ }
1766
+ if (changedKey !== undefined)
1767
+ clearCooldownsForKey(id);
1768
+ const response = { success: true };
1769
+ if (enabled !== undefined)
1770
+ response.enabled = enabled;
1771
+ if (label !== undefined)
1772
+ response.label = label;
1773
+ if (proxyUrl !== undefined)
1774
+ response.maskedProxyUrl = maskProxyUrl(proxyUrl);
1775
+ if (key !== undefined)
1776
+ response.maskedKey = maskKey(key);
1777
+ if (modelScope !== undefined)
1778
+ response.modelScope = scopeIds.length > 0 ? scopeIds : null;
1779
+ if (budgetPeriod !== undefined) {
1780
+ response.budgetPeriod = budgetPeriod;
1781
+ const after = syncAutoBudgetPeriod(id);
1782
+ response.resolvedBudgetPeriod = after.period;
1783
+ response.budgetPeriodSource = after.source;
1784
+ }
1785
+ res.json(response);
1786
+ });
1787
+ //# sourceMappingURL=keys.js.map