@srouterhq/server 0.0.0-stage → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/client/dist/assets/am-TQ7Jmdqe.js +1 -0
- package/client/dist/assets/ar-Bf5a7yfC.js +1 -0
- package/client/dist/assets/az-CrosE1xH.js +1 -0
- package/client/dist/assets/bg-BvWnmcUz.js +1 -0
- package/client/dist/assets/bn-ams5TsJp.js +1 -0
- package/client/dist/assets/cs-Dh67CaJ_.js +1 -0
- package/client/dist/assets/da-9WlT8Iq7.js +1 -0
- package/client/dist/assets/de-lWhJJzz7.js +1 -0
- package/client/dist/assets/el-Cu7_GhNq.js +1 -0
- package/client/dist/assets/es-BZuzsBcP.js +1 -0
- package/client/dist/assets/fa-Dwdn-jKS.js +1 -0
- package/client/dist/assets/fi-BFrFyOTy.js +1 -0
- package/client/dist/assets/fr-XhbtmPpj.js +1 -0
- package/client/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/client/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/client/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/client/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/client/dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
- package/client/dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
- package/client/dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
- package/client/dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
- package/client/dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
- package/client/dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
- package/client/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/client/dist/assets/gu-D0uhLUB8.js +1 -0
- package/client/dist/assets/ha-CVlm9pqp.js +1 -0
- package/client/dist/assets/he-CMIFeoWr.js +1 -0
- package/client/dist/assets/hi-ENTwhBpn.js +1 -0
- package/client/dist/assets/hr-fc03vMy_.js +1 -0
- package/client/dist/assets/hu-YBo2BIYt.js +1 -0
- package/client/dist/assets/id-BEWXYX69.js +1 -0
- package/client/dist/assets/ig-BKLZFKka.js +1 -0
- package/client/dist/assets/index-D9I4KtvR.js +145 -0
- package/client/dist/assets/index-txhEmCdb.css +2 -0
- package/client/dist/assets/it-Bzmei1Y2.js +1 -0
- package/client/dist/assets/ja-BfcpuqEl.js +1 -0
- package/client/dist/assets/ka-CnWjRgLo.js +1 -0
- package/client/dist/assets/km-BAjPR3Qj.js +1 -0
- package/client/dist/assets/kn-B15WKU5m.js +1 -0
- package/client/dist/assets/ko-C61fAB-w.js +1 -0
- package/client/dist/assets/lt-CYTr4_YW.js +1 -0
- package/client/dist/assets/ml-CtqxgyH3.js +1 -0
- package/client/dist/assets/mr-X5TMY7aR.js +1 -0
- package/client/dist/assets/ms-DSg8RqPS.js +1 -0
- package/client/dist/assets/my-D7RqR8uE.js +1 -0
- package/client/dist/assets/ne-DaflxkDj.js +1 -0
- package/client/dist/assets/nl-BOqqtsC8.js +1 -0
- package/client/dist/assets/no-BSHp_StB.js +1 -0
- package/client/dist/assets/or-BkLR9Vub.js +1 -0
- package/client/dist/assets/pa-CarJ2XgI.js +1 -0
- package/client/dist/assets/pl-C2n0V-0k.js +1 -0
- package/client/dist/assets/pt-BR-CqJdNhg2.js +1 -0
- package/client/dist/assets/pt-PT-C_HpMyKz.js +1 -0
- package/client/dist/assets/ro-DVRZqSAS.js +1 -0
- package/client/dist/assets/ru-DwAJG7VC.js +1 -0
- package/client/dist/assets/si-BxUezXm0.js +1 -0
- package/client/dist/assets/sk-eI1VcIfY.js +1 -0
- package/client/dist/assets/sr-CJF3auCr.js +1 -0
- package/client/dist/assets/srouter-logo-C6ZfjGIi.svg +14 -0
- package/client/dist/assets/sv-nqvwHAq_.js +1 -0
- package/client/dist/assets/sw-DWynE2pW.js +1 -0
- package/client/dist/assets/ta-BVM12knC.js +1 -0
- package/client/dist/assets/te-oyzEkF7_.js +1 -0
- package/client/dist/assets/th-COVeE0ZQ.js +1 -0
- package/client/dist/assets/tl-DsJANnQz.js +1 -0
- package/client/dist/assets/tr-B-Fl-urO.js +1 -0
- package/client/dist/assets/uk-C5qketAO.js +1 -0
- package/client/dist/assets/ur-QU9KugfV.js +1 -0
- package/client/dist/assets/uz-DL9cG_mY.js +1 -0
- package/client/dist/assets/vi-CQiVy3Pu.js +1 -0
- package/client/dist/assets/yo-DqazMdrZ.js +1 -0
- package/client/dist/assets/zh-CN-DnYL_Kio.js +1 -0
- package/client/dist/assets/zh-TW-5bdbosKj.js +1 -0
- package/client/dist/favicon.svg +14 -0
- package/client/dist/icons.svg +24 -0
- package/client/dist/index.html +37 -0
- package/dist/app.d.ts +4 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +372 -0
- package/dist/app.js.map +1 -0
- package/dist/db/index.d.ts +49 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +203 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/migrate/TEMPLATE.d.ts +4 -0
- package/dist/db/migrate/TEMPLATE.d.ts.map +1 -0
- package/dist/db/migrate/TEMPLATE.js +18 -0
- package/dist/db/migrate/TEMPLATE.js.map +1 -0
- package/dist/db/migrate/cli.d.ts +2 -0
- package/dist/db/migrate/cli.d.ts.map +1 -0
- package/dist/db/migrate/cli.js +177 -0
- package/dist/db/migrate/cli.js.map +1 -0
- package/dist/db/migrate/defaults.d.ts +52 -0
- package/dist/db/migrate/defaults.d.ts.map +1 -0
- package/dist/db/migrate/defaults.js +126 -0
- package/dist/db/migrate/defaults.js.map +1 -0
- package/dist/db/migrate/runner.d.ts +16 -0
- package/dist/db/migrate/runner.d.ts.map +1 -0
- package/dist/db/migrate/runner.js +178 -0
- package/dist/db/migrate/runner.js.map +1 -0
- package/dist/db/migrations/20260101_000000_legacy_baseline.d.ts +4 -0
- package/dist/db/migrations/20260101_000000_legacy_baseline.d.ts.map +1 -0
- package/dist/db/migrations/20260101_000000_legacy_baseline.js +2240 -0
- package/dist/db/migrations/20260101_000000_legacy_baseline.js.map +1 -0
- package/dist/db/migrations/20260627_000001_custom_provider_modalities.d.ts +4 -0
- package/dist/db/migrations/20260627_000001_custom_provider_modalities.d.ts.map +1 -0
- package/dist/db/migrations/20260627_000001_custom_provider_modalities.js +27 -0
- package/dist/db/migrations/20260627_000001_custom_provider_modalities.js.map +1 -0
- package/dist/db/migrations/20260627_000002_catalog_model_state.d.ts +4 -0
- package/dist/db/migrations/20260627_000002_catalog_model_state.d.ts.map +1 -0
- package/dist/db/migrations/20260627_000002_catalog_model_state.js +30 -0
- package/dist/db/migrations/20260627_000002_catalog_model_state.js.map +1 -0
- package/dist/db/migrations/20260628_120000_request_aggregates.d.ts +6 -0
- package/dist/db/migrations/20260628_120000_request_aggregates.d.ts.map +1 -0
- package/dist/db/migrations/20260628_120000_request_aggregates.js +104 -0
- package/dist/db/migrations/20260628_120000_request_aggregates.js.map +1 -0
- package/dist/db/migrations/20260630_000001_github_gpt41_context.d.ts +9 -0
- package/dist/db/migrations/20260630_000001_github_gpt41_context.d.ts.map +1 -0
- package/dist/db/migrations/20260630_000001_github_gpt41_context.js +22 -0
- package/dist/db/migrations/20260630_000001_github_gpt41_context.js.map +1 -0
- package/dist/db/migrations/20260706_000001_request_client_info.d.ts +4 -0
- package/dist/db/migrations/20260706_000001_request_client_info.d.ts.map +1 -0
- package/dist/db/migrations/20260706_000001_request_client_info.js +30 -0
- package/dist/db/migrations/20260706_000001_request_client_info.js.map +1 -0
- package/dist/db/migrations/20260706_000002_custom_model_tool_support.d.ts +17 -0
- package/dist/db/migrations/20260706_000002_custom_model_tool_support.d.ts.map +1 -0
- package/dist/db/migrations/20260706_000002_custom_model_tool_support.js +20 -0
- package/dist/db/migrations/20260706_000002_custom_model_tool_support.js.map +1 -0
- package/dist/db/migrations/20260714_000001_profile_chain_backfill.d.ts +12 -0
- package/dist/db/migrations/20260714_000001_profile_chain_backfill.d.ts.map +1 -0
- package/dist/db/migrations/20260714_000001_profile_chain_backfill.js +45 -0
- package/dist/db/migrations/20260714_000001_profile_chain_backfill.js.map +1 -0
- package/dist/db/migrations/20260720_000001_key_health_error.d.ts +5 -0
- package/dist/db/migrations/20260720_000001_key_health_error.d.ts.map +1 -0
- package/dist/db/migrations/20260720_000001_key_health_error.js +16 -0
- package/dist/db/migrations/20260720_000001_key_health_error.js.map +1 -0
- package/dist/db/migrations/20260726_000001_cooldown_probe_provenance.d.ts +4 -0
- package/dist/db/migrations/20260726_000001_cooldown_probe_provenance.d.ts.map +1 -0
- package/dist/db/migrations/20260726_000001_cooldown_probe_provenance.js +38 -0
- package/dist/db/migrations/20260726_000001_cooldown_probe_provenance.js.map +1 -0
- package/dist/db/migrations/20260726_000002_request_attempts.d.ts +4 -0
- package/dist/db/migrations/20260726_000002_request_attempts.d.ts.map +1 -0
- package/dist/db/migrations/20260726_000002_request_attempts.js +43 -0
- package/dist/db/migrations/20260726_000002_request_attempts.js.map +1 -0
- package/dist/db/migrations/20260726_000003_model_source_provenance.d.ts +4 -0
- package/dist/db/migrations/20260726_000003_model_source_provenance.d.ts.map +1 -0
- package/dist/db/migrations/20260726_000003_model_source_provenance.js +80 -0
- package/dist/db/migrations/20260726_000003_model_source_provenance.js.map +1 -0
- package/dist/db/migrations/20260726_000004_media_model_meta.d.ts +4 -0
- package/dist/db/migrations/20260726_000004_media_model_meta.d.ts.map +1 -0
- package/dist/db/migrations/20260726_000004_media_model_meta.js +34 -0
- package/dist/db/migrations/20260726_000004_media_model_meta.js.map +1 -0
- package/dist/db/migrations/20260726_000005_request_served_model.d.ts +4 -0
- package/dist/db/migrations/20260726_000005_request_served_model.d.ts.map +1 -0
- package/dist/db/migrations/20260726_000005_request_served_model.js +31 -0
- package/dist/db/migrations/20260726_000005_request_served_model.js.map +1 -0
- package/dist/db/migrations/20260726_000006_attempt_error_summary.d.ts +4 -0
- package/dist/db/migrations/20260726_000006_attempt_error_summary.d.ts.map +1 -0
- package/dist/db/migrations/20260726_000006_attempt_error_summary.js +29 -0
- package/dist/db/migrations/20260726_000006_attempt_error_summary.js.map +1 -0
- package/dist/db/migrations/20260727_000001_agent_compatibility.d.ts +4 -0
- package/dist/db/migrations/20260727_000001_agent_compatibility.d.ts.map +1 -0
- package/dist/db/migrations/20260727_000001_agent_compatibility.js +41 -0
- package/dist/db/migrations/20260727_000001_agent_compatibility.js.map +1 -0
- package/dist/db/migrations/20260728_000001_tombstone_provenance.d.ts +12 -0
- package/dist/db/migrations/20260728_000001_tombstone_provenance.d.ts.map +1 -0
- package/dist/db/migrations/20260728_000001_tombstone_provenance.js +37 -0
- package/dist/db/migrations/20260728_000001_tombstone_provenance.js.map +1 -0
- package/dist/db/migrations/20260729_000001_custom_model_endpoint_identity.d.ts +4 -0
- package/dist/db/migrations/20260729_000001_custom_model_endpoint_identity.d.ts.map +1 -0
- package/dist/db/migrations/20260729_000001_custom_model_endpoint_identity.js +145 -0
- package/dist/db/migrations/20260729_000001_custom_model_endpoint_identity.js.map +1 -0
- package/dist/db/migrations/20260802_000001_custom_endpoint_host_labels.d.ts +6 -0
- package/dist/db/migrations/20260802_000001_custom_endpoint_host_labels.d.ts.map +1 -0
- package/dist/db/migrations/20260802_000001_custom_endpoint_host_labels.js +52 -0
- package/dist/db/migrations/20260802_000001_custom_endpoint_host_labels.js.map +1 -0
- package/dist/db/migrations/20260805_000001_key_model_scope.d.ts +6 -0
- package/dist/db/migrations/20260805_000001_key_model_scope.d.ts.map +1 -0
- package/dist/db/migrations/20260805_000001_key_model_scope.js +17 -0
- package/dist/db/migrations/20260805_000001_key_model_scope.js.map +1 -0
- package/dist/db/migrations/20260805_000002_client_profiles.d.ts +4 -0
- package/dist/db/migrations/20260805_000002_client_profiles.d.ts.map +1 -0
- package/dist/db/migrations/20260805_000002_client_profiles.js +33 -0
- package/dist/db/migrations/20260805_000002_client_profiles.js.map +1 -0
- package/dist/db/migrations/20260810_000001_api_key_proxy.d.ts +4 -0
- package/dist/db/migrations/20260810_000001_api_key_proxy.d.ts.map +1 -0
- package/dist/db/migrations/20260810_000001_api_key_proxy.js +33 -0
- package/dist/db/migrations/20260810_000001_api_key_proxy.js.map +1 -0
- package/dist/db/migrations/20260819_000001_custom_model_tombstones.d.ts +4 -0
- package/dist/db/migrations/20260819_000001_custom_model_tombstones.d.ts.map +1 -0
- package/dist/db/migrations/20260819_000001_custom_model_tombstones.js +19 -0
- package/dist/db/migrations/20260819_000001_custom_model_tombstones.js.map +1 -0
- package/dist/db/migrations/20260820_000001_playground_conversations.d.ts +4 -0
- package/dist/db/migrations/20260820_000001_playground_conversations.d.ts.map +1 -0
- package/dist/db/migrations/20260820_000001_playground_conversations.js +47 -0
- package/dist/db/migrations/20260820_000001_playground_conversations.js.map +1 -0
- package/dist/db/migrations/20260823_000001_server_logs.d.ts +4 -0
- package/dist/db/migrations/20260823_000001_server_logs.d.ts.map +1 -0
- package/dist/db/migrations/20260823_000001_server_logs.js +53 -0
- package/dist/db/migrations/20260823_000001_server_logs.js.map +1 -0
- package/dist/db/migrations/20260823_000002_backups_table.d.ts +8 -0
- package/dist/db/migrations/20260823_000002_backups_table.d.ts.map +1 -0
- package/dist/db/migrations/20260823_000002_backups_table.js +22 -0
- package/dist/db/migrations/20260823_000002_backups_table.js.map +1 -0
- package/dist/db/migrations/20260823_000003_attempt_key_label.d.ts +19 -0
- package/dist/db/migrations/20260823_000003_attempt_key_label.d.ts.map +1 -0
- package/dist/db/migrations/20260823_000003_attempt_key_label.js +28 -0
- package/dist/db/migrations/20260823_000003_attempt_key_label.js.map +1 -0
- package/dist/db/migrations/20260823_000004_profile_auto_include.d.ts +14 -0
- package/dist/db/migrations/20260823_000004_profile_auto_include.d.ts.map +1 -0
- package/dist/db/migrations/20260823_000004_profile_auto_include.js +25 -0
- package/dist/db/migrations/20260823_000004_profile_auto_include.js.map +1 -0
- package/dist/db/migrations/20260901_000001_idempotency_claims.d.ts +4 -0
- package/dist/db/migrations/20260901_000001_idempotency_claims.d.ts.map +1 -0
- package/dist/db/migrations/20260901_000001_idempotency_claims.js +46 -0
- package/dist/db/migrations/20260901_000001_idempotency_claims.js.map +1 -0
- package/dist/db/migrations/20260901_000002_quota_observation_lookup.d.ts +4 -0
- package/dist/db/migrations/20260901_000002_quota_observation_lookup.d.ts.map +1 -0
- package/dist/db/migrations/20260901_000002_quota_observation_lookup.js +37 -0
- package/dist/db/migrations/20260901_000002_quota_observation_lookup.js.map +1 -0
- package/dist/db/migrations/20260901_000003_request_caller.d.ts +4 -0
- package/dist/db/migrations/20260901_000003_request_caller.d.ts.map +1 -0
- package/dist/db/migrations/20260901_000003_request_caller.js +27 -0
- package/dist/db/migrations/20260901_000003_request_caller.js.map +1 -0
- package/dist/db/migrations/20260902_000001_analytics_latency_percentile_index.d.ts +4 -0
- package/dist/db/migrations/20260902_000001_analytics_latency_percentile_index.d.ts.map +1 -0
- package/dist/db/migrations/20260902_000001_analytics_latency_percentile_index.js +35 -0
- package/dist/db/migrations/20260902_000001_analytics_latency_percentile_index.js.map +1 -0
- package/dist/db/migrations/20260903_000001_mcp_enabled_default.d.ts +4 -0
- package/dist/db/migrations/20260903_000001_mcp_enabled_default.d.ts.map +1 -0
- package/dist/db/migrations/20260903_000001_mcp_enabled_default.js +37 -0
- package/dist/db/migrations/20260903_000001_mcp_enabled_default.js.map +1 -0
- package/dist/db/migrations/20260903_000002_response_cache.d.ts +4 -0
- package/dist/db/migrations/20260903_000002_response_cache.d.ts.map +1 -0
- package/dist/db/migrations/20260903_000002_response_cache.js +53 -0
- package/dist/db/migrations/20260903_000002_response_cache.js.map +1 -0
- package/dist/db/migrations/20260904_000001_key_monthly_budget.d.ts +13 -0
- package/dist/db/migrations/20260904_000001_key_monthly_budget.d.ts.map +1 -0
- package/dist/db/migrations/20260904_000001_key_monthly_budget.js +35 -0
- package/dist/db/migrations/20260904_000001_key_monthly_budget.js.map +1 -0
- package/dist/db/migrations/20260913_000001_request_model_attribution.d.ts +4 -0
- package/dist/db/migrations/20260913_000001_request_model_attribution.d.ts.map +1 -0
- package/dist/db/migrations/20260913_000001_request_model_attribution.js +68 -0
- package/dist/db/migrations/20260913_000001_request_model_attribution.js.map +1 -0
- package/dist/db/migrations/20260914_000001_key_monthly_usage.d.ts +5 -0
- package/dist/db/migrations/20260914_000001_key_monthly_usage.d.ts.map +1 -0
- package/dist/db/migrations/20260914_000001_key_monthly_usage.js +37 -0
- package/dist/db/migrations/20260914_000001_key_monthly_usage.js.map +1 -0
- package/dist/db/migrations/20260915_000001_quota_snapshot_freshness.d.ts +4 -0
- package/dist/db/migrations/20260915_000001_quota_snapshot_freshness.d.ts.map +1 -0
- package/dist/db/migrations/20260915_000001_quota_snapshot_freshness.js +28 -0
- package/dist/db/migrations/20260915_000001_quota_snapshot_freshness.js.map +1 -0
- package/dist/db/migrations/20261004_000001_unified_key_sr_prefix.d.ts +4 -0
- package/dist/db/migrations/20261004_000001_unified_key_sr_prefix.d.ts.map +1 -0
- package/dist/db/migrations/20261004_000001_unified_key_sr_prefix.js +25 -0
- package/dist/db/migrations/20261004_000001_unified_key_sr_prefix.js.map +1 -0
- package/dist/db/migrations/20261005_000001_key_budget_period.d.ts +10 -0
- package/dist/db/migrations/20261005_000001_key_budget_period.d.ts.map +1 -0
- package/dist/db/migrations/20261005_000001_key_budget_period.js +78 -0
- package/dist/db/migrations/20261005_000001_key_budget_period.js.map +1 -0
- package/dist/db/migrations/20261005_000002_key_budget_period_auto.d.ts +21 -0
- package/dist/db/migrations/20261005_000002_key_budget_period_auto.d.ts.map +1 -0
- package/dist/db/migrations/20261005_000002_key_budget_period_auto.js +41 -0
- package/dist/db/migrations/20261005_000002_key_budget_period_auto.js.map +1 -0
- package/dist/db/model-pricing.d.ts +39 -0
- package/dist/db/model-pricing.d.ts.map +1 -0
- package/dist/db/model-pricing.js +274 -0
- package/dist/db/model-pricing.js.map +1 -0
- package/dist/db/node-sqlite.d.ts +9 -0
- package/dist/db/node-sqlite.d.ts.map +1 -0
- package/dist/db/node-sqlite.js +87 -0
- package/dist/db/node-sqlite.js.map +1 -0
- package/dist/db/types.d.ts +22 -0
- package/dist/db/types.d.ts.map +1 -0
- package/dist/db/types.js +2 -0
- package/dist/db/types.js.map +1 -0
- package/dist/docs/docs-page.d.ts +2 -0
- package/dist/docs/docs-page.d.ts.map +1 -0
- package/dist/docs/docs-page.js +319 -0
- package/dist/docs/docs-page.js.map +1 -0
- package/dist/docs/openapi.d.ts +1896 -0
- package/dist/docs/openapi.d.ts.map +1 -0
- package/dist/docs/openapi.js +1096 -0
- package/dist/docs/openapi.js.map +1 -0
- package/dist/env.d.ts +2 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +9 -0
- package/dist/env.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +144 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/anthropic-documents.d.ts +41 -0
- package/dist/lib/anthropic-documents.d.ts.map +1 -0
- package/dist/lib/anthropic-documents.js +132 -0
- package/dist/lib/anthropic-documents.js.map +1 -0
- package/dist/lib/app-version.d.ts +5 -0
- package/dist/lib/app-version.d.ts.map +1 -0
- package/dist/lib/app-version.js +61 -0
- package/dist/lib/app-version.js.map +1 -0
- package/dist/lib/attempt-trace.d.ts +23 -0
- package/dist/lib/attempt-trace.d.ts.map +1 -0
- package/dist/lib/attempt-trace.js +32 -0
- package/dist/lib/attempt-trace.js.map +1 -0
- package/dist/lib/budget.d.ts +6 -0
- package/dist/lib/budget.d.ts.map +1 -0
- package/dist/lib/budget.js +40 -0
- package/dist/lib/budget.js.map +1 -0
- package/dist/lib/client-classifier.d.ts +10 -0
- package/dist/lib/client-classifier.d.ts.map +1 -0
- package/dist/lib/client-classifier.js +126 -0
- package/dist/lib/client-classifier.js.map +1 -0
- package/dist/lib/client-context.d.ts +10 -0
- package/dist/lib/client-context.d.ts.map +1 -0
- package/dist/lib/client-context.js +39 -0
- package/dist/lib/client-context.js.map +1 -0
- package/dist/lib/config.d.ts +33 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +94 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/content.d.ts +33 -0
- package/dist/lib/content.d.ts.map +1 -0
- package/dist/lib/content.js +201 -0
- package/dist/lib/content.js.map +1 -0
- package/dist/lib/credential.d.ts +11 -0
- package/dist/lib/credential.d.ts.map +1 -0
- package/dist/lib/credential.js +18 -0
- package/dist/lib/credential.js.map +1 -0
- package/dist/lib/crypto.d.ts +31 -0
- package/dist/lib/crypto.d.ts.map +1 -0
- package/dist/lib/crypto.js +206 -0
- package/dist/lib/crypto.js.map +1 -0
- package/dist/lib/custom-provider-cleanup.d.ts +13 -0
- package/dist/lib/custom-provider-cleanup.d.ts.map +1 -0
- package/dist/lib/custom-provider-cleanup.js +43 -0
- package/dist/lib/custom-provider-cleanup.js.map +1 -0
- package/dist/lib/db-backup.d.ts +31 -0
- package/dist/lib/db-backup.d.ts.map +1 -0
- package/dist/lib/db-backup.js +255 -0
- package/dist/lib/db-backup.js.map +1 -0
- package/dist/lib/endpoint-scope.d.ts +42 -0
- package/dist/lib/endpoint-scope.d.ts.map +1 -0
- package/dist/lib/endpoint-scope.js +95 -0
- package/dist/lib/endpoint-scope.js.map +1 -0
- package/dist/lib/env-drift.d.ts +17 -0
- package/dist/lib/env-drift.d.ts.map +1 -0
- package/dist/lib/env-drift.js +108 -0
- package/dist/lib/env-drift.js.map +1 -0
- package/dist/lib/error-classify.d.ts +40 -0
- package/dist/lib/error-classify.d.ts.map +1 -0
- package/dist/lib/error-classify.js +687 -0
- package/dist/lib/error-classify.js.map +1 -0
- package/dist/lib/error-redaction.d.ts +8 -0
- package/dist/lib/error-redaction.d.ts.map +1 -0
- package/dist/lib/error-redaction.js +45 -0
- package/dist/lib/error-redaction.js.map +1 -0
- package/dist/lib/fallback-loop.d.ts +306 -0
- package/dist/lib/fallback-loop.d.ts.map +1 -0
- package/dist/lib/fallback-loop.js +1336 -0
- package/dist/lib/fallback-loop.js.map +1 -0
- package/dist/lib/file-permissions.d.ts +98 -0
- package/dist/lib/file-permissions.d.ts.map +1 -0
- package/dist/lib/file-permissions.js +159 -0
- package/dist/lib/file-permissions.js.map +1 -0
- package/dist/lib/gemini-wire.d.ts +76 -0
- package/dist/lib/gemini-wire.d.ts.map +1 -0
- package/dist/lib/gemini-wire.js +392 -0
- package/dist/lib/gemini-wire.js.map +1 -0
- package/dist/lib/guardrails.d.ts +37 -0
- package/dist/lib/guardrails.d.ts.map +1 -0
- package/dist/lib/guardrails.js +106 -0
- package/dist/lib/guardrails.js.map +1 -0
- package/dist/lib/header-value.d.ts +8 -0
- package/dist/lib/header-value.d.ts.map +1 -0
- package/dist/lib/header-value.js +55 -0
- package/dist/lib/header-value.js.map +1 -0
- package/dist/lib/image-normalize.d.ts +21 -0
- package/dist/lib/image-normalize.d.ts.map +1 -0
- package/dist/lib/image-normalize.js +218 -0
- package/dist/lib/image-normalize.js.map +1 -0
- package/dist/lib/inbound-chat.d.ts +48 -0
- package/dist/lib/inbound-chat.d.ts.map +1 -0
- package/dist/lib/inbound-chat.js +406 -0
- package/dist/lib/inbound-chat.js.map +1 -0
- package/dist/lib/key-parser.d.ts +79 -0
- package/dist/lib/key-parser.d.ts.map +1 -0
- package/dist/lib/key-parser.js +701 -0
- package/dist/lib/key-parser.js.map +1 -0
- package/dist/lib/key-proxy.d.ts +49 -0
- package/dist/lib/key-proxy.d.ts.map +1 -0
- package/dist/lib/key-proxy.js +92 -0
- package/dist/lib/key-proxy.js.map +1 -0
- package/dist/lib/log-redaction.d.ts +28 -0
- package/dist/lib/log-redaction.d.ts.map +1 -0
- package/dist/lib/log-redaction.js +166 -0
- package/dist/lib/log-redaction.js.map +1 -0
- package/dist/lib/model-scope.d.ts +9 -0
- package/dist/lib/model-scope.d.ts.map +1 -0
- package/dist/lib/model-scope.js +29 -0
- package/dist/lib/model-scope.js.map +1 -0
- package/dist/lib/one-time-code.d.ts +15 -0
- package/dist/lib/one-time-code.d.ts.map +1 -0
- package/dist/lib/one-time-code.js +41 -0
- package/dist/lib/one-time-code.js.map +1 -0
- package/dist/lib/output-cap.d.ts +24 -0
- package/dist/lib/output-cap.d.ts.map +1 -0
- package/dist/lib/output-cap.js +80 -0
- package/dist/lib/output-cap.js.map +1 -0
- package/dist/lib/password.d.ts +3 -0
- package/dist/lib/password.d.ts.map +1 -0
- package/dist/lib/password.js +27 -0
- package/dist/lib/password.js.map +1 -0
- package/dist/lib/process-safety-net.d.ts +32 -0
- package/dist/lib/process-safety-net.d.ts.map +1 -0
- package/dist/lib/process-safety-net.js +123 -0
- package/dist/lib/process-safety-net.js.map +1 -0
- package/dist/lib/provider-identity.d.ts +37 -0
- package/dist/lib/provider-identity.d.ts.map +1 -0
- package/dist/lib/provider-identity.js +110 -0
- package/dist/lib/provider-identity.js.map +1 -0
- package/dist/lib/provider-size-parser.d.ts +6 -0
- package/dist/lib/provider-size-parser.d.ts.map +1 -0
- package/dist/lib/provider-size-parser.js +72 -0
- package/dist/lib/provider-size-parser.js.map +1 -0
- package/dist/lib/provider-timeout.d.ts +17 -0
- package/dist/lib/provider-timeout.d.ts.map +1 -0
- package/dist/lib/provider-timeout.js +70 -0
- package/dist/lib/provider-timeout.js.map +1 -0
- package/dist/lib/proxy.d.ts +180 -0
- package/dist/lib/proxy.d.ts.map +1 -0
- package/dist/lib/proxy.js +1002 -0
- package/dist/lib/proxy.js.map +1 -0
- package/dist/lib/request-log.d.ts +4 -0
- package/dist/lib/request-log.d.ts.map +1 -0
- package/dist/lib/request-log.js +161 -0
- package/dist/lib/request-log.js.map +1 -0
- package/dist/lib/reset-code.d.ts +5 -0
- package/dist/lib/reset-code.d.ts.map +1 -0
- package/dist/lib/reset-code.js +39 -0
- package/dist/lib/reset-code.js.map +1 -0
- package/dist/lib/retry-hint.d.ts +14 -0
- package/dist/lib/retry-hint.d.ts.map +1 -0
- package/dist/lib/retry-hint.js +36 -0
- package/dist/lib/retry-hint.js.map +1 -0
- package/dist/lib/route-live.d.ts +29 -0
- package/dist/lib/route-live.d.ts.map +1 -0
- package/dist/lib/route-live.js +97 -0
- package/dist/lib/route-live.js.map +1 -0
- package/dist/lib/sampling-params.d.ts +183 -0
- package/dist/lib/sampling-params.d.ts.map +1 -0
- package/dist/lib/sampling-params.js +386 -0
- package/dist/lib/sampling-params.js.map +1 -0
- package/dist/lib/scheduler.d.ts +13 -0
- package/dist/lib/scheduler.d.ts.map +1 -0
- package/dist/lib/scheduler.js +11 -0
- package/dist/lib/scheduler.js.map +1 -0
- package/dist/lib/served-model.d.ts +16 -0
- package/dist/lib/served-model.d.ts.map +1 -0
- package/dist/lib/served-model.js +0 -0
- package/dist/lib/served-model.js.map +1 -0
- package/dist/lib/server-logs.d.ts +132 -0
- package/dist/lib/server-logs.d.ts.map +1 -0
- package/dist/lib/server-logs.js +473 -0
- package/dist/lib/server-logs.js.map +1 -0
- package/dist/lib/setup-code.d.ts +5 -0
- package/dist/lib/setup-code.d.ts.map +1 -0
- package/dist/lib/setup-code.js +35 -0
- package/dist/lib/setup-code.js.map +1 -0
- package/dist/lib/structured-output.d.ts +9 -0
- package/dist/lib/structured-output.d.ts.map +1 -0
- package/dist/lib/structured-output.js +72 -0
- package/dist/lib/structured-output.js.map +1 -0
- package/dist/lib/system-prompt.d.ts +30 -0
- package/dist/lib/system-prompt.d.ts.map +1 -0
- package/dist/lib/system-prompt.js +66 -0
- package/dist/lib/system-prompt.js.map +1 -0
- package/dist/lib/task-type.d.ts +18 -0
- package/dist/lib/task-type.d.ts.map +1 -0
- package/dist/lib/task-type.js +92 -0
- package/dist/lib/task-type.js.map +1 -0
- package/dist/lib/think-tags.d.ts +37 -0
- package/dist/lib/think-tags.d.ts.map +1 -0
- package/dist/lib/think-tags.js +203 -0
- package/dist/lib/think-tags.js.map +1 -0
- package/dist/lib/tool-args.d.ts +36 -0
- package/dist/lib/tool-args.d.ts.map +1 -0
- package/dist/lib/tool-args.js +190 -0
- package/dist/lib/tool-args.js.map +1 -0
- package/dist/lib/tool-call-rescue.d.ts +62 -0
- package/dist/lib/tool-call-rescue.d.ts.map +1 -0
- package/dist/lib/tool-call-rescue.js +258 -0
- package/dist/lib/tool-call-rescue.js.map +1 -0
- package/dist/lib/tool-capability.d.ts +13 -0
- package/dist/lib/tool-capability.d.ts.map +1 -0
- package/dist/lib/tool-capability.js +70 -0
- package/dist/lib/tool-capability.js.map +1 -0
- package/dist/lib/tool-validate.d.ts +44 -0
- package/dist/lib/tool-validate.d.ts.map +1 -0
- package/dist/lib/tool-validate.js +165 -0
- package/dist/lib/tool-validate.js.map +1 -0
- package/dist/lib/ttfb-budget.d.ts +13 -0
- package/dist/lib/ttfb-budget.d.ts.map +1 -0
- package/dist/lib/ttfb-budget.js +153 -0
- package/dist/lib/ttfb-budget.js.map +1 -0
- package/dist/lib/url-guard.d.ts +47 -0
- package/dist/lib/url-guard.d.ts.map +1 -0
- package/dist/lib/url-guard.js +229 -0
- package/dist/lib/url-guard.js.map +1 -0
- package/dist/lib/wake-detect.d.ts +12 -0
- package/dist/lib/wake-detect.d.ts.map +1 -0
- package/dist/lib/wake-detect.js +99 -0
- package/dist/lib/wake-detect.js.map +1 -0
- package/dist/middleware/errorHandler.d.ts +3 -0
- package/dist/middleware/errorHandler.d.ts.map +1 -0
- package/dist/middleware/errorHandler.js +45 -0
- package/dist/middleware/errorHandler.js.map +1 -0
- package/dist/middleware/rateLimit.d.ts +4 -0
- package/dist/middleware/rateLimit.d.ts.map +1 -0
- package/dist/middleware/rateLimit.js +125 -0
- package/dist/middleware/rateLimit.js.map +1 -0
- package/dist/middleware/requireAuth.d.ts +3 -0
- package/dist/middleware/requireAuth.d.ts.map +1 -0
- package/dist/middleware/requireAuth.js +17 -0
- package/dist/middleware/requireAuth.js.map +1 -0
- package/dist/providers/aclide.d.ts +18 -0
- package/dist/providers/aclide.d.ts.map +1 -0
- package/dist/providers/aclide.js +174 -0
- package/dist/providers/aclide.js.map +1 -0
- package/dist/providers/aihorde.d.ts +42 -0
- package/dist/providers/aihorde.d.ts.map +1 -0
- package/dist/providers/aihorde.js +192 -0
- package/dist/providers/aihorde.js.map +1 -0
- package/dist/providers/airforce.d.ts +11 -0
- package/dist/providers/airforce.d.ts.map +1 -0
- package/dist/providers/airforce.js +32 -0
- package/dist/providers/airforce.js.map +1 -0
- package/dist/providers/base.d.ts +177 -0
- package/dist/providers/base.d.ts.map +1 -0
- package/dist/providers/base.js +354 -0
- package/dist/providers/base.js.map +1 -0
- package/dist/providers/blaze.d.ts +11 -0
- package/dist/providers/blaze.d.ts.map +1 -0
- package/dist/providers/blaze.js +33 -0
- package/dist/providers/blaze.js.map +1 -0
- package/dist/providers/blockrun.d.ts +14 -0
- package/dist/providers/blockrun.d.ts.map +1 -0
- package/dist/providers/blockrun.js +36 -0
- package/dist/providers/blockrun.js.map +1 -0
- package/dist/providers/clod.d.ts +11 -0
- package/dist/providers/clod.d.ts.map +1 -0
- package/dist/providers/clod.js +39 -0
- package/dist/providers/clod.js.map +1 -0
- package/dist/providers/cloudflare.d.ts +15 -0
- package/dist/providers/cloudflare.d.ts.map +1 -0
- package/dist/providers/cloudflare.js +175 -0
- package/dist/providers/cloudflare.js.map +1 -0
- package/dist/providers/cohere.d.ts +11 -0
- package/dist/providers/cohere.d.ts.map +1 -0
- package/dist/providers/cohere.js +117 -0
- package/dist/providers/cohere.js.map +1 -0
- package/dist/providers/dreamprompting.d.ts +11 -0
- package/dist/providers/dreamprompting.d.ts.map +1 -0
- package/dist/providers/dreamprompting.js +41 -0
- package/dist/providers/dreamprompting.js.map +1 -0
- package/dist/providers/electronhub.d.ts +10 -0
- package/dist/providers/electronhub.d.ts.map +1 -0
- package/dist/providers/electronhub.js +56 -0
- package/dist/providers/electronhub.js.map +1 -0
- package/dist/providers/experiential.d.ts +10 -0
- package/dist/providers/experiential.d.ts.map +1 -0
- package/dist/providers/experiential.js +33 -0
- package/dist/providers/experiential.js.map +1 -0
- package/dist/providers/gizmo.d.ts +15 -0
- package/dist/providers/gizmo.d.ts.map +1 -0
- package/dist/providers/gizmo.js +54 -0
- package/dist/providers/gizmo.js.map +1 -0
- package/dist/providers/google.d.ts +30 -0
- package/dist/providers/google.d.ts.map +1 -0
- package/dist/providers/google.js +766 -0
- package/dist/providers/google.js.map +1 -0
- package/dist/providers/index.d.ts +13 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +561 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/llmtr.d.ts +15 -0
- package/dist/providers/llmtr.d.ts.map +1 -0
- package/dist/providers/llmtr.js +51 -0
- package/dist/providers/llmtr.js.map +1 -0
- package/dist/providers/logfare.d.ts +11 -0
- package/dist/providers/logfare.d.ts.map +1 -0
- package/dist/providers/logfare.js +33 -0
- package/dist/providers/logfare.js.map +1 -0
- package/dist/providers/lucidity.d.ts +11 -0
- package/dist/providers/lucidity.d.ts.map +1 -0
- package/dist/providers/lucidity.js +37 -0
- package/dist/providers/lucidity.js.map +1 -0
- package/dist/providers/modelscope.d.ts +50 -0
- package/dist/providers/modelscope.d.ts.map +1 -0
- package/dist/providers/modelscope.js +143 -0
- package/dist/providers/modelscope.js.map +1 -0
- package/dist/providers/moondream.d.ts +17 -0
- package/dist/providers/moondream.d.ts.map +1 -0
- package/dist/providers/moondream.js +140 -0
- package/dist/providers/moondream.js.map +1 -0
- package/dist/providers/openai-compat.d.ts +140 -0
- package/dist/providers/openai-compat.d.ts.map +1 -0
- package/dist/providers/openai-compat.js +537 -0
- package/dist/providers/openai-compat.js.map +1 -0
- package/dist/providers/opencode-free.d.ts +24 -0
- package/dist/providers/opencode-free.d.ts.map +1 -0
- package/dist/providers/opencode-free.js +262 -0
- package/dist/providers/opencode-free.js.map +1 -0
- package/dist/providers/pollinations.d.ts +32 -0
- package/dist/providers/pollinations.d.ts.map +1 -0
- package/dist/providers/pollinations.js +65 -0
- package/dist/providers/pollinations.js.map +1 -0
- package/dist/providers/router9.d.ts +16 -0
- package/dist/providers/router9.d.ts.map +1 -0
- package/dist/providers/router9.js +42 -0
- package/dist/providers/router9.js.map +1 -0
- package/dist/providers/sail.d.ts +44 -0
- package/dist/providers/sail.d.ts.map +1 -0
- package/dist/providers/sail.js +302 -0
- package/dist/providers/sail.js.map +1 -0
- package/dist/providers/septor.d.ts +10 -0
- package/dist/providers/septor.d.ts.map +1 -0
- package/dist/providers/septor.js +36 -0
- package/dist/providers/septor.js.map +1 -0
- package/dist/providers/speechify.d.ts +14 -0
- package/dist/providers/speechify.d.ts.map +1 -0
- package/dist/providers/speechify.js +28 -0
- package/dist/providers/speechify.js.map +1 -0
- package/dist/providers/speka.d.ts +12 -0
- package/dist/providers/speka.d.ts.map +1 -0
- package/dist/providers/speka.js +37 -0
- package/dist/providers/speka.js.map +1 -0
- package/dist/providers/waterfall.d.ts +11 -0
- package/dist/providers/waterfall.d.ts.map +1 -0
- package/dist/providers/waterfall.js +33 -0
- package/dist/providers/waterfall.js.map +1 -0
- package/dist/providers/xkiro.d.ts +71 -0
- package/dist/providers/xkiro.d.ts.map +1 -0
- package/dist/providers/xkiro.js +119 -0
- package/dist/providers/xkiro.js.map +1 -0
- package/dist/providers/zhipu.d.ts +43 -0
- package/dist/providers/zhipu.d.ts.map +1 -0
- package/dist/providers/zhipu.js +95 -0
- package/dist/providers/zhipu.js.map +1 -0
- package/dist/routes/analytics.d.ts +2 -0
- package/dist/routes/analytics.d.ts.map +1 -0
- package/dist/routes/analytics.js +738 -0
- package/dist/routes/analytics.js.map +1 -0
- package/dist/routes/anthropic.d.ts +7 -0
- package/dist/routes/anthropic.d.ts.map +1 -0
- package/dist/routes/anthropic.js +1076 -0
- package/dist/routes/anthropic.js.map +1 -0
- package/dist/routes/auth.d.ts +2 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +310 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/backups.d.ts +2 -0
- package/dist/routes/backups.d.ts.map +1 -0
- package/dist/routes/backups.js +127 -0
- package/dist/routes/backups.js.map +1 -0
- package/dist/routes/cache.d.ts +2 -0
- package/dist/routes/cache.d.ts.map +1 -0
- package/dist/routes/cache.js +40 -0
- package/dist/routes/cache.js.map +1 -0
- package/dist/routes/client-profiles.d.ts +2 -0
- package/dist/routes/client-profiles.d.ts.map +1 -0
- package/dist/routes/client-profiles.js +128 -0
- package/dist/routes/client-profiles.js.map +1 -0
- package/dist/routes/compression.d.ts +2 -0
- package/dist/routes/compression.d.ts.map +1 -0
- package/dist/routes/compression.js +82 -0
- package/dist/routes/compression.js.map +1 -0
- package/dist/routes/conversations.d.ts +3 -0
- package/dist/routes/conversations.d.ts.map +1 -0
- package/dist/routes/conversations.js +215 -0
- package/dist/routes/conversations.js.map +1 -0
- package/dist/routes/docs.d.ts +2 -0
- package/dist/routes/docs.d.ts.map +1 -0
- package/dist/routes/docs.js +21 -0
- package/dist/routes/docs.js.map +1 -0
- package/dist/routes/embeddings.d.ts +2 -0
- package/dist/routes/embeddings.d.ts.map +1 -0
- package/dist/routes/embeddings.js +255 -0
- package/dist/routes/embeddings.js.map +1 -0
- package/dist/routes/fallback.d.ts +2 -0
- package/dist/routes/fallback.d.ts.map +1 -0
- package/dist/routes/fallback.js +631 -0
- package/dist/routes/fallback.js.map +1 -0
- package/dist/routes/free-tier.d.ts +22 -0
- package/dist/routes/free-tier.d.ts.map +1 -0
- package/dist/routes/free-tier.js +175 -0
- package/dist/routes/free-tier.js.map +1 -0
- package/dist/routes/gemini.d.ts +2 -0
- package/dist/routes/gemini.d.ts.map +1 -0
- package/dist/routes/gemini.js +218 -0
- package/dist/routes/gemini.js.map +1 -0
- package/dist/routes/health.d.ts +2 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +72 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/keys.d.ts +17 -0
- package/dist/routes/keys.d.ts.map +1 -0
- package/dist/routes/keys.js +1787 -0
- package/dist/routes/keys.js.map +1 -0
- package/dist/routes/logs.d.ts +2 -0
- package/dist/routes/logs.d.ts.map +1 -0
- package/dist/routes/logs.js +88 -0
- package/dist/routes/logs.js.map +1 -0
- package/dist/routes/mcp.d.ts +4 -0
- package/dist/routes/mcp.d.ts.map +1 -0
- package/dist/routes/mcp.js +563 -0
- package/dist/routes/mcp.js.map +1 -0
- package/dist/routes/media.d.ts +2 -0
- package/dist/routes/media.d.ts.map +1 -0
- package/dist/routes/media.js +180 -0
- package/dist/routes/media.js.map +1 -0
- package/dist/routes/models.d.ts +2 -0
- package/dist/routes/models.d.ts.map +1 -0
- package/dist/routes/models.js +439 -0
- package/dist/routes/models.js.map +1 -0
- package/dist/routes/ollama.d.ts +7 -0
- package/dist/routes/ollama.d.ts.map +1 -0
- package/dist/routes/ollama.js +595 -0
- package/dist/routes/ollama.js.map +1 -0
- package/dist/routes/profiles.d.ts +3 -0
- package/dist/routes/profiles.d.ts.map +1 -0
- package/dist/routes/profiles.js +431 -0
- package/dist/routes/profiles.js.map +1 -0
- package/dist/routes/proxy.d.ts +32 -0
- package/dist/routes/proxy.d.ts.map +1 -0
- package/dist/routes/proxy.js +2793 -0
- package/dist/routes/proxy.js.map +1 -0
- package/dist/routes/responses.d.ts +1161 -0
- package/dist/routes/responses.d.ts.map +1 -0
- package/dist/routes/responses.js +1521 -0
- package/dist/routes/responses.js.map +1 -0
- package/dist/routes/settings.d.ts +2 -0
- package/dist/routes/settings.d.ts.map +1 -0
- package/dist/routes/settings.js +549 -0
- package/dist/routes/settings.js.map +1 -0
- package/dist/routes/status.d.ts +3 -0
- package/dist/routes/status.d.ts.map +1 -0
- package/dist/routes/status.js +214 -0
- package/dist/routes/status.js.map +1 -0
- package/dist/routes/update.d.ts +31 -0
- package/dist/routes/update.d.ts.map +1 -0
- package/dist/routes/update.js +536 -0
- package/dist/routes/update.js.map +1 -0
- package/dist/routes/url-tokens.d.ts +2 -0
- package/dist/routes/url-tokens.d.ts.map +1 -0
- package/dist/routes/url-tokens.js +31 -0
- package/dist/routes/url-tokens.js.map +1 -0
- package/dist/scripts/export-catalog.d.ts +2 -0
- package/dist/scripts/export-catalog.d.ts.map +1 -0
- package/dist/scripts/export-catalog.js +114 -0
- package/dist/scripts/export-catalog.js.map +1 -0
- package/dist/scripts/rotate-encryption-key.d.ts +81 -0
- package/dist/scripts/rotate-encryption-key.d.ts.map +1 -0
- package/dist/scripts/rotate-encryption-key.js +232 -0
- package/dist/scripts/rotate-encryption-key.js.map +1 -0
- package/dist/scripts/routing-sim.d.ts +2 -0
- package/dist/scripts/routing-sim.d.ts.map +1 -0
- package/dist/scripts/routing-sim.js +130 -0
- package/dist/scripts/routing-sim.js.map +1 -0
- package/dist/scripts/test-all-models.d.ts +2 -0
- package/dist/scripts/test-all-models.d.ts.map +1 -0
- package/dist/scripts/test-all-models.js +56 -0
- package/dist/scripts/test-all-models.js.map +1 -0
- package/dist/services/anthropic-map.d.ts +39 -0
- package/dist/services/anthropic-map.d.ts.map +1 -0
- package/dist/services/anthropic-map.js +138 -0
- package/dist/services/anthropic-map.js.map +1 -0
- package/dist/services/auth.d.ts +30 -0
- package/dist/services/auth.d.ts.map +1 -0
- package/dist/services/auth.js +124 -0
- package/dist/services/auth.js.map +1 -0
- package/dist/services/auto-discover.d.ts +77 -0
- package/dist/services/auto-discover.d.ts.map +1 -0
- package/dist/services/auto-discover.js +1047 -0
- package/dist/services/auto-discover.js.map +1 -0
- package/dist/services/backups.d.ts +67 -0
- package/dist/services/backups.d.ts.map +1 -0
- package/dist/services/backups.js +444 -0
- package/dist/services/backups.js.map +1 -0
- package/dist/services/builtin-model-discovery.d.ts +107 -0
- package/dist/services/builtin-model-discovery.d.ts.map +1 -0
- package/dist/services/builtin-model-discovery.js +296 -0
- package/dist/services/builtin-model-discovery.js.map +1 -0
- package/dist/services/cache.d.ts +190 -0
- package/dist/services/cache.d.ts.map +1 -0
- package/dist/services/cache.js +617 -0
- package/dist/services/cache.js.map +1 -0
- package/dist/services/catalog-sync.d.ts +257 -0
- package/dist/services/catalog-sync.d.ts.map +1 -0
- package/dist/services/catalog-sync.js +1041 -0
- package/dist/services/catalog-sync.js.map +1 -0
- package/dist/services/compression/config.d.ts +33 -0
- package/dist/services/compression/config.d.ts.map +1 -0
- package/dist/services/compression/config.js +158 -0
- package/dist/services/compression/config.js.map +1 -0
- package/dist/services/compression/engines/aging.d.ts +2 -0
- package/dist/services/compression/engines/aging.d.ts.map +1 -0
- package/dist/services/compression/engines/aging.js +53 -0
- package/dist/services/compression/engines/aging.js.map +1 -0
- package/dist/services/compression/engines/custom-filters.d.ts +4 -0
- package/dist/services/compression/engines/custom-filters.d.ts.map +1 -0
- package/dist/services/compression/engines/custom-filters.js +49 -0
- package/dist/services/compression/engines/custom-filters.js.map +1 -0
- package/dist/services/compression/engines/dedup.d.ts +2 -0
- package/dist/services/compression/engines/dedup.d.ts.map +1 -0
- package/dist/services/compression/engines/dedup.js +61 -0
- package/dist/services/compression/engines/dedup.js.map +1 -0
- package/dist/services/compression/engines/filter-definitions.d.ts +75 -0
- package/dist/services/compression/engines/filter-definitions.d.ts.map +1 -0
- package/dist/services/compression/engines/filter-definitions.js +45 -0
- package/dist/services/compression/engines/filter-definitions.js.map +1 -0
- package/dist/services/compression/engines/hard-budget.d.ts +2 -0
- package/dist/services/compression/engines/hard-budget.d.ts.map +1 -0
- package/dist/services/compression/engines/hard-budget.js +52 -0
- package/dist/services/compression/engines/hard-budget.js.map +1 -0
- package/dist/services/compression/engines/index.d.ts +9 -0
- package/dist/services/compression/engines/index.d.ts.map +1 -0
- package/dist/services/compression/engines/index.js +9 -0
- package/dist/services/compression/engines/index.js.map +1 -0
- package/dist/services/compression/engines/jsoncompact.d.ts +3 -0
- package/dist/services/compression/engines/jsoncompact.d.ts.map +1 -0
- package/dist/services/compression/engines/jsoncompact.js +145 -0
- package/dist/services/compression/engines/jsoncompact.js.map +1 -0
- package/dist/services/compression/engines/lite.d.ts +2 -0
- package/dist/services/compression/engines/lite.d.ts.map +1 -0
- package/dist/services/compression/engines/lite.js +34 -0
- package/dist/services/compression/engines/lite.js.map +1 -0
- package/dist/services/compression/engines/read-lifecycle.d.ts +2 -0
- package/dist/services/compression/engines/read-lifecycle.d.ts.map +1 -0
- package/dist/services/compression/engines/read-lifecycle.js +70 -0
- package/dist/services/compression/engines/read-lifecycle.js.map +1 -0
- package/dist/services/compression/engines/relevance.d.ts +2 -0
- package/dist/services/compression/engines/relevance.d.ts.map +1 -0
- package/dist/services/compression/engines/relevance.js +74 -0
- package/dist/services/compression/engines/relevance.js.map +1 -0
- package/dist/services/compression/engines/toolfilter.d.ts +2 -0
- package/dist/services/compression/engines/toolfilter.d.ts.map +1 -0
- package/dist/services/compression/engines/toolfilter.js +195 -0
- package/dist/services/compression/engines/toolfilter.js.map +1 -0
- package/dist/services/compression/fidelity-gate.d.ts +12 -0
- package/dist/services/compression/fidelity-gate.d.ts.map +1 -0
- package/dist/services/compression/fidelity-gate.js +90 -0
- package/dist/services/compression/fidelity-gate.js.map +1 -0
- package/dist/services/compression/helpers.d.ts +9 -0
- package/dist/services/compression/helpers.d.ts.map +1 -0
- package/dist/services/compression/helpers.js +52 -0
- package/dist/services/compression/helpers.js.map +1 -0
- package/dist/services/compression/pipeline.d.ts +7 -0
- package/dist/services/compression/pipeline.d.ts.map +1 -0
- package/dist/services/compression/pipeline.js +245 -0
- package/dist/services/compression/pipeline.js.map +1 -0
- package/dist/services/compression/preservation.d.ts +27 -0
- package/dist/services/compression/preservation.d.ts.map +1 -0
- package/dist/services/compression/preservation.js +109 -0
- package/dist/services/compression/preservation.js.map +1 -0
- package/dist/services/compression/registry.d.ts +6 -0
- package/dist/services/compression/registry.d.ts.map +1 -0
- package/dist/services/compression/registry.js +16 -0
- package/dist/services/compression/registry.js.map +1 -0
- package/dist/services/compression/stats.d.ts +29 -0
- package/dist/services/compression/stats.d.ts.map +1 -0
- package/dist/services/compression/stats.js +55 -0
- package/dist/services/compression/stats.js.map +1 -0
- package/dist/services/compression/types.d.ts +90 -0
- package/dist/services/compression/types.d.ts.map +1 -0
- package/dist/services/compression/types.js +2 -0
- package/dist/services/compression/types.js.map +1 -0
- package/dist/services/context-handoff.d.ts +22 -0
- package/dist/services/context-handoff.d.ts.map +1 -0
- package/dist/services/context-handoff.js +164 -0
- package/dist/services/context-handoff.js.map +1 -0
- package/dist/services/cooldown-probe.d.ts +24 -0
- package/dist/services/cooldown-probe.d.ts.map +1 -0
- package/dist/services/cooldown-probe.js +181 -0
- package/dist/services/cooldown-probe.js.map +1 -0
- package/dist/services/custom-endpoint.d.ts +58 -0
- package/dist/services/custom-endpoint.d.ts.map +1 -0
- package/dist/services/custom-endpoint.js +167 -0
- package/dist/services/custom-endpoint.js.map +1 -0
- package/dist/services/custom-media-register.d.ts +22 -0
- package/dist/services/custom-media-register.d.ts.map +1 -0
- package/dist/services/custom-media-register.js +45 -0
- package/dist/services/custom-media-register.js.map +1 -0
- package/dist/services/custom-model-register.d.ts +50 -0
- package/dist/services/custom-model-register.d.ts.map +1 -0
- package/dist/services/custom-model-register.js +103 -0
- package/dist/services/custom-model-register.js.map +1 -0
- package/dist/services/custom-model-seed.d.ts +18 -0
- package/dist/services/custom-model-seed.d.ts.map +1 -0
- package/dist/services/custom-model-seed.js +42 -0
- package/dist/services/custom-model-seed.js.map +1 -0
- package/dist/services/custom-model-sync.d.ts +37 -0
- package/dist/services/custom-model-sync.d.ts.map +1 -0
- package/dist/services/custom-model-sync.js +118 -0
- package/dist/services/custom-model-sync.js.map +1 -0
- package/dist/services/custom-model-tombstone.d.ts +5 -0
- package/dist/services/custom-model-tombstone.d.ts.map +1 -0
- package/dist/services/custom-model-tombstone.js +30 -0
- package/dist/services/custom-model-tombstone.js.map +1 -0
- package/dist/services/declarative-config.d.ts +356 -0
- package/dist/services/declarative-config.d.ts.map +1 -0
- package/dist/services/declarative-config.js +427 -0
- package/dist/services/declarative-config.js.map +1 -0
- package/dist/services/degradation.d.ts +40 -0
- package/dist/services/degradation.d.ts.map +1 -0
- package/dist/services/degradation.js +119 -0
- package/dist/services/degradation.js.map +1 -0
- package/dist/services/embeddings.d.ts +76 -0
- package/dist/services/embeddings.d.ts.map +1 -0
- package/dist/services/embeddings.js +319 -0
- package/dist/services/embeddings.js.map +1 -0
- package/dist/services/fusion.d.ts +162 -0
- package/dist/services/fusion.d.ts.map +1 -0
- package/dist/services/fusion.js +789 -0
- package/dist/services/fusion.js.map +1 -0
- package/dist/services/gemini-map.d.ts +14 -0
- package/dist/services/gemini-map.d.ts.map +1 -0
- package/dist/services/gemini-map.js +78 -0
- package/dist/services/gemini-map.js.map +1 -0
- package/dist/services/health.d.ts +52 -0
- package/dist/services/health.d.ts.map +1 -0
- package/dist/services/health.js +352 -0
- package/dist/services/health.js.map +1 -0
- package/dist/services/idempotency.d.ts +49 -0
- package/dist/services/idempotency.d.ts.map +1 -0
- package/dist/services/idempotency.js +140 -0
- package/dist/services/idempotency.js.map +1 -0
- package/dist/services/key-budget.d.ts +74 -0
- package/dist/services/key-budget.d.ts.map +1 -0
- package/dist/services/key-budget.js +200 -0
- package/dist/services/key-budget.js.map +1 -0
- package/dist/services/media.d.ts +123 -0
- package/dist/services/media.d.ts.map +1 -0
- package/dist/services/media.js +954 -0
- package/dist/services/media.js.map +1 -0
- package/dist/services/model-discovery.d.ts +126 -0
- package/dist/services/model-discovery.d.ts.map +1 -0
- package/dist/services/model-discovery.js +662 -0
- package/dist/services/model-discovery.js.map +1 -0
- package/dist/services/model-groups.d.ts +149 -0
- package/dist/services/model-groups.d.ts.map +1 -0
- package/dist/services/model-groups.js +314 -0
- package/dist/services/model-groups.js.map +1 -0
- package/dist/services/model-listing.d.ts +18 -0
- package/dist/services/model-listing.d.ts.map +1 -0
- package/dist/services/model-listing.js +94 -0
- package/dist/services/model-listing.js.map +1 -0
- package/dist/services/model-retirement.d.ts +29 -0
- package/dist/services/model-retirement.d.ts.map +1 -0
- package/dist/services/model-retirement.js +86 -0
- package/dist/services/model-retirement.js.map +1 -0
- package/dist/services/model-state.d.ts +90 -0
- package/dist/services/model-state.d.ts.map +1 -0
- package/dist/services/model-state.js +342 -0
- package/dist/services/model-state.js.map +1 -0
- package/dist/services/model-weight-overrides.d.ts +48 -0
- package/dist/services/model-weight-overrides.d.ts.map +1 -0
- package/dist/services/model-weight-overrides.js +127 -0
- package/dist/services/model-weight-overrides.js.map +1 -0
- package/dist/services/notification-emitter.d.ts +50 -0
- package/dist/services/notification-emitter.d.ts.map +1 -0
- package/dist/services/notification-emitter.js +177 -0
- package/dist/services/notification-emitter.js.map +1 -0
- package/dist/services/opencode-free-sync.d.ts +29 -0
- package/dist/services/opencode-free-sync.d.ts.map +1 -0
- package/dist/services/opencode-free-sync.js +206 -0
- package/dist/services/opencode-free-sync.js.map +1 -0
- package/dist/services/penalty-inspector.d.ts +56 -0
- package/dist/services/penalty-inspector.d.ts.map +1 -0
- package/dist/services/penalty-inspector.js +167 -0
- package/dist/services/penalty-inspector.js.map +1 -0
- package/dist/services/profile-models.d.ts +5 -0
- package/dist/services/profile-models.d.ts.map +1 -0
- package/dist/services/profile-models.js +62 -0
- package/dist/services/profile-models.js.map +1 -0
- package/dist/services/provider-credential.d.ts +19 -0
- package/dist/services/provider-credential.d.ts.map +1 -0
- package/dist/services/provider-credential.js +51 -0
- package/dist/services/provider-credential.js.map +1 -0
- package/dist/services/provider-quota.d.ts +64 -0
- package/dist/services/provider-quota.d.ts.map +1 -0
- package/dist/services/provider-quota.js +563 -0
- package/dist/services/provider-quota.js.map +1 -0
- package/dist/services/quirks.d.ts +0 -0
- package/dist/services/quirks.d.ts.map +1 -0
- package/dist/services/quirks.js +0 -0
- package/dist/services/quirks.js.map +1 -0
- package/dist/services/quota-forecast.d.ts +50 -0
- package/dist/services/quota-forecast.d.ts.map +1 -0
- package/dist/services/quota-forecast.js +150 -0
- package/dist/services/quota-forecast.js.map +1 -0
- package/dist/services/quota-outlook.d.ts +4 -0
- package/dist/services/quota-outlook.d.ts.map +1 -0
- package/dist/services/quota-outlook.js +92 -0
- package/dist/services/quota-outlook.js.map +1 -0
- package/dist/services/ratelimit.d.ts +249 -0
- package/dist/services/ratelimit.d.ts.map +1 -0
- package/dist/services/ratelimit.js +1182 -0
- package/dist/services/ratelimit.js.map +1 -0
- package/dist/services/request-retention.d.ts +60 -0
- package/dist/services/request-retention.d.ts.map +1 -0
- package/dist/services/request-retention.js +229 -0
- package/dist/services/request-retention.js.map +1 -0
- package/dist/services/router.d.ts +384 -0
- package/dist/services/router.d.ts.map +1 -0
- package/dist/services/router.js +1986 -0
- package/dist/services/router.js.map +1 -0
- package/dist/services/scoring.d.ts +156 -0
- package/dist/services/scoring.d.ts.map +1 -0
- package/dist/services/scoring.js +435 -0
- package/dist/services/scoring.js.map +1 -0
- package/dist/services/url-tokens.d.ts +15 -0
- package/dist/services/url-tokens.d.ts.map +1 -0
- package/dist/services/url-tokens.js +55 -0
- package/dist/services/url-tokens.js.map +1 -0
- package/package.json +77 -4
- package/README.md +0 -3
|
@@ -0,0 +1,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
|