openvibers/openvibe.ai Commits
Unify provider protocol and search entries in the registry (#432)
Changes
nnew file mode 100644@@@ -0,0 +1,54 @@++# Provider registry++++The existing providers table is the authority for provider state, credentials, protocol, priority and capability membership. The seed in server/providers/pool-providers.js and its derived console presets describe defaults; they are not another routing list. An operator can add an OpenAI-compatible endpoint through the existing provider API without writing an adapter.++++New provider rows start disabled, including keyless search providers. Existing configured rows keep their status, endpoint, authentication mode and secret reference; legacy catalogue rows without a key are disabled and receive current documented endpoints; keys remain bound to the same origin. Existing model rows, prices, adult policy and routing gates are unchanged. Protocol metadata expands the existing JSON metadata field, so this stage needs no SQL migration. Search adapter identifiers are recorded here; search execution is integrated in the later ordering stage.++++Required-provider documentation was checked on 2026-11-10. Meta now documents Model API at api.meta.ai/v1 and explicitly says it does not host Llama; Llama remains available through existing inference vendors. Perplexity uses the Responses-compatible Agent API at /v1. Novita now documents /openai, Together /v1 on api.together.ai, and Hyperbolic /inference/v1 on api.hyperbolic.ai. Existing endpoint bindings are preserved for explicit owner migration. Qwen recommends a workspace-specific Singapore hostname; the legacy international base remains the configurable default. No provider keys or paid calls were used in this verification.++++| Provider | API family / adapter | Default base | Models list | Documentation |++|---|---|---|---|---|++| Qwen (Alibaba Model Studio) (qwen) | openai / openai | https://dashscope-intl.aliyuncs.com/compatible-mode/v1 | https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models | [Source](https://www.alibabacloud.com/help/en/model-studio/compatibility-of-openai-with-dashscope) |++| Kimi (Moonshot) (kimi) | openai / openai | https://api.moonshot.ai/v1 | https://api.moonshot.ai/v1/models | [Source](https://platform.moonshot.ai/docs/guide/start-using-kimi-api) |++| GLM (Z.ai) (glm) | openai / openai | https://api.z.ai/api/paas/v4 | https://api.z.ai/api/paas/v4/models | [Source](https://docs.z.ai/guides/overview/quick-start) |++| MiniMax (minimax) | openai / openai | https://api.minimax.io/v1 | https://api.minimax.io/v1/models | [Source](https://platform.minimax.io/docs/api-reference/text-openai-api) |++| DeepInfra (deepinfra) | openai / openai | https://api.deepinfra.com/v1/openai | https://api.deepinfra.com/v1/openai/models | [Source](https://deepinfra.com/docs/openai_api) |++| Novita AI (novita) | openai / openai | https://api.novita.ai/openai | https://api.novita.ai/openai/models | [Source](https://novita.ai/docs/api-reference/model-apis-llm-list-models) |++| Featherless AI (featherless) | openai / openai | https://api.featherless.ai/v1 | https://api.featherless.ai/v1/models | [Source](https://api.featherless.ai/v1/models) |++| Refuseless (the uncensored tier) (refuseless) | openai / openai | https://api.refuseless.com/v1 | https://api.refuseless.com/v1/models | [Source](https://docs.refuseless.com/api/models.md) |++| Abliteration.ai (the uncensored tier) (abliteration) | openai / openai | https://api.abliteration.ai/v1 | https://api.abliteration.ai/v1/models | [Source](https://docs.abliteration.ai/pricing.md) |++| OpenRouter (the last fallback) (openrouter) | openai / openai | https://openrouter.ai/api/v1 | https://openrouter.ai/api/v1/models | [Source](https://openrouter.ai/docs/api-reference/overview) |++| Venice AI (venice) | openai / openai | https://api.venice.ai/api/v1 | https://api.venice.ai/api/v1/models | [Source](https://docs.venice.ai/overview/pricing) |++| abliteratedmodels.cc (abliteratedmodels) | openai / openai | https://api.abliteratedmodels.cc/v1 | https://api.abliteratedmodels.cc/v1/models | [Source](https://abliteratedmodels.cc/es/) |++| UplinkSwitch (uplinkswitch) | openai / openai | https://api.uplinkswitch.ai/v1 | https://api.uplinkswitch.ai/v1/models | [Source](https://uplinkswitch.ai/acceptable-use) |++| Chutes (chutes) | openai / openai | https://llm.chutes.ai/v1 | https://llm.chutes.ai/v1/models | [Source](https://www.chutes.ai/pricing) |++| SiliconFlow (siliconflow) | openai / openai | https://api.siliconflow.com/v1 | https://api.siliconflow.com/v1/models | [Source](https://docs.siliconflow.com/) |++| Nebius Token Factory (nebius) | openai / openai | https://api.tokenfactory.nebius.com/v1 | https://api.tokenfactory.nebius.com/v1/models | [Source](https://docs.tokenfactory.nebius.com/quickstart) |++| Together AI (together) | openai / openai | https://api.together.ai/v1 | https://api.together.ai/v1/models | [Source](https://docs.together.ai/docs/openai-api-compatibility) |++| Fireworks AI (fireworks) | openai / openai | https://api.fireworks.ai/inference/v1 | https://api.fireworks.ai/inference/v1/models | [Source](https://docs.fireworks.ai/tools-sdks/openai-compatibility) |++| Groq (groq) | openai / openai | https://api.groq.com/openai/v1 | https://api.groq.com/openai/v1/models | [Source](https://console.groq.com/docs/openai) |++| Cerebras (cerebras) | openai / openai | https://api.cerebras.ai/v1 | https://api.cerebras.ai/v1/models | [Source](https://inference-docs.cerebras.ai/api-reference/models) |++| SambaNova (sambanova) | openai / openai | https://api.sambanova.ai/v1 | https://api.sambanova.ai/v1/models | [Source](https://docs-prod.sambanova.ai/docs/api-reference/endpoints/model-list) |++| Baseten (baseten) | openai / openai | https://inference.baseten.co/v1 | https://inference.baseten.co/v1/models | [Source](https://www.baseten.co/pricing/) |++| Hyperbolic (hyperbolic) | openai / openai | https://api.hyperbolic.ai/inference/v1 | https://api.hyperbolic.ai/inference/v1/models | [Source](https://docs.hyperbolic.ai/inference/quickstart) |++| Meta Model API (meta) | openai / openai | https://api.meta.ai/v1 | https://api.meta.ai/v1/models | [Source](https://dev.meta.ai/docs/api-reference) |++| DeepSeek (deepseek) | openai / openai | https://api.deepseek.com/v1 | https://api.deepseek.com/v1/models | [Source](https://api-docs.deepseek.com/) |++| Anthropic (anthropic) | anthropic / anthropic | https://api.anthropic.com/v1 | https://api.anthropic.com/v1/models | [Source](https://platform.claude.com/docs/en/api/overview) |++| OpenAI (openai) | openai / openai | https://api.openai.com/v1 | https://api.openai.com/v1/models | [Source](https://platform.openai.com/docs/api-reference/models/list) |++| Google Gemini (gemini) | google / openai | https://generativelanguage.googleapis.com/v1beta/openai | https://generativelanguage.googleapis.com/v1beta/openai/models | [Source](https://ai.google.dev/gemini-api/docs/openai) |++| xAI (xai) | openai / openai | https://api.x.ai/v1 | https://api.x.ai/v1/models | [Source](https://docs.x.ai/docs/api-reference) |++| Mistral (mistral) | openai / openai | https://api.mistral.ai/v1 | https://api.mistral.ai/v1/models | [Source](https://docs.mistral.ai/api) |++| Cohere (cohere) | openai / openai | https://api.cohere.com/compatibility/v1 | https://api.cohere.com/v1/models | [Source](https://docs.cohere.com/docs/compatibility-api) |++| Perplexity (perplexity) | responses / responses | https://api.perplexity.ai/v1 | https://api.perplexity.ai/v1/models | [Source](https://docs.perplexity.ai/docs/agent-api/openai-compatibility) |++| Brave Search (brave) | adapter / http | https://api.search.brave.com/res/v1 | Not applicable | [Source](https://api-dashboard.search.brave.com/documentation/services/web-search) |++| Tavily (tavily) | adapter / http | https://api.tavily.com | Not applicable | [Source](https://docs.tavily.com/documentation/api-reference/endpoint/search) |++| Exa (exa) | adapter / http | https://api.exa.ai | Not applicable | [Source](https://docs.exa.ai/reference/search) |++| Serper (serper) | adapter / http | https://google.serper.dev | Not applicable | [Source](https://serper.dev/) |++| Jina (jina) | adapter / http | https://s.jina.ai | Not applicable | [Source](https://jina.ai/reader/) |++| SearXNG (searxng) | adapter / http | Owner-configured | Not applicable | [Source](https://docs.searxng.org/dev/search_api.html) |++| Openverse (openverse) | adapter / http | https://api.openverse.org/v1 | Not applicable | [Source](https://api.openverse.org/) |++| Wikimedia Commons (commons) | adapter / http | https://commons.wikimedia.org/w/api.php | Not applicable | [Source](https://www.mediawiki.org/wiki/API:Search) |++++Search capabilities use web-search and image-search, matching the shared placement capability syntax. Brave web and images share one provider/key, as do Serper web and images. Openverse and Wikimedia Commons require no key. SearXNG has no invented public address; the owner supplies their self-hosted base.++++For Fable: the existing providers console now receives all seeded provider rows and protocol metadata. Its visual grouping and capability presentation remain for the main session.@@@ -4,20 +4,13 @@* is an OpenAI-compatible endpoint unless it says otherwise; prices are not here (rate cards are typed in by a person* from the provider's own price page, with the page and the day, on the model form).*/++const { PROVIDERS } = require('../providers/pool-providers');const PRESETS = [-- { id: 'deepseek', name: 'DeepSeek', kind: 'openai', base_url: 'https://api.deepseek.com/v1', model: 'deepseek-flash', keys: 'https://platform.deepseek.com/api_keys' },-- { id: 'openrouter', name: 'OpenRouter', kind: 'openai', base_url: 'https://openrouter.ai/api/v1', model: 'openrouter/auto', keys: 'https://openrouter.ai/settings/keys' },-- { id: 'groq', name: 'Groq', kind: 'openai', base_url: 'https://api.groq.com/openai/v1', model: 'openai/gpt-oss-20b', keys: 'https://console.groq.com/keys' },-- { id: 'gemini', name: 'Google Gemini', kind: 'openai', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai', model: '', keys: 'https://aistudio.google.com/apikey' },-- { id: 'mistral', name: 'Mistral', kind: 'openai', base_url: 'https://api.mistral.ai/v1', model: '', keys: 'https://console.mistral.ai/api-keys' },-- { id: 'together', name: 'Together AI', kind: 'openai', base_url: 'https://api.together.xyz/v1', model: '', keys: 'https://api.together.ai/settings/api-keys' },-- { id: 'fireworks', name: 'Fireworks', kind: 'openai', base_url: 'https://api.fireworks.ai/inference/v1', model: '', keys: 'https://fireworks.ai/account/api-keys' },-- { id: 'cerebras', name: 'Cerebras', kind: 'openai', base_url: 'https://api.cerebras.ai/v1', model: '', keys: 'https://cloud.cerebras.ai/' },-- { id: 'xai', name: 'xAI', kind: 'openai', base_url: 'https://api.x.ai/v1', model: '', keys: 'https://console.x.ai/' },-- { id: 'openai', name: 'OpenAI', kind: 'openai', base_url: 'https://api.openai.com/v1', model: 'gpt-5-mini', keys: 'https://platform.openai.com/api-keys' },-- { id: 'anthropic', name: 'Anthropic', kind: 'anthropic', base_url: 'https://api.anthropic.com/v1', model: '', keys: 'https://console.anthropic.com/settings/keys' },-- // Anthropic-format endpoints of providers that also speak the OpenAI format (Codes C3): POST /v1/messages reaches-- // these as they are, an OpenAI-shaped record never is converted.++ ...PROVIDERS.filter(p => p.capabilities.includes('chat')).map(p => ({++ id: p.key, name: p.display_name, kind: p.kind, base_url: p.base_url,++ model: p.credential_default_model ?? p.default_model ?? '', keys: p.keys_url || p.docs_url,++ })),++ // Compatibility names for already stored personal credentials; not separate provider rows.{ id: 'deepseek-anthropic', name: 'DeepSeek (Anthropic format)', kind: 'anthropic', base_url: 'https://api.deepseek.com/anthropic', model: 'deepseek-chat', keys: 'https://platform.deepseek.com/api_keys' },{ id: 'minimax-anthropic', name: 'MiniMax (Anthropic format)', kind: 'anthropic', base_url: 'https://api.minimax.io/anthropic', model: 'MiniMax-M2', keys: 'https://platform.minimax.io/user-center/basic-information/interface-key' },{ id: 'custom', name: 'Another OpenAI-compatible API', kind: 'openai', base_url: '', model: '', keys: null },@@@ -47,9 +47,6 @@const CHECKED = '2026-11-06';const NEW_CHECKED = '2026-11-09';--/** The keys of the ten providers, in the order the router should prefer them when the price is equal. */--const PROVIDER_KEYS = ['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'abliteration', 'openrouter', 'venice', 'abliteratedmodels', 'uplinkswitch', 'chutes', 'siliconflow', 'nebius', 'together', 'fireworks', 'groq', 'cerebras', 'sambanova', 'baseten', 'hyperbolic'];--/*** What a provider says it does with a prompt.*@@@ -97,6 +94,7 @@ const privacy = (o) => ({const PROVIDERS = [{key: 'qwen',++ docs_url: 'https://www.alibabacloud.com/help/en/model-studio/compatibility-of-openai-with-dashscope',display_name: 'Qwen (Alibaba Model Studio)',base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',default_model: 'qwen3.8-max',@@@ -148,6 +146,7 @@ const PROVIDERS = [},{key: 'kimi',++ docs_url: 'https://platform.moonshot.ai/docs/guide/start-using-kimi-api',display_name: 'Kimi (Moonshot)',base_url: 'https://api.moonshot.ai/v1',default_model: 'kimi-k3.1-pro',@@@ -198,6 +197,7 @@ const PROVIDERS = [},{key: 'glm',++ docs_url: 'https://docs.z.ai/guides/overview/quick-start',display_name: 'GLM (Z.ai)',base_url: 'https://api.z.ai/api/paas/v4',default_model: 'glm-5.3-flash',@@@ -258,6 +258,7 @@ const PROVIDERS = [},{key: 'minimax',++ docs_url: 'https://platform.minimax.io/docs/api-reference/text-openai-api',display_name: 'MiniMax',base_url: 'https://api.minimax.io/v1',default_model: 'MiniMax-M3.1-Flash',@@@ -296,6 +297,7 @@ const PROVIDERS = [},{key: 'deepinfra',++ docs_url: 'https://deepinfra.com/docs/openai_api',display_name: 'DeepInfra',base_url: 'https://api.deepinfra.com/v1/openai',default_model: 'deepseek-ai/DeepSeek-V4.1-Flash-1017',@@@ -369,8 +371,9 @@ const PROVIDERS = [},{key: 'novita',++ docs_url: 'https://novita.ai/docs/api-reference/model-apis-llm-list-models',display_name: 'Novita AI',-- base_url: 'https://api.novita.ai/v3/openai',++ base_url: 'https://api.novita.ai/openai',default_model: 'deepseek/deepseek-v4.1-flash-1017',timeout_ms: 90000,capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],@@@ -378,13 +381,13 @@ const PROVIDERS = [priority: 30,privacy: privacy({label: 'anonymized',-- terms_url: 'https://api.novita.ai/v3/openai/models',++ terms_url: 'https://api.novita.ai/openai/models',wording: 'Novita has no readable privacy policy page (the address returns a not-found page on 2026-11-06), so nothing says zero retention or no training',}),data_policy: policy({trains: null, retention: null, region: null,retention_note: 'not stated (the provider has no readable privacy page)',-- terms: 'https://api.novita.ai/v3/openai/models',++ terms: 'https://api.novita.ai/openai/models',resale: 'UNVERIFIED (2026-11-03 matrix): the terms page returned 404',}),@@@ -392,7 +395,7 @@ const PROVIDERS = [{model_key: 'deepseek/deepseek-v4.1-flash-1017', display_name: 'DeepSeek V4.1 Flash 1017', family: 'deepseek', context_window: 1048576,cost: { in_per_mtok: 0.30, cached_per_mtok: 0.006, out_per_mtok: 1.20 },-- source: 'https://api.novita.ai/v3/openai/models', checked: CHECKED,++ source: 'https://api.novita.ai/openai/models', checked: CHECKED,supports: { json: true, tools: true, streaming: true, vision: false },quantization: null, refusal_rate: null,note: 'the list marks the model as serverless with function calling, structured outputs and reasoning',@@@ -400,28 +403,28 @@ const PROVIDERS = [{model_key: 'qwen/qwen3.8-max', display_name: 'Qwen3.8-Max', family: 'qwen', context_window: 1000000,cost: { in_per_mtok: 2.00, cached_per_mtok: 0.25, out_per_mtok: 6.00 },-- source: 'https://api.novita.ai/v3/openai/models', checked: CHECKED,++ source: 'https://api.novita.ai/openai/models', checked: CHECKED,supports: { json: true, tools: true, streaming: true, vision: false },quantization: null, refusal_rate: null,},{model_key: 'zai-org/glm-5.3', display_name: 'GLM-5.3', family: 'glm', context_window: 1048576,cost: { in_per_mtok: 1.40, cached_per_mtok: 0.26, out_per_mtok: 4.40 },-- source: 'https://api.novita.ai/v3/openai/models', checked: CHECKED,++ source: 'https://api.novita.ai/openai/models', checked: CHECKED,supports: { json: true, tools: true, streaming: true, vision: false },quantization: null, refusal_rate: null,},{model_key: 'moonshotai/kimi-k3.1-pro', display_name: 'Kimi K3.1 Pro', family: 'kimi', context_window: 1048576,cost: { in_per_mtok: 2.00, cached_per_mtok: 0.20, out_per_mtok: 10.00 },-- source: 'https://api.novita.ai/v3/openai/models', checked: CHECKED,++ source: 'https://api.novita.ai/openai/models', checked: CHECKED,supports: { json: true, tools: true, streaming: true, vision: false },quantization: null, refusal_rate: null,},{model_key: 'xiaomimimo/mimo-v2.6-flash', display_name: 'MiMo V2.6 Flash', family: 'mimo', context_window: 1048576,cost: { in_per_mtok: 0.14, cached_per_mtok: 0.0028, out_per_mtok: 0.28 },-- source: 'https://api.novita.ai/v3/openai/models', checked: CHECKED,++ source: 'https://api.novita.ai/openai/models', checked: CHECKED,supports: { json: true, tools: true, streaming: true, vision: false },quantization: null, refusal_rate: null,},@@@ -612,7 +615,8 @@ const PROVIDERS = [],},{-- key: 'openrouter',++ key: 'openrouter', keys_url: 'https://openrouter.ai/settings/keys',++ docs_url: 'https://openrouter.ai/docs/api-reference/overview',display_name: 'OpenRouter (the last fallback)',base_url: 'https://openrouter.ai/api/v1',default_model: 'openrouter/auto',@@@ -642,18 +646,18 @@ const PROVIDERS = [['uplinkswitch', 'UplinkSwitch', 'https://api.uplinkswitch.ai/v1', 'abliterated', 'https://uplinkswitch.ai/acceptable-use', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, null, true],['chutes', 'Chutes', 'https://llm.chutes.ai/v1', 'Qwen/Qwen3-32B-TEE', 'https://www.chutes.ai/pricing', 'UNKNOWN', 'UNVERIFIED', { in: 0.104, cached: null, out: 0.416 }, 40960, false],['siliconflow', 'SiliconFlow', 'https://api.siliconflow.com/v1', 'Qwen/Qwen3-32B', 'https://docs.siliconflow.com/', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, null, false],-- ['nebius', 'Nebius Token Factory', 'https://api.tokenfactory.nebius.com/v1', 'Qwen/Qwen3-30B-A3B-Instruct-2507', 'https://nebius.com/services/token-factory/models/qwen-models-inference', 'UNKNOWN', 'UNVERIFIED', { in: 0.10, cached: null, out: 0.30 }, 262144, false],-- ['together', 'Together AI', 'https://api.together.xyz/v1', 'openai/gpt-oss-120b', 'https://www.together.ai/pricing', 'UNKNOWN', 'RESTRICTED', { in: 0.15, cached: null, out: 0.60 }, 131072, false],-- ['fireworks', 'Fireworks AI', 'https://api.fireworks.ai/inference/v1', 'accounts/fireworks/models/gpt-oss-120b', 'https://fireworks.ai/models', 'UNKNOWN', 'UNVERIFIED', { in: 0.15, cached: null, out: 0.60 }, 131072, false],-- ['groq', 'Groq', 'https://api.groq.com/openai/v1', 'openai/gpt-oss-120b', 'https://console.groq.com/docs/model/openai/gpt-oss-120b', 'UNKNOWN', 'UNVERIFIED', { in: 0.15, cached: 0.075, out: 0.60 }, 131072, false],-- ['cerebras', 'Cerebras', 'https://api.cerebras.ai/v1', 'gpt-oss-120b', 'https://api.cerebras.ai/public/v1/models', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, 131072, false],-- ['sambanova', 'SambaNova', 'https://api.sambanova.ai/v1', 'gpt-oss-120b', 'https://cloud.sambanova.ai/plans/pricing', 'UNKNOWN', 'UNVERIFIED', { in: 0.22, cached: null, out: 0.59 }, 131072, false],++ ['nebius', 'Nebius Token Factory', 'https://api.tokenfactory.nebius.com/v1', 'Qwen/Qwen3-30B-A3B-Instruct-2507', 'https://nebius.com/services/token-factory/models/qwen-models-inference', 'UNKNOWN', 'UNVERIFIED', { in: 0.10, cached: null, out: 0.30 }, 262144, false, 'https://docs.tokenfactory.nebius.com/quickstart'],++ ['together', 'Together AI', 'https://api.together.ai/v1', 'openai/gpt-oss-120b', 'https://www.together.ai/pricing', 'UNKNOWN', 'RESTRICTED', { in: 0.15, cached: null, out: 0.60 }, 131072, false, 'https://docs.together.ai/docs/openai-api-compatibility', 'https://api.together.ai/settings/api-keys', ''],++ ['fireworks', 'Fireworks AI', 'https://api.fireworks.ai/inference/v1', 'accounts/fireworks/models/gpt-oss-120b', 'https://fireworks.ai/models', 'UNKNOWN', 'UNVERIFIED', { in: 0.15, cached: null, out: 0.60 }, 131072, false, 'https://docs.fireworks.ai/tools-sdks/openai-compatibility', 'https://fireworks.ai/account/api-keys', ''],++ ['groq', 'Groq', 'https://api.groq.com/openai/v1', 'openai/gpt-oss-120b', 'https://console.groq.com/docs/model/openai/gpt-oss-120b', 'UNKNOWN', 'UNVERIFIED', { in: 0.15, cached: 0.075, out: 0.60 }, 131072, false, 'https://console.groq.com/docs/openai', 'https://console.groq.com/keys', 'openai/gpt-oss-20b'],++ ['cerebras', 'Cerebras', 'https://api.cerebras.ai/v1', 'gpt-oss-120b', 'https://api.cerebras.ai/public/v1/models', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, 131072, false, 'https://inference-docs.cerebras.ai/api-reference/models', 'https://cloud.cerebras.ai/', ''],++ ['sambanova', 'SambaNova', 'https://api.sambanova.ai/v1', 'gpt-oss-120b', 'https://cloud.sambanova.ai/plans/pricing', 'UNKNOWN', 'UNVERIFIED', { in: 0.22, cached: null, out: 0.59 }, 131072, false, 'https://docs-prod.sambanova.ai/docs/api-reference/endpoints/model-list'],['baseten', 'Baseten', 'https://inference.baseten.co/v1', 'openai/gpt-oss-120b', 'https://www.baseten.co/pricing/', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, 131072, false],-- ['hyperbolic', 'Hyperbolic', 'https://api.hyperbolic.xyz/v1', 'openai/gpt-oss-120b', 'https://docs.hyperbolic.xyz/docs/hyperbolic-ai-inference-pricing', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, 131072, false],-- ].map(([key, display_name, base_url, model_key, source, privacyLabel, provider_access, prices, context_window, uncensored]) => {++ ['hyperbolic', 'Hyperbolic', 'https://api.hyperbolic.ai/inference/v1', 'openai/gpt-oss-120b', 'https://docs.hyperbolic.xyz/docs/hyperbolic-ai-inference-pricing', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, 131072, false, 'https://docs.hyperbolic.ai/inference/quickstart'],++ ].map(([key, display_name, base_url, model_key, source, privacyLabel, provider_access, prices, context_window, uncensored, docs_url, keys_url, credential_default_model]) => {const privateTier = privacyLabel === 'PRIVATE';return {-- key, display_name, base_url, default_model: model_key, timeout_ms: 60000,++ key, display_name, base_url, docs_url, keys_url, credential_default_model, default_model: model_key, timeout_ms: 60000,capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],provider_access, priority: 40, ...(uncensored ? { uncensored: true } : {}),privacy: privacy({ label: privateTier ? 'private' : 'anonymized', terms_url: privateTier ? source : null,@@@ -672,8 +676,62 @@ const PROVIDERS = [],};}),++ // Providers without a hand-kept model list: the owner syncs and enables their models.++ { key: 'meta', display_name: 'Meta Model API', base_url: 'https://api.meta.ai/v1',++ docs_url: 'https://dev.meta.ai/docs/api-reference', capabilities: ['chat', 'vision', 'tools'] },++ { key: 'deepseek', keys_url: 'https://platform.deepseek.com/api_keys', display_name: 'DeepSeek', base_url: 'https://api.deepseek.com/v1', default_model: 'deepseek-flash',++ docs_url: 'https://api-docs.deepseek.com/', capabilities: ['chat', 'vision', 'tools', 'web-search'], search_adapters: ['deepseek'] },++ { key: 'anthropic', keys_url: 'https://console.anthropic.com/settings/keys', display_name: 'Anthropic', kind: 'anthropic', base_url: 'https://api.anthropic.com/v1', auth_mode: 'x-api-key',++ docs_url: 'https://platform.claude.com/docs/en/api/overview', capabilities: ['chat', 'messages', 'vision', 'tools'] },++ { key: 'openai', keys_url: 'https://platform.openai.com/api-keys', display_name: 'OpenAI', base_url: 'https://api.openai.com/v1', default_model: 'gpt-5-mini',++ docs_url: 'https://platform.openai.com/docs/api-reference/models/list', capabilities: ['chat', 'vision', 'tools', 'image', 'video', 'speech', 'transcribe', 'embed'] },++ { key: 'gemini', keys_url: 'https://aistudio.google.com/apikey', display_name: 'Google Gemini', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',++ api_family: 'google', docs_url: 'https://ai.google.dev/gemini-api/docs/openai', capabilities: ['chat', 'vision', 'tools', 'embed'] },++ { key: 'xai', keys_url: 'https://console.x.ai/', display_name: 'xAI', base_url: 'https://api.x.ai/v1',++ docs_url: 'https://docs.x.ai/docs/api-reference', capabilities: ['chat', 'vision', 'tools', 'image', 'video'] },++ { key: 'mistral', keys_url: 'https://console.mistral.ai/api-keys', display_name: 'Mistral', base_url: 'https://api.mistral.ai/v1',++ docs_url: 'https://docs.mistral.ai/api', capabilities: ['chat', 'vision', 'tools', 'embed', 'transcribe'] },++ { key: 'cohere', display_name: 'Cohere', base_url: 'https://api.cohere.com/compatibility/v1', models_endpoint: 'https://api.cohere.com/v1/models',++ docs_url: 'https://docs.cohere.com/docs/compatibility-api', capabilities: ['chat', 'tools', 'embed', 'rerank'] },++ { key: 'perplexity', display_name: 'Perplexity', kind: 'responses', base_url: 'https://api.perplexity.ai/v1',++ docs_url: 'https://docs.perplexity.ai/docs/agent-api/openai-compatibility', capabilities: ['chat', 'web-search'], search_adapters: ['perplexity'] },++ { key: 'brave', display_name: 'Brave Search', kind: 'http', base_url: 'https://api.search.brave.com/res/v1',++ auth_header: 'x-subscription-token', models_endpoint: null, capabilities: ['web-search', 'image-search'],++ search_adapters: ['brave', 'brave-images'], docs_url: 'https://api-dashboard.search.brave.com/documentation/services/web-search' },++ { key: 'tavily', display_name: 'Tavily', kind: 'http', base_url: 'https://api.tavily.com', models_endpoint: null,++ capabilities: ['web-search'], search_adapters: ['tavily'], docs_url: 'https://docs.tavily.com/documentation/api-reference/endpoint/search' },++ { key: 'exa', display_name: 'Exa', kind: 'http', base_url: 'https://api.exa.ai', auth_mode: 'x-api-key', models_endpoint: null,++ capabilities: ['web-search'], search_adapters: ['exa'], docs_url: 'https://docs.exa.ai/reference/search' },++ { key: 'serper', display_name: 'Serper', kind: 'http', base_url: 'https://google.serper.dev', auth_mode: 'x-api-key', models_endpoint: null,++ capabilities: ['web-search', 'image-search'], search_adapters: ['serper', 'serper-images'], docs_url: 'https://serper.dev/' },++ { key: 'jina', display_name: 'Jina', kind: 'http', base_url: 'https://s.jina.ai', models_endpoint: null,++ capabilities: ['web-search'], search_adapters: ['jina'], docs_url: 'https://jina.ai/reader/' },++ { key: 'searxng', display_name: 'SearXNG', kind: 'http', base_url: null, auth_mode: 'none', models_endpoint: null,++ capabilities: ['web-search'], search_adapters: ['searxng'], docs_url: 'https://docs.searxng.org/dev/search_api.html' },++ { key: 'openverse', display_name: 'Openverse', kind: 'http', base_url: 'https://api.openverse.org/v1', auth_mode: 'none', models_endpoint: null,++ capabilities: ['image-search'], search_adapters: ['openverse'], docs_url: 'https://api.openverse.org/' },++ { key: 'commons', display_name: 'Wikimedia Commons', kind: 'http', base_url: 'https://commons.wikimedia.org/w/api.php', auth_mode: 'none', models_endpoint: null,++ capabilities: ['image-search'], search_adapters: ['commons'], docs_url: 'https://www.mediawiki.org/wiki/API:Search' },++];++// The seed, console and personal-key forms consume these same records.++for (const p of PROVIDERS) {++ if (p.capabilities.includes('chat')) p.capabilities = [...new Set([...p.capabilities, 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json'])];++ p.kind ||= 'openai';++ p.auth_mode ||= 'bearer';++ p.api_family ||= p.kind === 'http' ? 'adapter' : p.kind;++ if (p.models_endpoint === undefined) p.models_endpoint = p.base_url ? `${p.base_url}/models` : null;++ p.models ||= [];++ p.provider_access ||= 'UNVERIFIED';++ p.priority ??= 100;++ p.timeout_ms ||= 30000;++ p.docs_url ||= p.models[0]?.source || null;++ p.data_policy ||= policy({ checked: '2026-11-10' });++ p.privacy ||= privacy({ label: 'anonymized', wording: 'Data retention and training terms have not been verified.', checked: '2026-11-10' });++}++const PROVIDER_KEYS = PROVIDERS.map(p => p.key);++const byKey = new Map(PROVIDERS.map((p) => [p.key, p]));/** The name a provider's model is known by, without its namespace: `deepseek/deepseek-chat` -> `deepseek-chat`. */@@@ -17,16 +17,20 @@*/const pool = require('./pool-providers');++const { providerSecret } = require('../util');/** The registry row of one catalogued provider. */function recordOf(p) {return {-- key: p.key, display_name: p.display_name, kind: 'openai', status: 'active',-- base_url: p.base_url, auth_mode: 'bearer', secret_ref: `vault:${p.key}`,++ key: p.key, display_name: p.display_name, kind: p.kind, status: 'disabled',++ base_url: p.base_url, auth_mode: p.auth_mode, secret_ref: p.auth_mode === 'none' ? null : `vault:${p.key}`,default_model: p.default_model || null, capabilities: p.capabilities, timeout_ms: p.timeout_ms,priority: p.priority, billing_profile: 'metered', provider_access: p.provider_access,metadata: {catalog: 'pool-providers',++ api_family: p.api_family, models_endpoint: p.models_endpoint,++ auth_header: p.auth_header || null, docs_url: p.docs_url || null,++ search_adapters: p.search_adapters || [],data_policy: p.data_policy,// The privacy label of the provider: `private` only where its own terms say it keeps nothing and trains on// nothing, `anonymized` for everyone else. The wording and the page it was read from travel with it.@@@ -83,7 +87,7 @@ function cataloguePrice(row) {** → { providers: [key], models: [key], skipped: [key] }*/--async function seedPoolProviders({ registry, actor = 'system' }) {++async function seedPoolProviders({ registry, actor = 'system', env = process.env }) {const out = { providers: [], models: [], skipped: [] };for (const p of pool.PROVIDERS) {const prev = await registry.getProvider(p.key);@@@ -91,16 +95,30 @@ async function seedPoolProviders({ registry, actor = 'system' }) {await registry.upsertProvider(recordOf(p), { actor, origin: 'catalog' });out.providers.push(p.key);} else if (prev.origin === 'catalog') {-- // The catalogue owns this row: keep the owner's status and key slot, refresh the rest.++ const defaults = recordOf(p);++ const configured = prev.auth_mode === 'none' || Boolean(providerSecret(prev, env));++ if (configured && prev.base_url !== p.base_url) defaults.metadata.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;++ const metadata = { ...defaults.metadata, ...prev.metadata };++ if (!configured && prev.base_url !== p.base_url) {++ metadata.models_endpoint = defaults.metadata.models_endpoint;++ metadata.api_family = defaults.metadata.api_family;++ }++ // A key is bound to its origin. A documentation refresh must never move a working++ // provider or change its wire format; endpoint changes are explicit owner edits.await registry.upsertProvider({-- ...recordOf(p),-- status: prev.status,-- secret_ref: prev.secret_ref || `vault:${p.key}`,++ ...defaults,++ ...(configured ? { base_url: prev.base_url, kind: prev.kind, auth_mode: prev.auth_mode } : {}),++ status: prev.status === 'active' && !configured ? 'disabled' : prev.status,++ secret_ref: prev.secret_ref,provider_access: prev.provider_access || p.provider_access,-- metadata: { ...prev.metadata, ...recordOf(p).metadata },++ metadata,}, { actor, origin: 'catalog' });} else {-- out.skipped.push(p.key); // the owner's own provider: never touched++ out.skipped.push(p.key);++ const protocol = recordOf(p).metadata;++ if (prev.base_url !== p.base_url) protocol.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;++ const additions = Object.fromEntries(Object.entries(protocol).filter(([k]) => ['api_family', 'models_endpoint', 'auth_header', 'docs_url', 'search_adapters'].includes(k) && prev.metadata[k] === undefined));++ if (Object.keys(additions).length) await registry.upsertProvider({ key: p.key, metadata: { ...prev.metadata, ...additions } }, { actor, origin: prev.origin });}for (const m of p.models) {const row = await registry.getModel(p.key, m.model_key);@@@ -17,7 +17,7 @@ const PROVIDER_KINDS = ['stub', 'openai', 'responses', 'anthropic', 'http', 'whiconst BILLING_PROFILES = ['metered', 'subscription', 'payg', 'free', 'byok'];// AI Exchange D3: who may resell this offer's capacity. Defaults to UNVERIFIED (migrations/0024).const PROVIDER_ACCESS = ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'];--const FEATURES = ['chat', 'complete', 'messages', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'tools', 'transcribe', 'image'];++const FEATURES = ['chat', 'complete', 'messages', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'tools', 'transcribe', 'image', 'video', 'speech', 'rerank', 'web-search', 'image-search'];const LIFECYCLE = ['draft', 'active', 'deprecated', 'archived'];const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;const flag = (v) => v === true || v === 1 || /^(1|true|yes|on)$/i.test(String(v == null ? '' : v));@@@ -147,7 +147,7 @@ async function seed({ registry, quotas, config, env = process.env, db }) {// DeepInfra, Novita, Featherless, Refuseless, Abliteration.ai and OpenRouter. Each is registered off until its key// is in the owner settings; the pool skips one whose key does not resolve. Prices come from each provider's own// page, with that page and the day they were read (server/providers/pool-providers.js).-- await poolSeed.seedPoolProviders({ registry, actor: 'seed' });++ await poolSeed.seedPoolProviders({ registry, actor: 'seed', env });const shared = await registry.getProvider('shared');// Models@@@ -44,11 +44,20 @@ const iso = (n) => new Date(n).toISOString();const CHAT = { messages: [{ role: 'user', content: 'x' }], timeoutMs: 3000 };t.test('provider presets are OpenAI-compatible, with a base URL, a timeout, an access mode and a privacy label', () => {-- assert.deepStrictEqual(pool.PROVIDERS.map((p) => p.key),++ assert.deepStrictEqual(pool.PROVIDERS.slice(0, 23).map((p) => p.key),['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'abliteration', 'openrouter', 'venice', 'abliteratedmodels', 'uplinkswitch', 'chutes', 'siliconflow', 'nebius', 'together', 'fireworks', 'groq', 'cerebras', 'sambanova', 'baseten', 'hyperbolic']);assert.deepStrictEqual(pool.PROVIDER_KEYS, pool.PROVIDERS.map((p) => p.key));++ assert.strictEqual(new Set(pool.PROVIDER_KEYS).size, 40);++ assert.strictEqual(pool.byKey.get('meta').base_url, 'https://api.meta.ai/v1');++ assert.strictEqual(pool.byKey.get('perplexity').kind, 'responses');++ assert.strictEqual(require('../server/console/presets').credentialProvider('groq').model, 'openai/gpt-oss-20b', 'existing personal-key default is preserved');++ assert.deepStrictEqual(pool.byKey.get('brave').search_adapters, ['brave', 'brave-images']);++ assert.strictEqual(pool.byKey.get('openverse').auth_mode, 'none');++ assert.strictEqual(pool.byKey.get('commons').auth_mode, 'none');for (const p of pool.PROVIDERS) {-- assert.ok(/^https:\/\//.test(p.base_url), `${p.key} base_url`);++ assert.ok(p.key === 'searxng' && p.base_url === null || /^https:\/\//.test(p.base_url), `${p.key} base_url`);++ assert.strictEqual(seed.recordOf(p).status, 'disabled', `${p.key} starts off`);++ assert.ok(p.api_family && Object.hasOwn(p, 'models_endpoint'), `${p.key} has protocol metadata`);assert.ok(p.timeout_ms >= 1000, `${p.key} timeout`);assert.ok(['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'].includes(p.provider_access), p.key);assert.ok(p.data_policy, `${p.key} data policy`);@@@ -142,8 +151,32 @@ t.test('the seed writes the privacy label, the quantization and the refusal rateassert.strictEqual(saved.get('qwen').base_url, 'https://proxy.example/v1');});++t.test('seed refreshes unused endpoints while preserving configured and owner-managed bindings', async () => {++ const source = pool.byKey.get('hyperbolic');++ const records = new Map([['hyperbolic', {++ ...seed.recordOf(source), status: 'active', origin: 'catalog',++ base_url: 'https://legacy.example/v1', secret_ref: 'env:REGISTRY_TEST_KEY', metadata: {},++ }]]);++ const registry = {++ getProvider: async key => records.get(key) || null,++ upsertProvider: async (row, options) => records.set(row.key, { ...records.get(row.key), ...row, origin: options.origin }),++ getModel: async () => null, upsertModel: async () => {},++ };++ await seed.seedPoolProviders({ registry, env: { REGISTRY_TEST_KEY: 'fixture-provider-credential' } });++ assert.strictEqual(records.get('hyperbolic').base_url, 'https://legacy.example/v1');++ assert.strictEqual(records.get('hyperbolic').status, 'active');++ await seed.seedPoolProviders({ registry, env: {} });++ assert.strictEqual(records.get('hyperbolic').base_url, source.base_url);++ assert.strictEqual(records.get('hyperbolic').metadata.models_endpoint, source.models_endpoint);++ assert.strictEqual(records.get('hyperbolic').status, 'disabled');++ records.set('hyperbolic', { ...records.get('hyperbolic'), origin: 'admin', base_url: 'https://owner.example/v1', status: 'active' });++ await seed.seedPoolProviders({ registry, env: {} });++ assert.strictEqual(records.get('hyperbolic').base_url, 'https://owner.example/v1');++ assert.strictEqual(records.get('hyperbolic').status, 'active');++});++t.test('the recorded shape of every provider is parsed by the OpenAI adapter, tool calls included', async () => {-- for (const key of pool.PROVIDER_KEYS) {++ for (const key of pool.PROVIDERS.filter(p => p.kind === 'openai').map(p => p.key)) {const recorded = fixtures[key] || fixtures.openrouter;assert.ok(recorded && recorded.completion, `${key} fixture`);const srv = await seamServer(() => recorded.completion);@@@ -165,7 +198,7 @@ t.test('the recorded shape of every provider is parsed by the OpenAI adapter, tot.test('a catalogue offer with no key is off: the pool skips it no_credentials', async () => {// The commercial gate (an UNVERIFIED offer) keeps most of the catalogue out of the managed pool; the// credential gate is what this test is about, so the row carries the access mode the owner would grant.-- const row = { ...seed.recordOf(pool.byKey.get('qwen')), provider_access: 'OWN_DEPLOYMENT', origin: 'catalog' };++ const row = { ...seed.recordOf(pool.byKey.get('qwen')), status: 'active', provider_access: 'OWN_DEPLOYMENT', origin: 'catalog' };const registry = {getProvider: async (k) => (k === 'qwen' ? row : null),listProviders: async () => [row],@@@ -200,14 +233,15 @@ t.test('a provider that says the account has no money left is exhaustion, and itassert.strictEqual(insufficientBalance(new ProviderError('the backend fell over', { status: 500, code: 'provider.http', body: {} })), false);});--t.test('a booted service seeds the ten offers, each with its own key slot', async () => {++t.test('a booted service seeds the registry and preserves the existing environment key slot', async () => {const h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });try {const rows = await h.registry.listProviders();for (const p of pool.PROVIDERS) {const row = rows.find((x) => x.key === p.key);assert.ok(row, `${p.key} seeded`);-- assert.strictEqual(row.secret_ref, `vault:${p.key}`, `${p.key} key slot`);++ assert.strictEqual(row.secret_ref, p.key === 'deepseek' ? 'env:DEEPSEEK_API_KEY' : p.auth_mode === 'none' ? null : `vault:${p.key}`, `${p.key} key slot`);++ if (p.key === 'deepseek') continue; // the existing environment-backed seed retains its configurationassert.ok(row.metadata && row.metadata.catalog === 'pool-providers', `${p.key} catalogue row`);assert.strictEqual(row.metadata.privacy.label, p.privacy.label, `${p.key} privacy label`);}@@@ -215,7 +249,7 @@ t.test('a booted service seeds the ten offers, each with its own key slot', asynconst route = { key: 'test.seeded', primary: { provider: 'qwen', model: null }, fallbacks: [], timeout_ms: 3000 };const { explain } = await h.pool.candidates(route, ['chat'], 'chat', {});assert.strictEqual(explain.candidates[0].eligible, false, 'an offer with no key is not eligible');-- assert.ok(String(explain.candidates[0].excluded_reason).includes('no_credentials'), explain.candidates[0].excluded_reason);++ assert.ok(/no_credentials|disabled/.test(String(explain.candidates[0].excluded_reason)), explain.candidates[0].excluded_reason);await assert.rejects(h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'x' }], timeoutMs: 3000 }, { routeKey: route.key }),(err) => err.code === 'provider.unavailable',@@@ -199,7 +199,7 @@ t.test('a booted service seeds the catalogue off, and the uncensored request isfor (const p of pool.PROVIDERS) {const row = seeded.find((x) => x.key === p.key);assert.ok(row, `${p.key} seeded`);-- assert.strictEqual(row.secret_ref, `vault:${p.key}`, `${p.key} key slot`);++ assert.strictEqual(row.secret_ref, p.key === 'deepseek' ? 'env:DEEPSEEK_API_KEY' : p.auth_mode === 'none' ? null : `vault:${p.key}`, `${p.key} key slot`);assert.ok(await h.registry.getModel(p.key, p.models[0] && p.models[0].model_key || '').catch(() => null) || p.models.length === 0, `${p.key} model`);}await h.registry.upsertProvider({ key: 'refuseless', display_name: 'Refuseless', kind: 'openai', status: 'active', base_url: seam.url, auth_mode: 'none', secret_ref: null, default_model: 'deepseek-v4.1-flash-abliterated', capabilities: ['chat', 'generate', 'complete'], timeout_ms: 5000, priority: 1, provider_access: 'AUTHORIZED_WHOLESALE', metadata: { uncensored: true } }, { origin: 'admin' });@@@ -66,7 +66,7 @@ async function makeProvider(name) {const made = await request(h.base, 'POST', '/api/v1/providers', {tok: service,body: {-- key: name, kind: offer.kind, base_url: `${upstream.url}/v1`, auth_mode: 'none',++ key: name, status: 'active', kind: offer.kind, base_url: `${upstream.url}/v1`, auth_mode: 'none',capabilities: anthropic ? ['chat', 'messages', 'tools', 'json'] : ['chat', 'complete', 'tools', 'json'],default_model: offer.model,provider_access: 'OWN_DEPLOYMENT', metadata: { data_policy: offer.data_policy },@@@ -56,7 +56,7 @@ t.test('catalog groups the same model across providers, with prices, speed, uptiawait h.registry.upsertModel({ provider_key: 'deepseek', model_key: 'deepseek-chat', type: 'chat', context_window: 131072,cost: { in_per_mtok: 0.28, out_per_mtok: 0.42, cached_per_mtok: 0.028 } });await h.registry.upsertProvider({-- key: 'deepinfra', display_name: 'DeepInfra', kind: 'openai', auth_mode: 'bearer', secret_ref: 'env:DEEPINFRA_KEY',++ key: 'deepinfra', status: 'active', display_name: 'DeepInfra', kind: 'openai', auth_mode: 'bearer', secret_ref: 'env:DEEPINFRA_KEY',base_url: 'https://api.deepinfra.test', capabilities: ['chat'], provider_access: 'AUTHORIZED_WHOLESALE',});await h.registry.upsertModel({ provider_key: 'deepinfra', model_key: 'deepseek-chat', type: 'chat', context_window: 131072,@@@ -28,7 +28,7 @@ const credentialFetch = async (url, init = {}) => {};async function provider(body) {-- const r = await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: { kind: 'http', auth_mode: 'none', timeout_ms: 3000, ...body } });++ const r = await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: { status: 'active', kind: 'http', auth_mode: 'none', timeout_ms: 3000, ...body } });assert.strictEqual(r.status, 201, r.text);}async function model(providerKey, modelKey, body) {@@@ -158,7 +158,7 @@ t.test('the owner approves a waiting change and the price the router charges folt.test('the public catalog says when the price was checked', async () => {// The catalog lists an offer only when the provider's key is there, as production has it.await h.registry.upsertProvider({-- key: 'deepinfra', display_name: 'DeepInfra', kind: 'openai', auth_mode: 'bearer', secret_ref: 'env:DEEPINFRA_KEY',++ key: 'deepinfra', status: 'active', display_name: 'DeepInfra', kind: 'openai', auth_mode: 'bearer', secret_ref: 'env:DEEPINFRA_KEY',base_url: 'https://api.deepinfra.test', capabilities: ['chat'], provider_access: 'AUTHORIZED_WHOLESALE',});const book = createPriceBook({ db: h.db, registry: h.registry, config: { priceBook: { enabled: true }, nodeEnv: 'test' }, clock: CLOCK, log: silent, transport: recorded });
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+# Provider registry
+
+The existing providers table is the authority for provider state, credentials, protocol, priority and capability membership. The seed in server/providers/pool-providers.js and its derived console presets describe defaults; they are not another routing list. An operator can add an OpenAI-compatible endpoint through the existing provider API without writing an adapter.
+
+New provider rows start disabled, including keyless search providers. Existing configured rows keep their status, endpoint, authentication mode and secret reference; legacy catalogue rows without a key are disabled and receive current documented endpoints; keys remain bound to the same origin. Existing model rows, prices, adult policy and routing gates are unchanged. Protocol metadata expands the existing JSON metadata field, so this stage needs no SQL migration. Search adapter identifiers are recorded here; search execution is integrated in the later ordering stage.
+
+Required-provider documentation was checked on 2026-11-10. Meta now documents Model API at api.meta.ai/v1 and explicitly says it does not host Llama; Llama remains available through existing inference vendors. Perplexity uses the Responses-compatible Agent API at /v1. Novita now documents /openai, Together /v1 on api.together.ai, and Hyperbolic /inference/v1 on api.hyperbolic.ai. Existing endpoint bindings are preserved for explicit owner migration. Qwen recommends a workspace-specific Singapore hostname; the legacy international base remains the configurable default. No provider keys or paid calls were used in this verification.
+
+| Provider | API family / adapter | Default base | Models list | Documentation |
+|---|---|---|---|---|
+| Qwen (Alibaba Model Studio) (qwen) | openai / openai | https://dashscope-intl.aliyuncs.com/compatible-mode/v1 | https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models | [Source](https://www.alibabacloud.com/help/en/model-studio/compatibility-of-openai-with-dashscope) |
+| Kimi (Moonshot) (kimi) | openai / openai | https://api.moonshot.ai/v1 | https://api.moonshot.ai/v1/models | [Source](https://platform.moonshot.ai/docs/guide/start-using-kimi-api) |
+| GLM (Z.ai) (glm) | openai / openai | https://api.z.ai/api/paas/v4 | https://api.z.ai/api/paas/v4/models | [Source](https://docs.z.ai/guides/overview/quick-start) |
+| MiniMax (minimax) | openai / openai | https://api.minimax.io/v1 | https://api.minimax.io/v1/models | [Source](https://platform.minimax.io/docs/api-reference/text-openai-api) |
+| DeepInfra (deepinfra) | openai / openai | https://api.deepinfra.com/v1/openai | https://api.deepinfra.com/v1/openai/models | [Source](https://deepinfra.com/docs/openai_api) |
+| Novita AI (novita) | openai / openai | https://api.novita.ai/openai | https://api.novita.ai/openai/models | [Source](https://novita.ai/docs/api-reference/model-apis-llm-list-models) |
+| Featherless AI (featherless) | openai / openai | https://api.featherless.ai/v1 | https://api.featherless.ai/v1/models | [Source](https://api.featherless.ai/v1/models) |
+| Refuseless (the uncensored tier) (refuseless) | openai / openai | https://api.refuseless.com/v1 | https://api.refuseless.com/v1/models | [Source](https://docs.refuseless.com/api/models.md) |
+| Abliteration.ai (the uncensored tier) (abliteration) | openai / openai | https://api.abliteration.ai/v1 | https://api.abliteration.ai/v1/models | [Source](https://docs.abliteration.ai/pricing.md) |
+| OpenRouter (the last fallback) (openrouter) | openai / openai | https://openrouter.ai/api/v1 | https://openrouter.ai/api/v1/models | [Source](https://openrouter.ai/docs/api-reference/overview) |
+| Venice AI (venice) | openai / openai | https://api.venice.ai/api/v1 | https://api.venice.ai/api/v1/models | [Source](https://docs.venice.ai/overview/pricing) |
+| abliteratedmodels.cc (abliteratedmodels) | openai / openai | https://api.abliteratedmodels.cc/v1 | https://api.abliteratedmodels.cc/v1/models | [Source](https://abliteratedmodels.cc/es/) |
+| UplinkSwitch (uplinkswitch) | openai / openai | https://api.uplinkswitch.ai/v1 | https://api.uplinkswitch.ai/v1/models | [Source](https://uplinkswitch.ai/acceptable-use) |
+| Chutes (chutes) | openai / openai | https://llm.chutes.ai/v1 | https://llm.chutes.ai/v1/models | [Source](https://www.chutes.ai/pricing) |
+| SiliconFlow (siliconflow) | openai / openai | https://api.siliconflow.com/v1 | https://api.siliconflow.com/v1/models | [Source](https://docs.siliconflow.com/) |
+| Nebius Token Factory (nebius) | openai / openai | https://api.tokenfactory.nebius.com/v1 | https://api.tokenfactory.nebius.com/v1/models | [Source](https://docs.tokenfactory.nebius.com/quickstart) |
+| Together AI (together) | openai / openai | https://api.together.ai/v1 | https://api.together.ai/v1/models | [Source](https://docs.together.ai/docs/openai-api-compatibility) |
+| Fireworks AI (fireworks) | openai / openai | https://api.fireworks.ai/inference/v1 | https://api.fireworks.ai/inference/v1/models | [Source](https://docs.fireworks.ai/tools-sdks/openai-compatibility) |
+| Groq (groq) | openai / openai | https://api.groq.com/openai/v1 | https://api.groq.com/openai/v1/models | [Source](https://console.groq.com/docs/openai) |
+| Cerebras (cerebras) | openai / openai | https://api.cerebras.ai/v1 | https://api.cerebras.ai/v1/models | [Source](https://inference-docs.cerebras.ai/api-reference/models) |
+| SambaNova (sambanova) | openai / openai | https://api.sambanova.ai/v1 | https://api.sambanova.ai/v1/models | [Source](https://docs-prod.sambanova.ai/docs/api-reference/endpoints/model-list) |
+| Baseten (baseten) | openai / openai | https://inference.baseten.co/v1 | https://inference.baseten.co/v1/models | [Source](https://www.baseten.co/pricing/) |
+| Hyperbolic (hyperbolic) | openai / openai | https://api.hyperbolic.ai/inference/v1 | https://api.hyperbolic.ai/inference/v1/models | [Source](https://docs.hyperbolic.ai/inference/quickstart) |
+| Meta Model API (meta) | openai / openai | https://api.meta.ai/v1 | https://api.meta.ai/v1/models | [Source](https://dev.meta.ai/docs/api-reference) |
+| DeepSeek (deepseek) | openai / openai | https://api.deepseek.com/v1 | https://api.deepseek.com/v1/models | [Source](https://api-docs.deepseek.com/) |
+| Anthropic (anthropic) | anthropic / anthropic | https://api.anthropic.com/v1 | https://api.anthropic.com/v1/models | [Source](https://platform.claude.com/docs/en/api/overview) |
+| OpenAI (openai) | openai / openai | https://api.openai.com/v1 | https://api.openai.com/v1/models | [Source](https://platform.openai.com/docs/api-reference/models/list) |
+| Google Gemini (gemini) | google / openai | https://generativelanguage.googleapis.com/v1beta/openai | https://generativelanguage.googleapis.com/v1beta/openai/models | [Source](https://ai.google.dev/gemini-api/docs/openai) |
+| xAI (xai) | openai / openai | https://api.x.ai/v1 | https://api.x.ai/v1/models | [Source](https://docs.x.ai/docs/api-reference) |
+| Mistral (mistral) | openai / openai | https://api.mistral.ai/v1 | https://api.mistral.ai/v1/models | [Source](https://docs.mistral.ai/api) |
+| Cohere (cohere) | openai / openai | https://api.cohere.com/compatibility/v1 | https://api.cohere.com/v1/models | [Source](https://docs.cohere.com/docs/compatibility-api) |
+| Perplexity (perplexity) | responses / responses | https://api.perplexity.ai/v1 | https://api.perplexity.ai/v1/models | [Source](https://docs.perplexity.ai/docs/agent-api/openai-compatibility) |
+| Brave Search (brave) | adapter / http | https://api.search.brave.com/res/v1 | Not applicable | [Source](https://api-dashboard.search.brave.com/documentation/services/web-search) |
+| Tavily (tavily) | adapter / http | https://api.tavily.com | Not applicable | [Source](https://docs.tavily.com/documentation/api-reference/endpoint/search) |
+| Exa (exa) | adapter / http | https://api.exa.ai | Not applicable | [Source](https://docs.exa.ai/reference/search) |
+| Serper (serper) | adapter / http | https://google.serper.dev | Not applicable | [Source](https://serper.dev/) |
+| Jina (jina) | adapter / http | https://s.jina.ai | Not applicable | [Source](https://jina.ai/reader/) |
+| SearXNG (searxng) | adapter / http | Owner-configured | Not applicable | [Source](https://docs.searxng.org/dev/search_api.html) |
+| Openverse (openverse) | adapter / http | https://api.openverse.org/v1 | Not applicable | [Source](https://api.openverse.org/) |
+| Wikimedia Commons (commons) | adapter / http | https://commons.wikimedia.org/w/api.php | Not applicable | [Source](https://www.mediawiki.org/wiki/API:Search) |
+
+Search capabilities use web-search and image-search, matching the shared placement capability syntax. Brave web and images share one provider/key, as do Serper web and images. Openverse and Wikimedia Commons require no key. SearXNG has no invented public address; the owner supplies their self-hosted base.
+
+For Fable: the existing providers console now receives all seeded provider rows and protocol metadata. Its visual grouping and capability presentation remain for the main session.
@@ -4,20 +4,13 @@
@@ -4,20 +4,13 @@
* is an OpenAI-compatible endpoint unless it says otherwise; prices are not here (rate cards are typed in by a person
* is an OpenAI-compatible endpoint unless it says otherwise; prices are not here (rate cards are typed in by a person
* from the provider's own price page, with the page and the day, on the model form).
* from the provider's own price page, with the page and the day, on the model form).
*/
*/
+const { PROVIDERS } = require('../providers/pool-providers');
const PRESETS = [
const PRESETS = [
- { id: 'deepseek', name: 'DeepSeek', kind: 'openai', base_url: 'https://api.deepseek.com/v1', model: 'deepseek-flash', keys: 'https://platform.deepseek.com/api_keys' },
- { id: 'openrouter', name: 'OpenRouter', kind: 'openai', base_url: 'https://openrouter.ai/api/v1', model: 'openrouter/auto', keys: 'https://openrouter.ai/settings/keys' },
- { id: 'groq', name: 'Groq', kind: 'openai', base_url: 'https://api.groq.com/openai/v1', model: 'openai/gpt-oss-20b', keys: 'https://console.groq.com/keys' },
- { id: 'gemini', name: 'Google Gemini', kind: 'openai', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai', model: '', keys: 'https://aistudio.google.com/apikey' },
- { id: 'mistral', name: 'Mistral', kind: 'openai', base_url: 'https://api.mistral.ai/v1', model: '', keys: 'https://console.mistral.ai/api-keys' },
- { id: 'together', name: 'Together AI', kind: 'openai', base_url: 'https://api.together.xyz/v1', model: '', keys: 'https://api.together.ai/settings/api-keys' },
- { id: 'fireworks', name: 'Fireworks', kind: 'openai', base_url: 'https://api.fireworks.ai/inference/v1', model: '', keys: 'https://fireworks.ai/account/api-keys' },
- { id: 'cerebras', name: 'Cerebras', kind: 'openai', base_url: 'https://api.cerebras.ai/v1', model: '', keys: 'https://cloud.cerebras.ai/' },
- { id: 'xai', name: 'xAI', kind: 'openai', base_url: 'https://api.x.ai/v1', model: '', keys: 'https://console.x.ai/' },
- { id: 'openai', name: 'OpenAI', kind: 'openai', base_url: 'https://api.openai.com/v1', model: 'gpt-5-mini', keys: 'https://platform.openai.com/api-keys' },
- { id: 'anthropic', name: 'Anthropic', kind: 'anthropic', base_url: 'https://api.anthropic.com/v1', model: '', keys: 'https://console.anthropic.com/settings/keys' },
- // Anthropic-format endpoints of providers that also speak the OpenAI format (Codes C3): POST /v1/messages reaches
- // these as they are, an OpenAI-shaped record never is converted.
+ ...PROVIDERS.filter(p => p.capabilities.includes('chat')).map(p => ({
+ id: p.key, name: p.display_name, kind: p.kind, base_url: p.base_url,
+ model: p.credential_default_model ?? p.default_model ?? '', keys: p.keys_url || p.docs_url,
+ })),
+ // Compatibility names for already stored personal credentials; not separate provider rows.
{ id: 'deepseek-anthropic', name: 'DeepSeek (Anthropic format)', kind: 'anthropic', base_url: 'https://api.deepseek.com/anthropic', model: 'deepseek-chat', keys: 'https://platform.deepseek.com/api_keys' },
{ id: 'deepseek-anthropic', name: 'DeepSeek (Anthropic format)', kind: 'anthropic', base_url: 'https://api.deepseek.com/anthropic', model: 'deepseek-chat', keys: 'https://platform.deepseek.com/api_keys' },
{ id: 'minimax-anthropic', name: 'MiniMax (Anthropic format)', kind: 'anthropic', base_url: 'https://api.minimax.io/anthropic', model: 'MiniMax-M2', keys: 'https://platform.minimax.io/user-center/basic-information/interface-key' },
{ id: 'minimax-anthropic', name: 'MiniMax (Anthropic format)', kind: 'anthropic', base_url: 'https://api.minimax.io/anthropic', model: 'MiniMax-M2', keys: 'https://platform.minimax.io/user-center/basic-information/interface-key' },
{ id: 'custom', name: 'Another OpenAI-compatible API', kind: 'openai', base_url: '', model: '', keys: null },
{ id: 'custom', name: 'Another OpenAI-compatible API', kind: 'openai', base_url: '', model: '', keys: null },
@@ -47,9 +47,6 @@
@@ -47,9 +47,6 @@
const CHECKED = '2026-11-06';
const CHECKED = '2026-11-06';
const NEW_CHECKED = '2026-11-09';
const NEW_CHECKED = '2026-11-09';
-/** The keys of the ten providers, in the order the router should prefer them when the price is equal. */
-const PROVIDER_KEYS = ['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'abliteration', 'openrouter', 'venice', 'abliteratedmodels', 'uplinkswitch', 'chutes', 'siliconflow', 'nebius', 'together', 'fireworks', 'groq', 'cerebras', 'sambanova', 'baseten', 'hyperbolic'];
-
/**
/**
* What a provider says it does with a prompt.
* What a provider says it does with a prompt.
*
*
@@ -97,6 +94,7 @@ const privacy = (o) => ({
@@ -97,6 +94,7 @@ const privacy = (o) => ({
const PROVIDERS = [
const PROVIDERS = [
{
{
key: 'qwen',
key: 'qwen',
+ docs_url: 'https://www.alibabacloud.com/help/en/model-studio/compatibility-of-openai-with-dashscope',
display_name: 'Qwen (Alibaba Model Studio)',
display_name: 'Qwen (Alibaba Model Studio)',
base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
default_model: 'qwen3.8-max',
default_model: 'qwen3.8-max',
@@ -148,6 +146,7 @@ const PROVIDERS = [
@@ -148,6 +146,7 @@ const PROVIDERS = [
},
},
{
{
key: 'kimi',
key: 'kimi',
+ docs_url: 'https://platform.moonshot.ai/docs/guide/start-using-kimi-api',
display_name: 'Kimi (Moonshot)',
display_name: 'Kimi (Moonshot)',
base_url: 'https://api.moonshot.ai/v1',
base_url: 'https://api.moonshot.ai/v1',
default_model: 'kimi-k3.1-pro',
default_model: 'kimi-k3.1-pro',
@@ -198,6 +197,7 @@ const PROVIDERS = [
@@ -198,6 +197,7 @@ const PROVIDERS = [
},
},
{
{
key: 'glm',
key: 'glm',
+ docs_url: 'https://docs.z.ai/guides/overview/quick-start',
display_name: 'GLM (Z.ai)',
display_name: 'GLM (Z.ai)',
base_url: 'https://api.z.ai/api/paas/v4',
base_url: 'https://api.z.ai/api/paas/v4',
default_model: 'glm-5.3-flash',
default_model: 'glm-5.3-flash',
@@ -258,6 +258,7 @@ const PROVIDERS = [
@@ -258,6 +258,7 @@ const PROVIDERS = [
},
},
{
{
key: 'minimax',
key: 'minimax',
+ docs_url: 'https://platform.minimax.io/docs/api-reference/text-openai-api',
display_name: 'MiniMax',
display_name: 'MiniMax',
base_url: 'https://api.minimax.io/v1',
base_url: 'https://api.minimax.io/v1',
default_model: 'MiniMax-M3.1-Flash',
default_model: 'MiniMax-M3.1-Flash',
@@ -296,6 +297,7 @@ const PROVIDERS = [
@@ -296,6 +297,7 @@ const PROVIDERS = [
},
},
{
{
key: 'deepinfra',
key: 'deepinfra',
+ docs_url: 'https://deepinfra.com/docs/openai_api',
display_name: 'DeepInfra',
display_name: 'DeepInfra',
base_url: 'https://api.deepinfra.com/v1/openai',
base_url: 'https://api.deepinfra.com/v1/openai',
default_model: 'deepseek-ai/DeepSeek-V4.1-Flash-1017',
default_model: 'deepseek-ai/DeepSeek-V4.1-Flash-1017',
@@ -369,8 +371,9 @@ const PROVIDERS = [
@@ -369,8 +371,9 @@ const PROVIDERS = [
},
},
{
{
key: 'novita',
key: 'novita',
+ docs_url: 'https://novita.ai/docs/api-reference/model-apis-llm-list-models',
display_name: 'Novita AI',
display_name: 'Novita AI',
- base_url: 'https://api.novita.ai/v3/openai',
+ base_url: 'https://api.novita.ai/openai',
default_model: 'deepseek/deepseek-v4.1-flash-1017',
default_model: 'deepseek/deepseek-v4.1-flash-1017',
timeout_ms: 90000,
timeout_ms: 90000,
capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
@@ -378,13 +381,13 @@ const PROVIDERS = [
@@ -378,13 +381,13 @@ const PROVIDERS = [
priority: 30,
priority: 30,
privacy: privacy({
privacy: privacy({
label: 'anonymized',
label: 'anonymized',
- terms_url: 'https://api.novita.ai/v3/openai/models',
+ terms_url: 'https://api.novita.ai/openai/models',
wording: 'Novita has no readable privacy policy page (the address returns a not-found page on 2026-11-06), so nothing says zero retention or no training',
wording: 'Novita has no readable privacy policy page (the address returns a not-found page on 2026-11-06), so nothing says zero retention or no training',
}),
}),
data_policy: policy({
data_policy: policy({
trains: null, retention: null, region: null,
trains: null, retention: null, region: null,
retention_note: 'not stated (the provider has no readable privacy page)',
retention_note: 'not stated (the provider has no readable privacy page)',
- terms: 'https://api.novita.ai/v3/openai/models',
+ terms: 'https://api.novita.ai/openai/models',
resale: 'UNVERIFIED (2026-11-03 matrix): the terms page returned 404',
resale: 'UNVERIFIED (2026-11-03 matrix): the terms page returned 404',
}),
}),
@@ -392,7 +395,7 @@ const PROVIDERS = [
@@ -392,7 +395,7 @@ const PROVIDERS = [
{
{
model_key: 'deepseek/deepseek-v4.1-flash-1017', display_name: 'DeepSeek V4.1 Flash 1017', family: 'deepseek', context_window: 1048576,
model_key: 'deepseek/deepseek-v4.1-flash-1017', display_name: 'DeepSeek V4.1 Flash 1017', family: 'deepseek', context_window: 1048576,
cost: { in_per_mtok: 0.30, cached_per_mtok: 0.006, out_per_mtok: 1.20 },
cost: { in_per_mtok: 0.30, cached_per_mtok: 0.006, out_per_mtok: 1.20 },
- source: 'https://api.novita.ai/v3/openai/models', checked: CHECKED,
+ source: 'https://api.novita.ai/openai/models', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
supports: { json: true, tools: true, streaming: true, vision: false },
quantization: null, refusal_rate: null,
quantization: null, refusal_rate: null,
note: 'the list marks the model as serverless with function calling, structured outputs and reasoning',
note: 'the list marks the model as serverless with function calling, structured outputs and reasoning',
@@ -400,28 +403,28 @@ const PROVIDERS = [
@@ -400,28 +403,28 @@ const PROVIDERS = [
{
{
model_key: 'qwen/qwen3.8-max', display_name: 'Qwen3.8-Max', family: 'qwen', context_window: 1000000,
model_key: 'qwen/qwen3.8-max', display_name: 'Qwen3.8-Max', family: 'qwen', context_window: 1000000,
cost: { in_per_mtok: 2.00, cached_per_mtok: 0.25, out_per_mtok: 6.00 },
cost: { in_per_mtok: 2.00, cached_per_mtok: 0.25, out_per_mtok: 6.00 },
- source: 'https://api.novita.ai/v3/openai/models', checked: CHECKED,
+ source: 'https://api.novita.ai/openai/models', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
supports: { json: true, tools: true, streaming: true, vision: false },
quantization: null, refusal_rate: null,
quantization: null, refusal_rate: null,
},
},
{
{
model_key: 'zai-org/glm-5.3', display_name: 'GLM-5.3', family: 'glm', context_window: 1048576,
model_key: 'zai-org/glm-5.3', display_name: 'GLM-5.3', family: 'glm', context_window: 1048576,
cost: { in_per_mtok: 1.40, cached_per_mtok: 0.26, out_per_mtok: 4.40 },
cost: { in_per_mtok: 1.40, cached_per_mtok: 0.26, out_per_mtok: 4.40 },
- source: 'https://api.novita.ai/v3/openai/models', checked: CHECKED,
+ source: 'https://api.novita.ai/openai/models', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
supports: { json: true, tools: true, streaming: true, vision: false },
quantization: null, refusal_rate: null,
quantization: null, refusal_rate: null,
},
},
{
{
model_key: 'moonshotai/kimi-k3.1-pro', display_name: 'Kimi K3.1 Pro', family: 'kimi', context_window: 1048576,
model_key: 'moonshotai/kimi-k3.1-pro', display_name: 'Kimi K3.1 Pro', family: 'kimi', context_window: 1048576,
cost: { in_per_mtok: 2.00, cached_per_mtok: 0.20, out_per_mtok: 10.00 },
cost: { in_per_mtok: 2.00, cached_per_mtok: 0.20, out_per_mtok: 10.00 },
- source: 'https://api.novita.ai/v3/openai/models', checked: CHECKED,
+ source: 'https://api.novita.ai/openai/models', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
supports: { json: true, tools: true, streaming: true, vision: false },
quantization: null, refusal_rate: null,
quantization: null, refusal_rate: null,
},
},
{
{
model_key: 'xiaomimimo/mimo-v2.6-flash', display_name: 'MiMo V2.6 Flash', family: 'mimo', context_window: 1048576,
model_key: 'xiaomimimo/mimo-v2.6-flash', display_name: 'MiMo V2.6 Flash', family: 'mimo', context_window: 1048576,
cost: { in_per_mtok: 0.14, cached_per_mtok: 0.0028, out_per_mtok: 0.28 },
cost: { in_per_mtok: 0.14, cached_per_mtok: 0.0028, out_per_mtok: 0.28 },
- source: 'https://api.novita.ai/v3/openai/models', checked: CHECKED,
+ source: 'https://api.novita.ai/openai/models', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
supports: { json: true, tools: true, streaming: true, vision: false },
quantization: null, refusal_rate: null,
quantization: null, refusal_rate: null,
},
},
@@ -612,7 +615,8 @@ const PROVIDERS = [
@@ -612,7 +615,8 @@ const PROVIDERS = [
],
],
},
},
{
{
- key: 'openrouter',
+ key: 'openrouter', keys_url: 'https://openrouter.ai/settings/keys',
+ docs_url: 'https://openrouter.ai/docs/api-reference/overview',
display_name: 'OpenRouter (the last fallback)',
display_name: 'OpenRouter (the last fallback)',
base_url: 'https://openrouter.ai/api/v1',
base_url: 'https://openrouter.ai/api/v1',
default_model: 'openrouter/auto',
default_model: 'openrouter/auto',
@@ -642,18 +646,18 @@ const PROVIDERS = [
@@ -642,18 +646,18 @@ const PROVIDERS = [
['uplinkswitch', 'UplinkSwitch', 'https://api.uplinkswitch.ai/v1', 'abliterated', 'https://uplinkswitch.ai/acceptable-use', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, null, true],
['uplinkswitch', 'UplinkSwitch', 'https://api.uplinkswitch.ai/v1', 'abliterated', 'https://uplinkswitch.ai/acceptable-use', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, null, true],
['chutes', 'Chutes', 'https://llm.chutes.ai/v1', 'Qwen/Qwen3-32B-TEE', 'https://www.chutes.ai/pricing', 'UNKNOWN', 'UNVERIFIED', { in: 0.104, cached: null, out: 0.416 }, 40960, false],
['chutes', 'Chutes', 'https://llm.chutes.ai/v1', 'Qwen/Qwen3-32B-TEE', 'https://www.chutes.ai/pricing', 'UNKNOWN', 'UNVERIFIED', { in: 0.104, cached: null, out: 0.416 }, 40960, false],
['siliconflow', 'SiliconFlow', 'https://api.siliconflow.com/v1', 'Qwen/Qwen3-32B', 'https://docs.siliconflow.com/', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, null, false],
['siliconflow', 'SiliconFlow', 'https://api.siliconflow.com/v1', 'Qwen/Qwen3-32B', 'https://docs.siliconflow.com/', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, null, false],
- ['nebius', 'Nebius Token Factory', 'https://api.tokenfactory.nebius.com/v1', 'Qwen/Qwen3-30B-A3B-Instruct-2507', 'https://nebius.com/services/token-factory/models/qwen-models-inference', 'UNKNOWN', 'UNVERIFIED', { in: 0.10, cached: null, out: 0.30 }, 262144, false],
- ['together', 'Together AI', 'https://api.together.xyz/v1', 'openai/gpt-oss-120b', 'https://www.together.ai/pricing', 'UNKNOWN', 'RESTRICTED', { in: 0.15, cached: null, out: 0.60 }, 131072, false],
- ['fireworks', 'Fireworks AI', 'https://api.fireworks.ai/inference/v1', 'accounts/fireworks/models/gpt-oss-120b', 'https://fireworks.ai/models', 'UNKNOWN', 'UNVERIFIED', { in: 0.15, cached: null, out: 0.60 }, 131072, false],
- ['groq', 'Groq', 'https://api.groq.com/openai/v1', 'openai/gpt-oss-120b', 'https://console.groq.com/docs/model/openai/gpt-oss-120b', 'UNKNOWN', 'UNVERIFIED', { in: 0.15, cached: 0.075, out: 0.60 }, 131072, false],
- ['cerebras', 'Cerebras', 'https://api.cerebras.ai/v1', 'gpt-oss-120b', 'https://api.cerebras.ai/public/v1/models', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, 131072, false],
- ['sambanova', 'SambaNova', 'https://api.sambanova.ai/v1', 'gpt-oss-120b', 'https://cloud.sambanova.ai/plans/pricing', 'UNKNOWN', 'UNVERIFIED', { in: 0.22, cached: null, out: 0.59 }, 131072, false],
+ ['nebius', 'Nebius Token Factory', 'https://api.tokenfactory.nebius.com/v1', 'Qwen/Qwen3-30B-A3B-Instruct-2507', 'https://nebius.com/services/token-factory/models/qwen-models-inference', 'UNKNOWN', 'UNVERIFIED', { in: 0.10, cached: null, out: 0.30 }, 262144, false, 'https://docs.tokenfactory.nebius.com/quickstart'],
+ ['together', 'Together AI', 'https://api.together.ai/v1', 'openai/gpt-oss-120b', 'https://www.together.ai/pricing', 'UNKNOWN', 'RESTRICTED', { in: 0.15, cached: null, out: 0.60 }, 131072, false, 'https://docs.together.ai/docs/openai-api-compatibility', 'https://api.together.ai/settings/api-keys', ''],
+ ['fireworks', 'Fireworks AI', 'https://api.fireworks.ai/inference/v1', 'accounts/fireworks/models/gpt-oss-120b', 'https://fireworks.ai/models', 'UNKNOWN', 'UNVERIFIED', { in: 0.15, cached: null, out: 0.60 }, 131072, false, 'https://docs.fireworks.ai/tools-sdks/openai-compatibility', 'https://fireworks.ai/account/api-keys', ''],
+ ['groq', 'Groq', 'https://api.groq.com/openai/v1', 'openai/gpt-oss-120b', 'https://console.groq.com/docs/model/openai/gpt-oss-120b', 'UNKNOWN', 'UNVERIFIED', { in: 0.15, cached: 0.075, out: 0.60 }, 131072, false, 'https://console.groq.com/docs/openai', 'https://console.groq.com/keys', 'openai/gpt-oss-20b'],
+ ['cerebras', 'Cerebras', 'https://api.cerebras.ai/v1', 'gpt-oss-120b', 'https://api.cerebras.ai/public/v1/models', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, 131072, false, 'https://inference-docs.cerebras.ai/api-reference/models', 'https://cloud.cerebras.ai/', ''],
+ ['sambanova', 'SambaNova', 'https://api.sambanova.ai/v1', 'gpt-oss-120b', 'https://cloud.sambanova.ai/plans/pricing', 'UNKNOWN', 'UNVERIFIED', { in: 0.22, cached: null, out: 0.59 }, 131072, false, 'https://docs-prod.sambanova.ai/docs/api-reference/endpoints/model-list'],
['baseten', 'Baseten', 'https://inference.baseten.co/v1', 'openai/gpt-oss-120b', 'https://www.baseten.co/pricing/', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, 131072, false],
['baseten', 'Baseten', 'https://inference.baseten.co/v1', 'openai/gpt-oss-120b', 'https://www.baseten.co/pricing/', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, 131072, false],
- ['hyperbolic', 'Hyperbolic', 'https://api.hyperbolic.xyz/v1', 'openai/gpt-oss-120b', 'https://docs.hyperbolic.xyz/docs/hyperbolic-ai-inference-pricing', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, 131072, false],
- ].map(([key, display_name, base_url, model_key, source, privacyLabel, provider_access, prices, context_window, uncensored]) => {
+ ['hyperbolic', 'Hyperbolic', 'https://api.hyperbolic.ai/inference/v1', 'openai/gpt-oss-120b', 'https://docs.hyperbolic.xyz/docs/hyperbolic-ai-inference-pricing', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, 131072, false, 'https://docs.hyperbolic.ai/inference/quickstart'],
+ ].map(([key, display_name, base_url, model_key, source, privacyLabel, provider_access, prices, context_window, uncensored, docs_url, keys_url, credential_default_model]) => {
const privateTier = privacyLabel === 'PRIVATE';
const privateTier = privacyLabel === 'PRIVATE';
return {
return {
- key, display_name, base_url, default_model: model_key, timeout_ms: 60000,
+ key, display_name, base_url, docs_url, keys_url, credential_default_model, default_model: model_key, timeout_ms: 60000,
capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
provider_access, priority: 40, ...(uncensored ? { uncensored: true } : {}),
provider_access, priority: 40, ...(uncensored ? { uncensored: true } : {}),
privacy: privacy({ label: privateTier ? 'private' : 'anonymized', terms_url: privateTier ? source : null,
privacy: privacy({ label: privateTier ? 'private' : 'anonymized', terms_url: privateTier ? source : null,
@@ -672,8 +676,62 @@ const PROVIDERS = [
@@ -672,8 +676,62 @@ const PROVIDERS = [
],
],
};
};
}),
}),
+ // Providers without a hand-kept model list: the owner syncs and enables their models.
+ { key: 'meta', display_name: 'Meta Model API', base_url: 'https://api.meta.ai/v1',
+ docs_url: 'https://dev.meta.ai/docs/api-reference', capabilities: ['chat', 'vision', 'tools'] },
+ { key: 'deepseek', keys_url: 'https://platform.deepseek.com/api_keys', display_name: 'DeepSeek', base_url: 'https://api.deepseek.com/v1', default_model: 'deepseek-flash',
+ docs_url: 'https://api-docs.deepseek.com/', capabilities: ['chat', 'vision', 'tools', 'web-search'], search_adapters: ['deepseek'] },
+ { key: 'anthropic', keys_url: 'https://console.anthropic.com/settings/keys', display_name: 'Anthropic', kind: 'anthropic', base_url: 'https://api.anthropic.com/v1', auth_mode: 'x-api-key',
+ docs_url: 'https://platform.claude.com/docs/en/api/overview', capabilities: ['chat', 'messages', 'vision', 'tools'] },
+ { key: 'openai', keys_url: 'https://platform.openai.com/api-keys', display_name: 'OpenAI', base_url: 'https://api.openai.com/v1', default_model: 'gpt-5-mini',
+ docs_url: 'https://platform.openai.com/docs/api-reference/models/list', capabilities: ['chat', 'vision', 'tools', 'image', 'video', 'speech', 'transcribe', 'embed'] },
+ { key: 'gemini', keys_url: 'https://aistudio.google.com/apikey', display_name: 'Google Gemini', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',
+ api_family: 'google', docs_url: 'https://ai.google.dev/gemini-api/docs/openai', capabilities: ['chat', 'vision', 'tools', 'embed'] },
+ { key: 'xai', keys_url: 'https://console.x.ai/', display_name: 'xAI', base_url: 'https://api.x.ai/v1',
+ docs_url: 'https://docs.x.ai/docs/api-reference', capabilities: ['chat', 'vision', 'tools', 'image', 'video'] },
+ { key: 'mistral', keys_url: 'https://console.mistral.ai/api-keys', display_name: 'Mistral', base_url: 'https://api.mistral.ai/v1',
+ docs_url: 'https://docs.mistral.ai/api', capabilities: ['chat', 'vision', 'tools', 'embed', 'transcribe'] },
+ { key: 'cohere', display_name: 'Cohere', base_url: 'https://api.cohere.com/compatibility/v1', models_endpoint: 'https://api.cohere.com/v1/models',
+ docs_url: 'https://docs.cohere.com/docs/compatibility-api', capabilities: ['chat', 'tools', 'embed', 'rerank'] },
+ { key: 'perplexity', display_name: 'Perplexity', kind: 'responses', base_url: 'https://api.perplexity.ai/v1',
+ docs_url: 'https://docs.perplexity.ai/docs/agent-api/openai-compatibility', capabilities: ['chat', 'web-search'], search_adapters: ['perplexity'] },
+ { key: 'brave', display_name: 'Brave Search', kind: 'http', base_url: 'https://api.search.brave.com/res/v1',
+ auth_header: 'x-subscription-token', models_endpoint: null, capabilities: ['web-search', 'image-search'],
+ search_adapters: ['brave', 'brave-images'], docs_url: 'https://api-dashboard.search.brave.com/documentation/services/web-search' },
+ { key: 'tavily', display_name: 'Tavily', kind: 'http', base_url: 'https://api.tavily.com', models_endpoint: null,
+ capabilities: ['web-search'], search_adapters: ['tavily'], docs_url: 'https://docs.tavily.com/documentation/api-reference/endpoint/search' },
+ { key: 'exa', display_name: 'Exa', kind: 'http', base_url: 'https://api.exa.ai', auth_mode: 'x-api-key', models_endpoint: null,
+ capabilities: ['web-search'], search_adapters: ['exa'], docs_url: 'https://docs.exa.ai/reference/search' },
+ { key: 'serper', display_name: 'Serper', kind: 'http', base_url: 'https://google.serper.dev', auth_mode: 'x-api-key', models_endpoint: null,
+ capabilities: ['web-search', 'image-search'], search_adapters: ['serper', 'serper-images'], docs_url: 'https://serper.dev/' },
+ { key: 'jina', display_name: 'Jina', kind: 'http', base_url: 'https://s.jina.ai', models_endpoint: null,
+ capabilities: ['web-search'], search_adapters: ['jina'], docs_url: 'https://jina.ai/reader/' },
+ { key: 'searxng', display_name: 'SearXNG', kind: 'http', base_url: null, auth_mode: 'none', models_endpoint: null,
+ capabilities: ['web-search'], search_adapters: ['searxng'], docs_url: 'https://docs.searxng.org/dev/search_api.html' },
+ { key: 'openverse', display_name: 'Openverse', kind: 'http', base_url: 'https://api.openverse.org/v1', auth_mode: 'none', models_endpoint: null,
+ capabilities: ['image-search'], search_adapters: ['openverse'], docs_url: 'https://api.openverse.org/' },
+ { key: 'commons', display_name: 'Wikimedia Commons', kind: 'http', base_url: 'https://commons.wikimedia.org/w/api.php', auth_mode: 'none', models_endpoint: null,
+ capabilities: ['image-search'], search_adapters: ['commons'], docs_url: 'https://www.mediawiki.org/wiki/API:Search' },
+
];
];
+// The seed, console and personal-key forms consume these same records.
+for (const p of PROVIDERS) {
+ if (p.capabilities.includes('chat')) p.capabilities = [...new Set([...p.capabilities, 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json'])];
+ p.kind ||= 'openai';
+ p.auth_mode ||= 'bearer';
+ p.api_family ||= p.kind === 'http' ? 'adapter' : p.kind;
+ if (p.models_endpoint === undefined) p.models_endpoint = p.base_url ? `${p.base_url}/models` : null;
+ p.models ||= [];
+ p.provider_access ||= 'UNVERIFIED';
+ p.priority ??= 100;
+ p.timeout_ms ||= 30000;
+ p.docs_url ||= p.models[0]?.source || null;
+ p.data_policy ||= policy({ checked: '2026-11-10' });
+ p.privacy ||= privacy({ label: 'anonymized', wording: 'Data retention and training terms have not been verified.', checked: '2026-11-10' });
+}
+const PROVIDER_KEYS = PROVIDERS.map(p => p.key);
+
const byKey = new Map(PROVIDERS.map((p) => [p.key, p]));
const byKey = new Map(PROVIDERS.map((p) => [p.key, p]));
/** The name a provider's model is known by, without its namespace: `deepseek/deepseek-chat` -> `deepseek-chat`. */
/** The name a provider's model is known by, without its namespace: `deepseek/deepseek-chat` -> `deepseek-chat`. */
@@ -17,16 +17,20 @@
@@ -17,16 +17,20 @@
*/
*/
const pool = require('./pool-providers');
const pool = require('./pool-providers');
+const { providerSecret } = require('../util');
/** The registry row of one catalogued provider. */
/** The registry row of one catalogued provider. */
function recordOf(p) {
function recordOf(p) {
return {
return {
- key: p.key, display_name: p.display_name, kind: 'openai', status: 'active',
- base_url: p.base_url, auth_mode: 'bearer', secret_ref: `vault:${p.key}`,
+ key: p.key, display_name: p.display_name, kind: p.kind, status: 'disabled',
+ base_url: p.base_url, auth_mode: p.auth_mode, secret_ref: p.auth_mode === 'none' ? null : `vault:${p.key}`,
default_model: p.default_model || null, capabilities: p.capabilities, timeout_ms: p.timeout_ms,
default_model: p.default_model || null, capabilities: p.capabilities, timeout_ms: p.timeout_ms,
priority: p.priority, billing_profile: 'metered', provider_access: p.provider_access,
priority: p.priority, billing_profile: 'metered', provider_access: p.provider_access,
metadata: {
metadata: {
catalog: 'pool-providers',
catalog: 'pool-providers',
+ api_family: p.api_family, models_endpoint: p.models_endpoint,
+ auth_header: p.auth_header || null, docs_url: p.docs_url || null,
+ search_adapters: p.search_adapters || [],
data_policy: p.data_policy,
data_policy: p.data_policy,
// The privacy label of the provider: `private` only where its own terms say it keeps nothing and trains on
// The privacy label of the provider: `private` only where its own terms say it keeps nothing and trains on
// nothing, `anonymized` for everyone else. The wording and the page it was read from travel with it.
// nothing, `anonymized` for everyone else. The wording and the page it was read from travel with it.
@@ -83,7 +87,7 @@ function cataloguePrice(row) {
@@ -83,7 +87,7 @@ function cataloguePrice(row) {
*
*
* → { providers: [key], models: [key], skipped: [key] }
* → { providers: [key], models: [key], skipped: [key] }
*/
*/
-async function seedPoolProviders({ registry, actor = 'system' }) {
+async function seedPoolProviders({ registry, actor = 'system', env = process.env }) {
const out = { providers: [], models: [], skipped: [] };
const out = { providers: [], models: [], skipped: [] };
for (const p of pool.PROVIDERS) {
for (const p of pool.PROVIDERS) {
const prev = await registry.getProvider(p.key);
const prev = await registry.getProvider(p.key);
@@ -91,16 +95,30 @@ async function seedPoolProviders({ registry, actor = 'system' }) {
@@ -91,16 +95,30 @@ async function seedPoolProviders({ registry, actor = 'system' }) {
await registry.upsertProvider(recordOf(p), { actor, origin: 'catalog' });
await registry.upsertProvider(recordOf(p), { actor, origin: 'catalog' });
out.providers.push(p.key);
out.providers.push(p.key);
} else if (prev.origin === 'catalog') {
} else if (prev.origin === 'catalog') {
- // The catalogue owns this row: keep the owner's status and key slot, refresh the rest.
+ const defaults = recordOf(p);
+ const configured = prev.auth_mode === 'none' || Boolean(providerSecret(prev, env));
+ if (configured && prev.base_url !== p.base_url) defaults.metadata.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;
+ const metadata = { ...defaults.metadata, ...prev.metadata };
+ if (!configured && prev.base_url !== p.base_url) {
+ metadata.models_endpoint = defaults.metadata.models_endpoint;
+ metadata.api_family = defaults.metadata.api_family;
+ }
+ // A key is bound to its origin. A documentation refresh must never move a working
+ // provider or change its wire format; endpoint changes are explicit owner edits.
await registry.upsertProvider({
await registry.upsertProvider({
- ...recordOf(p),
- status: prev.status,
- secret_ref: prev.secret_ref || `vault:${p.key}`,
+ ...defaults,
+ ...(configured ? { base_url: prev.base_url, kind: prev.kind, auth_mode: prev.auth_mode } : {}),
+ status: prev.status === 'active' && !configured ? 'disabled' : prev.status,
+ secret_ref: prev.secret_ref,
provider_access: prev.provider_access || p.provider_access,
provider_access: prev.provider_access || p.provider_access,
- metadata: { ...prev.metadata, ...recordOf(p).metadata },
+ metadata,
}, { actor, origin: 'catalog' });
}, { actor, origin: 'catalog' });
} else {
} else {
- out.skipped.push(p.key); // the owner's own provider: never touched
+ out.skipped.push(p.key);
+ const protocol = recordOf(p).metadata;
+ if (prev.base_url !== p.base_url) protocol.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;
+ const additions = Object.fromEntries(Object.entries(protocol).filter(([k]) => ['api_family', 'models_endpoint', 'auth_header', 'docs_url', 'search_adapters'].includes(k) && prev.metadata[k] === undefined));
+ if (Object.keys(additions).length) await registry.upsertProvider({ key: p.key, metadata: { ...prev.metadata, ...additions } }, { actor, origin: prev.origin });
}
}
for (const m of p.models) {
for (const m of p.models) {
const row = await registry.getModel(p.key, m.model_key);
const row = await registry.getModel(p.key, m.model_key);
@@ -17,7 +17,7 @@ const PROVIDER_KINDS = ['stub', 'openai', 'responses', 'anthropic', 'http', 'whi
@@ -17,7 +17,7 @@ const PROVIDER_KINDS = ['stub', 'openai', 'responses', 'anthropic', 'http', 'whi
const BILLING_PROFILES = ['metered', 'subscription', 'payg', 'free', 'byok'];
const BILLING_PROFILES = ['metered', 'subscription', 'payg', 'free', 'byok'];
// AI Exchange D3: who may resell this offer's capacity. Defaults to UNVERIFIED (migrations/0024).
// AI Exchange D3: who may resell this offer's capacity. Defaults to UNVERIFIED (migrations/0024).
const PROVIDER_ACCESS = ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'];
const PROVIDER_ACCESS = ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'];
-const FEATURES = ['chat', 'complete', 'messages', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'tools', 'transcribe', 'image'];
+const FEATURES = ['chat', 'complete', 'messages', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'tools', 'transcribe', 'image', 'video', 'speech', 'rerank', 'web-search', 'image-search'];
const LIFECYCLE = ['draft', 'active', 'deprecated', 'archived'];
const LIFECYCLE = ['draft', 'active', 'deprecated', 'archived'];
const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;
const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;
const flag = (v) => v === true || v === 1 || /^(1|true|yes|on)$/i.test(String(v == null ? '' : v));
const flag = (v) => v === true || v === 1 || /^(1|true|yes|on)$/i.test(String(v == null ? '' : v));
@@ -147,7 +147,7 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
@@ -147,7 +147,7 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
// DeepInfra, Novita, Featherless, Refuseless, Abliteration.ai and OpenRouter. Each is registered off until its key
// DeepInfra, Novita, Featherless, Refuseless, Abliteration.ai and OpenRouter. Each is registered off until its key
// is in the owner settings; the pool skips one whose key does not resolve. Prices come from each provider's own
// is in the owner settings; the pool skips one whose key does not resolve. Prices come from each provider's own
// page, with that page and the day they were read (server/providers/pool-providers.js).
// page, with that page and the day they were read (server/providers/pool-providers.js).
- await poolSeed.seedPoolProviders({ registry, actor: 'seed' });
+ await poolSeed.seedPoolProviders({ registry, actor: 'seed', env });
const shared = await registry.getProvider('shared');
const shared = await registry.getProvider('shared');
// Models
// Models
@@ -44,11 +44,20 @@ const iso = (n) => new Date(n).toISOString();
@@ -44,11 +44,20 @@ const iso = (n) => new Date(n).toISOString();
const CHAT = { messages: [{ role: 'user', content: 'x' }], timeoutMs: 3000 };
const CHAT = { messages: [{ role: 'user', content: 'x' }], timeoutMs: 3000 };
t.test('provider presets are OpenAI-compatible, with a base URL, a timeout, an access mode and a privacy label', () => {
t.test('provider presets are OpenAI-compatible, with a base URL, a timeout, an access mode and a privacy label', () => {
- assert.deepStrictEqual(pool.PROVIDERS.map((p) => p.key),
+ assert.deepStrictEqual(pool.PROVIDERS.slice(0, 23).map((p) => p.key),
['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'abliteration', 'openrouter', 'venice', 'abliteratedmodels', 'uplinkswitch', 'chutes', 'siliconflow', 'nebius', 'together', 'fireworks', 'groq', 'cerebras', 'sambanova', 'baseten', 'hyperbolic']);
['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'abliteration', 'openrouter', 'venice', 'abliteratedmodels', 'uplinkswitch', 'chutes', 'siliconflow', 'nebius', 'together', 'fireworks', 'groq', 'cerebras', 'sambanova', 'baseten', 'hyperbolic']);
assert.deepStrictEqual(pool.PROVIDER_KEYS, pool.PROVIDERS.map((p) => p.key));
assert.deepStrictEqual(pool.PROVIDER_KEYS, pool.PROVIDERS.map((p) => p.key));
+ assert.strictEqual(new Set(pool.PROVIDER_KEYS).size, 40);
+ assert.strictEqual(pool.byKey.get('meta').base_url, 'https://api.meta.ai/v1');
+ assert.strictEqual(pool.byKey.get('perplexity').kind, 'responses');
+ assert.strictEqual(require('../server/console/presets').credentialProvider('groq').model, 'openai/gpt-oss-20b', 'existing personal-key default is preserved');
+ assert.deepStrictEqual(pool.byKey.get('brave').search_adapters, ['brave', 'brave-images']);
+ assert.strictEqual(pool.byKey.get('openverse').auth_mode, 'none');
+ assert.strictEqual(pool.byKey.get('commons').auth_mode, 'none');
for (const p of pool.PROVIDERS) {
for (const p of pool.PROVIDERS) {
- assert.ok(/^https:\/\//.test(p.base_url), `${p.key} base_url`);
+ assert.ok(p.key === 'searxng' && p.base_url === null || /^https:\/\//.test(p.base_url), `${p.key} base_url`);
+ assert.strictEqual(seed.recordOf(p).status, 'disabled', `${p.key} starts off`);
+ assert.ok(p.api_family && Object.hasOwn(p, 'models_endpoint'), `${p.key} has protocol metadata`);
assert.ok(p.timeout_ms >= 1000, `${p.key} timeout`);
assert.ok(p.timeout_ms >= 1000, `${p.key} timeout`);
assert.ok(['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'].includes(p.provider_access), p.key);
assert.ok(['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'].includes(p.provider_access), p.key);
assert.ok(p.data_policy, `${p.key} data policy`);
assert.ok(p.data_policy, `${p.key} data policy`);
@@ -142,8 +151,32 @@ t.test('the seed writes the privacy label, the quantization and the refusal rate
@@ -142,8 +151,32 @@ t.test('the seed writes the privacy label, the quantization and the refusal rate
assert.strictEqual(saved.get('qwen').base_url, 'https://proxy.example/v1');
assert.strictEqual(saved.get('qwen').base_url, 'https://proxy.example/v1');
});
});
+t.test('seed refreshes unused endpoints while preserving configured and owner-managed bindings', async () => {
+ const source = pool.byKey.get('hyperbolic');
+ const records = new Map([['hyperbolic', {
+ ...seed.recordOf(source), status: 'active', origin: 'catalog',
+ base_url: 'https://legacy.example/v1', secret_ref: 'env:REGISTRY_TEST_KEY', metadata: {},
+ }]]);
+ const registry = {
+ getProvider: async key => records.get(key) || null,
+ upsertProvider: async (row, options) => records.set(row.key, { ...records.get(row.key), ...row, origin: options.origin }),
+ getModel: async () => null, upsertModel: async () => {},
+ };
+ await seed.seedPoolProviders({ registry, env: { REGISTRY_TEST_KEY: 'fixture-provider-credential' } });
+ assert.strictEqual(records.get('hyperbolic').base_url, 'https://legacy.example/v1');
+ assert.strictEqual(records.get('hyperbolic').status, 'active');
+ await seed.seedPoolProviders({ registry, env: {} });
+ assert.strictEqual(records.get('hyperbolic').base_url, source.base_url);
+ assert.strictEqual(records.get('hyperbolic').metadata.models_endpoint, source.models_endpoint);
+ assert.strictEqual(records.get('hyperbolic').status, 'disabled');
+ records.set('hyperbolic', { ...records.get('hyperbolic'), origin: 'admin', base_url: 'https://owner.example/v1', status: 'active' });
+ await seed.seedPoolProviders({ registry, env: {} });
+ assert.strictEqual(records.get('hyperbolic').base_url, 'https://owner.example/v1');
+ assert.strictEqual(records.get('hyperbolic').status, 'active');
+});
+
t.test('the recorded shape of every provider is parsed by the OpenAI adapter, tool calls included', async () => {
t.test('the recorded shape of every provider is parsed by the OpenAI adapter, tool calls included', async () => {
- for (const key of pool.PROVIDER_KEYS) {
+ for (const key of pool.PROVIDERS.filter(p => p.kind === 'openai').map(p => p.key)) {
const recorded = fixtures[key] || fixtures.openrouter;
const recorded = fixtures[key] || fixtures.openrouter;
assert.ok(recorded && recorded.completion, `${key} fixture`);
assert.ok(recorded && recorded.completion, `${key} fixture`);
const srv = await seamServer(() => recorded.completion);
const srv = await seamServer(() => recorded.completion);
@@ -165,7 +198,7 @@ t.test('the recorded shape of every provider is parsed by the OpenAI adapter, to
@@ -165,7 +198,7 @@ t.test('the recorded shape of every provider is parsed by the OpenAI adapter, to
t.test('a catalogue offer with no key is off: the pool skips it no_credentials', async () => {
t.test('a catalogue offer with no key is off: the pool skips it no_credentials', async () => {
// The commercial gate (an UNVERIFIED offer) keeps most of the catalogue out of the managed pool; the
// The commercial gate (an UNVERIFIED offer) keeps most of the catalogue out of the managed pool; the
// credential gate is what this test is about, so the row carries the access mode the owner would grant.
// credential gate is what this test is about, so the row carries the access mode the owner would grant.
- const row = { ...seed.recordOf(pool.byKey.get('qwen')), provider_access: 'OWN_DEPLOYMENT', origin: 'catalog' };
+ const row = { ...seed.recordOf(pool.byKey.get('qwen')), status: 'active', provider_access: 'OWN_DEPLOYMENT', origin: 'catalog' };
const registry = {
const registry = {
getProvider: async (k) => (k === 'qwen' ? row : null),
getProvider: async (k) => (k === 'qwen' ? row : null),
listProviders: async () => [row],
listProviders: async () => [row],
@@ -200,14 +233,15 @@ t.test('a provider that says the account has no money left is exhaustion, and it
@@ -200,14 +233,15 @@ t.test('a provider that says the account has no money left is exhaustion, and it
assert.strictEqual(insufficientBalance(new ProviderError('the backend fell over', { status: 500, code: 'provider.http', body: {} })), false);
assert.strictEqual(insufficientBalance(new ProviderError('the backend fell over', { status: 500, code: 'provider.http', body: {} })), false);
});
});
-t.test('a booted service seeds the ten offers, each with its own key slot', async () => {
+t.test('a booted service seeds the registry and preserves the existing environment key slot', async () => {
const h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });
const h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });
try {
try {
const rows = await h.registry.listProviders();
const rows = await h.registry.listProviders();
for (const p of pool.PROVIDERS) {
for (const p of pool.PROVIDERS) {
const row = rows.find((x) => x.key === p.key);
const row = rows.find((x) => x.key === p.key);
assert.ok(row, `${p.key} seeded`);
assert.ok(row, `${p.key} seeded`);
- assert.strictEqual(row.secret_ref, `vault:${p.key}`, `${p.key} key slot`);
+ assert.strictEqual(row.secret_ref, p.key === 'deepseek' ? 'env:DEEPSEEK_API_KEY' : p.auth_mode === 'none' ? null : `vault:${p.key}`, `${p.key} key slot`);
+ if (p.key === 'deepseek') continue; // the existing environment-backed seed retains its configuration
assert.ok(row.metadata && row.metadata.catalog === 'pool-providers', `${p.key} catalogue row`);
assert.ok(row.metadata && row.metadata.catalog === 'pool-providers', `${p.key} catalogue row`);
assert.strictEqual(row.metadata.privacy.label, p.privacy.label, `${p.key} privacy label`);
assert.strictEqual(row.metadata.privacy.label, p.privacy.label, `${p.key} privacy label`);
}
}
@@ -215,7 +249,7 @@ t.test('a booted service seeds the ten offers, each with its own key slot', asyn
@@ -215,7 +249,7 @@ t.test('a booted service seeds the ten offers, each with its own key slot', asyn
const route = { key: 'test.seeded', primary: { provider: 'qwen', model: null }, fallbacks: [], timeout_ms: 3000 };
const route = { key: 'test.seeded', primary: { provider: 'qwen', model: null }, fallbacks: [], timeout_ms: 3000 };
const { explain } = await h.pool.candidates(route, ['chat'], 'chat', {});
const { explain } = await h.pool.candidates(route, ['chat'], 'chat', {});
assert.strictEqual(explain.candidates[0].eligible, false, 'an offer with no key is not eligible');
assert.strictEqual(explain.candidates[0].eligible, false, 'an offer with no key is not eligible');
- assert.ok(String(explain.candidates[0].excluded_reason).includes('no_credentials'), explain.candidates[0].excluded_reason);
+ assert.ok(/no_credentials|disabled/.test(String(explain.candidates[0].excluded_reason)), explain.candidates[0].excluded_reason);
await assert.rejects(
await assert.rejects(
h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'x' }], timeoutMs: 3000 }, { routeKey: route.key }),
h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'x' }], timeoutMs: 3000 }, { routeKey: route.key }),
(err) => err.code === 'provider.unavailable',
(err) => err.code === 'provider.unavailable',
@@ -199,7 +199,7 @@ t.test('a booted service seeds the catalogue off, and the uncensored request is
@@ -199,7 +199,7 @@ t.test('a booted service seeds the catalogue off, and the uncensored request is
for (const p of pool.PROVIDERS) {
for (const p of pool.PROVIDERS) {
const row = seeded.find((x) => x.key === p.key);
const row = seeded.find((x) => x.key === p.key);
assert.ok(row, `${p.key} seeded`);
assert.ok(row, `${p.key} seeded`);
- assert.strictEqual(row.secret_ref, `vault:${p.key}`, `${p.key} key slot`);
+ assert.strictEqual(row.secret_ref, p.key === 'deepseek' ? 'env:DEEPSEEK_API_KEY' : p.auth_mode === 'none' ? null : `vault:${p.key}`, `${p.key} key slot`);
assert.ok(await h.registry.getModel(p.key, p.models[0] && p.models[0].model_key || '').catch(() => null) || p.models.length === 0, `${p.key} model`);
assert.ok(await h.registry.getModel(p.key, p.models[0] && p.models[0].model_key || '').catch(() => null) || p.models.length === 0, `${p.key} model`);
}
}
await h.registry.upsertProvider({ key: 'refuseless', display_name: 'Refuseless', kind: 'openai', status: 'active', base_url: seam.url, auth_mode: 'none', secret_ref: null, default_model: 'deepseek-v4.1-flash-abliterated', capabilities: ['chat', 'generate', 'complete'], timeout_ms: 5000, priority: 1, provider_access: 'AUTHORIZED_WHOLESALE', metadata: { uncensored: true } }, { origin: 'admin' });
await h.registry.upsertProvider({ key: 'refuseless', display_name: 'Refuseless', kind: 'openai', status: 'active', base_url: seam.url, auth_mode: 'none', secret_ref: null, default_model: 'deepseek-v4.1-flash-abliterated', capabilities: ['chat', 'generate', 'complete'], timeout_ms: 5000, priority: 1, provider_access: 'AUTHORIZED_WHOLESALE', metadata: { uncensored: true } }, { origin: 'admin' });
@@ -66,7 +66,7 @@ async function makeProvider(name) {
@@ -66,7 +66,7 @@ async function makeProvider(name) {
const made = await request(h.base, 'POST', '/api/v1/providers', {
const made = await request(h.base, 'POST', '/api/v1/providers', {
tok: service,
tok: service,
body: {
body: {
- key: name, kind: offer.kind, base_url: `${upstream.url}/v1`, auth_mode: 'none',
+ key: name, status: 'active', kind: offer.kind, base_url: `${upstream.url}/v1`, auth_mode: 'none',
capabilities: anthropic ? ['chat', 'messages', 'tools', 'json'] : ['chat', 'complete', 'tools', 'json'],
capabilities: anthropic ? ['chat', 'messages', 'tools', 'json'] : ['chat', 'complete', 'tools', 'json'],
default_model: offer.model,
default_model: offer.model,
provider_access: 'OWN_DEPLOYMENT', metadata: { data_policy: offer.data_policy },
provider_access: 'OWN_DEPLOYMENT', metadata: { data_policy: offer.data_policy },
@@ -56,7 +56,7 @@ t.test('catalog groups the same model across providers, with prices, speed, upti
@@ -56,7 +56,7 @@ t.test('catalog groups the same model across providers, with prices, speed, upti
await h.registry.upsertModel({ provider_key: 'deepseek', model_key: 'deepseek-chat', type: 'chat', context_window: 131072,
await h.registry.upsertModel({ provider_key: 'deepseek', model_key: 'deepseek-chat', type: 'chat', context_window: 131072,
cost: { in_per_mtok: 0.28, out_per_mtok: 0.42, cached_per_mtok: 0.028 } });
cost: { in_per_mtok: 0.28, out_per_mtok: 0.42, cached_per_mtok: 0.028 } });
await h.registry.upsertProvider({
await h.registry.upsertProvider({
- key: 'deepinfra', display_name: 'DeepInfra', kind: 'openai', auth_mode: 'bearer', secret_ref: 'env:DEEPINFRA_KEY',
+ key: 'deepinfra', status: 'active', display_name: 'DeepInfra', kind: 'openai', auth_mode: 'bearer', secret_ref: 'env:DEEPINFRA_KEY',
base_url: 'https://api.deepinfra.test', capabilities: ['chat'], provider_access: 'AUTHORIZED_WHOLESALE',
base_url: 'https://api.deepinfra.test', capabilities: ['chat'], provider_access: 'AUTHORIZED_WHOLESALE',
});
});
await h.registry.upsertModel({ provider_key: 'deepinfra', model_key: 'deepseek-chat', type: 'chat', context_window: 131072,
await h.registry.upsertModel({ provider_key: 'deepinfra', model_key: 'deepseek-chat', type: 'chat', context_window: 131072,
@@ -28,7 +28,7 @@ const credentialFetch = async (url, init = {}) => {
@@ -28,7 +28,7 @@ const credentialFetch = async (url, init = {}) => {
};
};
async function provider(body) {
async function provider(body) {
- const r = await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: { kind: 'http', auth_mode: 'none', timeout_ms: 3000, ...body } });
+ const r = await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: { status: 'active', kind: 'http', auth_mode: 'none', timeout_ms: 3000, ...body } });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
}
}
async function model(providerKey, modelKey, body) {
async function model(providerKey, modelKey, body) {
@@ -158,7 +158,7 @@ t.test('the owner approves a waiting change and the price the router charges fol
@@ -158,7 +158,7 @@ t.test('the owner approves a waiting change and the price the router charges fol
t.test('the public catalog says when the price was checked', async () => {
t.test('the public catalog says when the price was checked', async () => {
// The catalog lists an offer only when the provider's key is there, as production has it.
// The catalog lists an offer only when the provider's key is there, as production has it.
await h.registry.upsertProvider({
await h.registry.upsertProvider({
- key: 'deepinfra', display_name: 'DeepInfra', kind: 'openai', auth_mode: 'bearer', secret_ref: 'env:DEEPINFRA_KEY',
+ key: 'deepinfra', status: 'active', display_name: 'DeepInfra', kind: 'openai', auth_mode: 'bearer', secret_ref: 'env:DEEPINFRA_KEY',
base_url: 'https://api.deepinfra.test', capabilities: ['chat'], provider_access: 'AUTHORIZED_WHOLESALE',
base_url: 'https://api.deepinfra.test', capabilities: ['chat'], provider_access: 'AUTHORIZED_WHOLESALE',
});
});
const book = createPriceBook({ db: h.db, registry: h.registry, config: { priceBook: { enabled: true }, nodeEnv: 'test' }, clock: CLOCK, log: silent, transport: recorded });
const book = createPriceBook({ db: h.db, registry: h.registry, config: { priceBook: { enabled: true }, nodeEnv: 'test' }, clock: CLOCK, log: silent, transport: recorded });