@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
851 lines (850 loc) • 38.5 kB
JavaScript
import { AIProviderName } from "../constants/enums.js";
import { GoogleAIModels, OpenAIModels, AnthropicModels, VertexModels, OllamaModels, LiteLLMModels, NvidiaNimModels, OpenRouterModels, CohereModels, VoyageModels, TypeSafeModels, LayaModels, XorModels, PerplexityDeciderModels, CloudflareClefModels, JinaModels, StabilityModels, IdeogramModels, RecraftModels, ReplicateModels, } from "../constants/enums.js";
import { API_KEY_FORMATS } from "../utils/providerConfig.js";
import { getCatalogJsonEntries, catalogCredentialsKey, catalogEnvVar, } from "../providers/catalog/loader.js";
import { DEFAULT_INFERENCE_KINDS } from "../types/index.js";
/**
* Hand-maintained provider identity, credentials, defaults, and runtime
* behavior classification for every provider EXCEPT the JSON-catalog
* providers derived by buildCatalogDescriptors() below (see that
* function's own doc). Pure data — no provider-class imports, no dynamic
* import(), no side effects beyond building the two derived lookup maps
* below. Order follows the AIProviderName enum declaration order
* (enums.ts:8-40) so this file stays easy to diff against it.
*/
const HAND_DESCRIPTORS = [
{
name: AIProviderName.BEDROCK,
defaultHealthSweepPriority: 5,
autoSelectPreference: 7,
aliases: ["aws"],
credentialsKey: "bedrock",
envVars: {
apiKey: "AWS_ACCESS_KEY_ID",
extraRequired: ["AWS_SECRET_ACCESS_KEY"],
model: "BEDROCK_MODEL",
},
defaultModel: "",
toolSupport: "native",
localRuntime: false,
healthCheck: "env-only",
setupUrl: "https://console.aws.amazon.com/iam/",
timeouts: { generateMs: 45_000, streamMs: 120_000 },
autoSelectPriority: 7,
apiKeyFormatPattern: API_KEY_FORMATS.bedrock,
// Falls back to the AWS SDK's own default credential chain (shared
// profile, IAM role) when these env vars are absent — see field JSDoc.
credentialsResolvedExternally: true,
},
{
name: AIProviderName.OPENAI,
defaultHealthSweepPriority: 4,
autoSelectPreference: 3,
aliases: ["gpt", "chatgpt"],
credentialsKey: "openai",
envVars: { apiKey: "OPENAI_API_KEY", baseURL: "OPENAI_BASE_URL" },
defaultModel: OpenAIModels.GPT_4O_MINI,
toolSupport: "native",
localRuntime: false,
healthCheck: "models-probe",
setupUrl: "https://platform.openai.com/api-keys",
timeouts: { generateMs: 30_000, streamMs: 120_000 },
autoSelectPriority: 5,
apiKeyFormatPattern: API_KEY_FORMATS.openai,
},
{
name: AIProviderName.OPENAI_COMPATIBLE,
aliases: ["vllm", "compatible"],
credentialsKey: "openaiCompatible",
envVars: {
apiKey: "OPENAI_COMPATIBLE_API_KEY",
baseURL: "OPENAI_COMPATIBLE_BASE_URL",
model: "OPENAI_COMPATIBLE_MODEL",
},
defaultModel: "",
toolSupport: "native",
localRuntime: false,
healthCheck: "env-only",
},
{
name: AIProviderName.OPENROUTER,
aliases: ["or"],
credentialsKey: "openrouter",
envVars: {
apiKey: "OPENROUTER_API_KEY",
baseURL: "OPENROUTER_BASE_URL",
model: "OPENROUTER_MODEL",
},
defaultModel: OpenRouterModels.CLAUDE_SONNET_4_5,
toolSupport: "model-dependent",
localRuntime: false,
healthCheck: "env-only",
setupUrl: "https://openrouter.ai/keys",
},
{
name: AIProviderName.VERTEX,
defaultHealthSweepPriority: 1,
autoSelectPreference: 5,
aliases: ["googleVertex"],
credentialsKey: "vertex",
envVars: {
// "apiKey" here names the primary identity env var, not a secret —
// Vertex's real auth is the extraRequired file-or-individual-creds
// check below. See checkVertexAuthentication() in providerHealth.ts.
apiKey: "GOOGLE_CLOUD_PROJECT_ID",
fallbacks: [
"VERTEX_PROJECT_ID",
"GOOGLE_VERTEX_PROJECT",
"GOOGLE_CLOUD_PROJECT",
],
extraRequired: ["GOOGLE_APPLICATION_CREDENTIALS"],
// GOOGLE_APPLICATION_CREDENTIALS_NEUROLINK is checked with higher
// priority than GOOGLE_APPLICATION_CREDENTIALS by the real gating
// logic (hasGoogleCredentials() in googleVertex/client.ts and
// googleVertex/utils.ts) — corrected here vs. the plan snippet, which
// omitted it. GOOGLE_AUTH_CLIENT_EMAIL and GOOGLE_AUTH_PRIVATE_KEY are
// nested together because hasGoogleCredentials() only accepts them as
// a pair — either alone is not valid auth, unlike the other flat
// entries here which are each independently sufficient.
extraRequiredFallbacks: [
"GOOGLE_APPLICATION_CREDENTIALS_NEUROLINK",
"GOOGLE_SERVICE_ACCOUNT_KEY",
["GOOGLE_AUTH_CLIENT_EMAIL", "GOOGLE_AUTH_PRIVATE_KEY"],
],
},
defaultModel: VertexModels.CLAUDE_4_6_SONNET,
toolSupport: "native",
localRuntime: false,
healthCheck: "env-only",
setupUrl: "https://console.cloud.google.com/",
timeouts: { generateMs: 60_000, streamMs: 120_000 },
autoSelectPriority: 3,
// OR-of-multiple-auth-paths (file / individual fields / base64 key) —
// not a flat AND-list of required env vars. See field JSDoc.
credentialsResolvedExternally: true,
},
{
name: AIProviderName.ANTHROPIC,
defaultHealthSweepPriority: 3,
autoSelectPreference: 4,
aliases: ["claude"],
credentialsKey: "anthropic",
envVars: {
apiKey: "ANTHROPIC_API_KEY",
fallbacks: [
"ANTHROPIC_OAUTH_TOKEN",
"CLAUDE_OAUTH_TOKEN",
"ANTHROPIC_OAUTH_ACCESS_TOKEN",
],
baseURL: "ANTHROPIC_BASE_URL",
},
defaultModel: AnthropicModels.CLAUDE_SONNET_4_6,
toolSupport: "native",
localRuntime: false,
healthCheck: "env-only",
setupUrl: "https://console.anthropic.com/settings/keys",
timeouts: { generateMs: 60_000, streamMs: 120_000 },
autoSelectPriority: 6,
apiKeyFormatPattern: API_KEY_FORMATS.anthropic,
},
{
name: AIProviderName.AZURE,
defaultHealthSweepPriority: 6,
autoSelectPreference: 8,
aliases: ["azureOpenai"],
credentialsKey: "azure",
envVars: {
apiKey: "AZURE_OPENAI_API_KEY",
extraRequired: ["AZURE_OPENAI_ENDPOINT"],
model: "AZURE_MODEL",
modelFallbacks: [
"AZURE_OPENAI_MODEL",
"AZURE_OPENAI_DEPLOYMENT",
"AZURE_OPENAI_DEPLOYMENT_ID",
],
},
defaultModel: "gpt-4o-mini",
toolSupport: "native",
localRuntime: false,
healthCheck: "env-only",
setupUrl: "https://portal.azure.com/",
timeouts: { generateMs: 30_000, streamMs: 120_000 },
autoSelectPriority: 8,
apiKeyFormatPattern: API_KEY_FORMATS.azure,
},
{
name: AIProviderName.GOOGLE_AI,
defaultHealthSweepPriority: 2,
autoSelectPreference: 6,
aliases: ["googleAiStudio", "google", "gemini", "google-ai-studio"],
credentialsKey: "googleAiStudio",
envVars: {
apiKey: "GOOGLE_AI_API_KEY",
fallbacks: ["GOOGLE_GENERATIVE_AI_API_KEY"],
baseURL: "GOOGLE_AI_BASE_URL",
},
defaultModel: GoogleAIModels.GEMINI_2_5_FLASH,
toolSupport: "native",
localRuntime: false,
healthCheck: "env-only",
setupUrl: "https://aistudio.google.com/apikey",
timeouts: { generateMs: 30_000, streamMs: 120_000 },
autoSelectPriority: 4,
apiKeyFormatPattern: API_KEY_FORMATS["google-ai"],
},
// HuggingFace migrated to the JSON catalog (catalog/huggingface.json) —
// its descriptor is now derived by buildCatalogDescriptors() below.
{
name: AIProviderName.OLLAMA,
defaultHealthSweepPriority: 8,
autoSelectPreference: 2,
aliases: ["local"],
credentialsKey: "ollama",
envVars: {
baseURL: "OLLAMA_BASE_URL",
baseURLFallbacks: ["OLLAMA_API_BASE"],
model: "OLLAMA_MODEL",
optional: true,
},
defaultModel: OllamaModels.LLAMA3_2_LATEST,
toolSupport: "model-dependent",
localRuntime: true,
healthCheck: "models-probe",
setupUrl: "https://ollama.com/download",
timeouts: { generateMs: 300_000, streamMs: 120_000 },
autoSelectPriority: 2,
},
// Mistral migrated to the JSON catalog (catalog/mistral.json, which now
// carries timeouts/autoSelectPriority/apiKeyFormat/setup.url matching
// this removed block exactly) — its descriptor is derived by
// buildCatalogDescriptors() below like deepseek's.
{
name: AIProviderName.LITELLM,
defaultHealthSweepPriority: 7,
autoSelectPreference: 1,
aliases: [],
credentialsKey: "litellm",
envVars: {
apiKey: "LITELLM_API_KEY",
baseURL: "LITELLM_BASE_URL",
model: "LITELLM_MODEL",
optional: true,
},
defaultModel: LiteLLMModels.OPENAI_GPT_4O_MINI,
toolSupport: "native",
localRuntime: false,
healthCheck: "models-probe",
setupUrl: "https://docs.litellm.ai/docs/proxy/quick_start",
timeouts: { generateMs: 300_000, streamMs: 120_000 },
autoSelectPriority: 1,
// Documented zero-config local proxy — see field JSDoc.
credentialsResolvedExternally: true,
},
{
name: AIProviderName.SAGEMAKER,
aliases: ["aws-sagemaker"],
credentialsKey: "sagemaker",
envVars: {
apiKey: "AWS_ACCESS_KEY_ID",
extraRequired: ["AWS_SECRET_ACCESS_KEY"],
model: "SAGEMAKER_MODEL",
modelFallbacks: [
"SAGEMAKER_MODEL_NAME",
"SAGEMAKER_DEFAULT_ENDPOINT",
"SAGEMAKER_ENDPOINT_NAME",
],
baseURL: "SAGEMAKER_ENDPOINT",
},
defaultModel: "sagemaker-model",
toolSupport: "native",
localRuntime: false,
// Deliberate: SageMaker has no models-list endpoint, so a live
// minimal generate() call is the only real reachability check
// (mirrors providerUtils.isProviderAvailable()'s legacy fallback).
healthCheck: "live-generate",
setupUrl: "https://console.aws.amazon.com/iam/",
apiKeyFormatPattern: API_KEY_FORMATS.aws,
},
// DeepSeek migrated to the JSON catalog (catalog/deepseek.json) — its
// descriptor is now derived by buildCatalogDescriptors() below, with no
// exclusion needed (unlike mistral): every field it derives matches this
// block byte-for-byte (credentialsKey, envVars, defaultModel, toolSupport,
// setupUrl), so removing the hand entry changes nothing observable.
{
name: AIProviderName.NVIDIA_NIM,
aliases: ["nvidia", "nim"],
credentialsKey: "nvidiaNim",
envVars: {
apiKey: "NVIDIA_NIM_API_KEY",
baseURL: "NVIDIA_NIM_BASE_URL",
model: "NVIDIA_NIM_MODEL",
},
defaultModel: NvidiaNimModels.GPT_OSS_20B,
toolSupport: "native",
localRuntime: false,
healthCheck: "env-only",
setupUrl: "https://build.nvidia.com/settings/api-keys",
},
{
name: AIProviderName.LM_STUDIO,
aliases: ["lmstudio", "lms"],
credentialsKey: "lmStudio",
envVars: {
baseURL: "LM_STUDIO_BASE_URL",
model: "LM_STUDIO_MODEL",
optional: true,
},
defaultModel: "",
toolSupport: "native",
localRuntime: true,
healthCheck: "env-only",
setupUrl: "https://lmstudio.ai/",
},
{
name: AIProviderName.LLAMACPP,
aliases: ["llama.cpp", "llama-cpp"],
credentialsKey: "llamacpp",
envVars: {
baseURL: "LLAMACPP_BASE_URL",
model: "LLAMACPP_MODEL",
optional: true,
},
defaultModel: "",
toolSupport: "native",
localRuntime: true,
healthCheck: "env-only",
setupUrl: "https://github.com/ggerganov/llama.cpp",
},
{
name: AIProviderName.COHERE,
aliases: [],
credentialsKey: "cohere",
envVars: {
apiKey: "COHERE_API_KEY",
baseURL: "COHERE_BASE_URL",
model: "COHERE_MODEL",
},
defaultModel: CohereModels.COMMAND_R_PLUS,
toolSupport: "native",
localRuntime: false,
healthCheck: "env-only",
setupUrl: "https://dashboard.cohere.com/api-keys",
},
{
name: AIProviderName.REPLICATE,
aliases: [],
credentialsKey: "replicate",
// NOTE: Replicate's NeurolinkCredentials shape uses non-standard field
// names (apiToken not apiKey, baseUrl not baseURL) — envVars below
// still describes the *environment variable* names, which follow the
// usual convention; only the credentials object's field names differ.
envVars: { apiKey: "REPLICATE_API_TOKEN", model: "REPLICATE_MODEL" },
defaultModel: ReplicateModels.LLAMA_3_70B_INSTRUCT,
toolSupport: "none",
localRuntime: false,
healthCheck: "env-only",
setupUrl: "https://replicate.com/account/api-tokens",
},
{
name: AIProviderName.VOYAGE,
aliases: ["voyage-ai"],
credentialsKey: "voyage",
envVars: {
apiKey: "VOYAGE_API_KEY",
baseURL: "VOYAGE_BASE_URL",
model: "VOYAGE_MODEL",
},
defaultModel: VoyageModels.VOYAGE_3_5,
toolSupport: "none",
localRuntime: false,
healthCheck: "env-only",
setupUrl: "https://dash.voyageai.com/api-keys",
},
{
name: AIProviderName.JINA,
aliases: ["jina-ai"],
credentialsKey: "jina",
envVars: {
apiKey: "JINA_API_KEY",
baseURL: "JINA_BASE_URL",
model: "JINA_MODEL",
},
defaultModel: JinaModels.JINA_EMBEDDINGS_V3,
toolSupport: "none",
localRuntime: false,
healthCheck: "env-only",
setupUrl: "https://jina.ai/?sui=apikey",
},
{
name: AIProviderName.STABILITY,
aliases: ["stability-ai", "sd"],
credentialsKey: "stability",
envVars: {
apiKey: "STABILITY_API_KEY",
baseURL: "STABILITY_BASE_URL",
model: "STABILITY_MODEL",
},
defaultModel: StabilityModels.STABLE_IMAGE_ULTRA,
toolSupport: "none",
localRuntime: false,
healthCheck: "env-only",
setupUrl: "https://platform.stability.ai/account/keys",
},
{
name: AIProviderName.IDEOGRAM,
aliases: [],
credentialsKey: "ideogram",
envVars: {
apiKey: "IDEOGRAM_API_KEY",
baseURL: "IDEOGRAM_BASE_URL",
model: "IDEOGRAM_MODEL",
},
defaultModel: IdeogramModels.IDEOGRAM_V3,
toolSupport: "none",
localRuntime: false,
healthCheck: "env-only",
setupUrl: "https://developer.ideogram.ai/",
},
{
name: AIProviderName.RECRAFT,
aliases: [],
credentialsKey: "recraft",
envVars: {
apiKey: "RECRAFT_API_KEY",
baseURL: "RECRAFT_BASE_URL",
model: "RECRAFT_MODEL",
},
defaultModel: RecraftModels.RECRAFT_V3,
toolSupport: "none",
localRuntime: false,
healthCheck: "env-only",
setupUrl: "https://www.recraft.ai/api",
},
{
name: AIProviderName.TYPESAFE,
aliases: ["jev", "typesafe-ai"],
credentialsKey: "typesafe",
envVars: {
apiKey: "TYPESAFE_API_KEY",
// A Vercel AI Gateway key reaches the same model through a different
// transport, so it satisfies this provider on its own. Listing it here
// is what makes `resolveDefaultDecisionProvider()` — and therefore
// "if somebody sets the key, we start using it" — true for gateway
// users who never hold a TypeSafe key at all.
fallbacks: ["AI_GATEWAY_API_KEY"],
baseURL: "TYPESAFE_BASE_URL",
model: "TYPESAFE_MODEL",
},
defaultModel: TypeSafeModels.JEV_LATEST,
// Jev serves only the `decide` inference type. Declaring that here is
// what keeps it out of every generation code path, rather than each of
// those having to special-case the provider name.
inferenceKinds: ["decide"],
// It emits no text, so it cannot call tools and cannot answer a
// "live-generate" health probe.
toolSupport: "none",
localRuntime: false,
healthCheck: "env-only",
// Deliberately NO autoSelectPriority / autoSelectPreference /
// defaultHealthSweepPriority: all three feed generation fallback chains,
// and a text-less provider must never be reachable from them.
timeouts: { decideMs: 5000 },
setupUrl: "https://console.typesafe.ai/keys",
},
// Laya MUST stay after TypeSafe, XOR after Laya, Perplexity after XOR, and
// Cloudflare Clef after Perplexity.
// resolveDefaultDecisionProvider() returns the first configured
// DECISION_PROVIDERS entry, in this order, so a host configured for several
// keeps Jev for every built-in consumer and reaches the others only by
// naming them. Reordering these silently changes which model routes,
// compacts and plans for every such host.
{
name: AIProviderName.LAYA,
aliases: [],
credentialsKey: "laya",
envVars: {
apiKey: "LAYA_API_KEY",
baseURL: "LAYA_BASE_URL",
// Laya has no built-in endpoint (it is self-hosted or behind a proxy),
// so a key is useless without a base URL. Both are required for it to
// count as configured, from the environment or credentials.laya.
extraRequired: ["LAYA_BASE_URL"],
model: "LAYA_MODEL",
},
defaultModel: LayaModels.TYPED_DECISIONS,
// Serves only `decide`, like TypeSafe — the one declaration that keeps it
// out of every generation code path.
inferenceKinds: ["decide"],
toolSupport: "none",
localRuntime: false,
healthCheck: "env-only",
// Deliberately NO autoSelectPriority / autoSelectPreference /
// defaultHealthSweepPriority, for the same reason as TypeSafe above.
timeouts: { decideMs: 5000 },
// Laya's encoders cut the state off silently past their window (measured
// live: a 60,000-character state answered 200 on 1,024 tokens). 768 is
// the 1,024-token window minus the 256-token budget Laya reserves for a
// question and its options; 320 is the English checkpoint's 512 minus
// 192. `auto` may route to English, and an unlisted name (Laya's server
// accepts aliases such as `en`) could be any checkpoint, so both get 320.
// 64 is the question cap in Laya's own server. Non-ASCII rates were
// measured live: Chinese is ~1.4 tokens per character on the English
// tokenizer (typed-decisions, english) and ~0.56 on multilingual.
decisionLimits: {
maxStateTokens: 320,
maxQuestions: 64,
nonAsciiTokensPerChar: 1.5,
models: {
"typed-decisions": { maxStateTokens: 768 },
multilingual: { maxStateTokens: 768, nonAsciiTokensPerChar: 0.6 },
english: { maxStateTokens: 320 },
auto: { maxStateTokens: 320 },
},
},
setupUrl: "https://github.com/NandhaKishorM/laya",
},
{
name: AIProviderName.XOR,
aliases: [],
credentialsKey: "xor",
envVars: {
apiKey: "XOR_API_KEY",
baseURL: "XOR_BASE_URL",
// XOR has no built-in endpoint (it is reached at a deployment or behind a
// proxy), so a key is useless without a base URL. Both are required for
// it to count as configured, from the environment or credentials.xor.
extraRequired: ["XOR_BASE_URL"],
model: "XOR_MODEL",
},
defaultModel: XorModels.XOR_1_1,
// Serves only `decide`, like TypeSafe and Laya — the one declaration that
// keeps it out of every generation code path.
inferenceKinds: ["decide"],
toolSupport: "none",
localRuntime: false,
healthCheck: "env-only",
// Deliberately NO autoSelectPriority / autoSelectPreference /
// defaultHealthSweepPriority, for the same reason as TypeSafe above.
timeouts: { decideMs: 5000 },
// The deployment's prefill is 250,000 tokens, shared by the state, the
// questions and any media. 200,000 leaves 20% for those, and a state
// between this and the server's 4 MB would otherwise pass validation and
// fail inside the engine as a 500, which the base class would retry. There
// is no question cap (the server only limits a choice to 2..255 options).
// Non-ASCII text is charged per character because the four-characters-
// per-token estimate is several times too generous for CJK on this
// tokenizer. One token per character is a deliberate upper bound, to be
// replaced by a live measurement.
decisionLimits: {
maxStateTokens: 200_000,
nonAsciiTokensPerChar: 1,
// The server takes 1..8 images and one video, and refuses a body over
// 8 MB.
media: { maxImages: 8, video: true, maxRequestBytes: 8 * 1024 * 1024 },
},
setupUrl: "https://huggingface.co/juspay/xor",
},
{
name: AIProviderName.PERPLEXITY_DECIDER,
aliases: [],
credentialsKey: "perplexityDecider",
envVars: {
// The same key the `perplexity` text provider reads, so an ambient
// PERPLEXITY_API_KEY configures this provider too. It sits after XOR in
// this list, which is what keeps that from displacing any other decision
// provider a host has configured.
apiKey: "PERPLEXITY_API_KEY",
// Optional: the API is hosted at a public endpoint, so a key alone is
// enough and there is no extraRequired.
baseURL: "PERPLEXITY_DECIDER_BASE_URL",
model: "PERPLEXITY_DECIDER_MODEL",
},
defaultModel: PerplexityDeciderModels.PPLX_DECIDER_V1_27B,
// Serves only `decide`, like the other decision providers — the one
// declaration that keeps it out of every generation code path, and out of
// the way of the `perplexity` text provider.
inferenceKinds: ["decide"],
toolSupport: "none",
localRuntime: false,
healthCheck: "env-only",
// Deliberately NO autoSelectPriority / autoSelectPreference /
// defaultHealthSweepPriority, for the same reason as TypeSafe above.
//
// Measured live: 4s at 65,000 input tokens, 10s at 146,000 and 23s at
// 251,000 (faster than linear: about 17,000, 15,000 and 11,000 tokens a
// second), plus about 65 ms for each question after the first (128 minimal
// questions took 8.9s). 10s is the allowance for the state and covers every
// one the window below admits except log text or number arrays close to it;
// the provider adds 100 ms a question on top, so a request at the
// 128-question cap waits about 22.8s. A larger state passes a per-call
// `timeoutMs`.
timeouts: { decideMs: 10_000 },
// The server reads 262,144 input tokens in all and answers an over-long
// request with an explicit 400, never a silent truncation. The state, the
// questions and the images were all measured to count toward it, an image at
// one token for each 32 x 32 tile. The window below is deliberately lower: a
// state of 100,000 tokens takes about 7s of the 10s above (interpolated from
// those points), and a much larger one would outlast it and be retried into
// the same wait. The estimate is about four characters per token. Measured
// on 40,000-character samples: English prose 4.3, TypeScript 3.8, a minified
// JSON catalog 3.4, log lines with timestamps and ids 1.35 and arrays of
// numbers 1.2. Only the last two run low enough to pass the window and still
// be refused by the server; JSON is under-counted by about 13%. A request
// over the window fails locally as max_tokens_exceeded, which every consumer
// treats as "carry on as before". 128 questions is the documented cap,
// confirmed live.
// Non-ASCII text is charged at the measured rate for CJK (0.46 tokens per
// code point) and Devanagari (0.44), not for emoji (2.6), because an
// over-charge silently shuts every non-Latin caller out while an
// under-charge is caught by that 400.
decisionLimits: {
maxStateTokens: 100_000,
maxQuestions: 128,
nonAsciiTokensPerChar: 0.5,
// Images only; the API has no video input. A ninth image in one request
// is refused with a 400, and the 32 MiB body is the documented cap.
media: { maxImages: 8, video: false, maxRequestBytes: 32 * 1024 * 1024 },
},
setupUrl: "https://console.perplexity.ai",
},
{
name: AIProviderName.CLOUDFLARE_CLEF,
aliases: [],
credentialsKey: "cloudflareClef",
envVars: {
// The same token and account id the `cloudflare` text provider reads, so
// ambient Workers AI settings configure this provider too. It sits last
// in this list, which is what keeps that from displacing any other
// decision provider a host has configured.
apiKey: "CLOUDFLARE_API_KEY",
// Cloudflare's own API is the endpoint, so a base URL is optional; but
// the path carries the account id, so a token alone is useless. Both are
// required for it to count as configured, from the environment or from
// credentials.cloudflareClef.
extraRequired: ["CLOUDFLARE_ACCOUNT_ID"],
extraRequiredCredentialFields: { CLOUDFLARE_ACCOUNT_ID: "accountId" },
baseURL: "CLOUDFLARE_CLEF_BASE_URL",
model: "CLOUDFLARE_CLEF_MODEL",
},
defaultModel: CloudflareClefModels.CLEF,
// Serves only `decide`, like the other decision providers — the one
// declaration that keeps it out of every generation code path, and out of
// the way of the `cloudflare` text provider.
inferenceKinds: ["decide"],
toolSupport: "none",
localRuntime: false,
healthCheck: "env-only",
// Deliberately NO autoSelectPriority / autoSelectPreference /
// defaultHealthSweepPriority, for the same reason as TypeSafe above.
//
// Measured 2026-10-03: 0.3 to 1.0 s for a small request, 1.1 s for 64 questions on
// clef-flash and 1.3 s on clef, and 2.2 s at most for any of 60 requests
// sent at once. On 2026-10-04, 64 questions took 1.5 s (flash) / 2.3 s (clef).
// 5s covers those observations and keeps a fail-open consumer
// from waiting on a stuck call.
timeouts: { decideMs: 5_000 },
// The Workers AI endpoint ignores state text past about 2,048 tokens
// (hosted service or model: unknown), despite the documented 64K. The local
// 1,500-token estimate refuses before every measured cut. On 2026-10-04,
// both models read facts at the original clef-flash lower bounds for logs,
// number lists, digit arrays and compact JSON, but not about 2.5% further on;
// English prose and random CJK already matched on both. Digits cost 1,
// ASCII symbols 0.75, BMP non-ASCII 1.5 and astral characters 3 tokens.
// Natural Chinese, Japanese, Korean and Hindi prose and emoji-rich English
// were also safe under those rates on clef. A many-key object cut between
// 128 and 134 preceding keys on both models: its lower bound was 4,883
// compact-JSON characters, estimated at 2,299 tokens. The probe used an
// explicit field-name question and zero-padded keys together after the
// control failed three times. It then passed; necessity was not established.
// Other object shapes and Unicode sequences may differ.
decisionLimits: {
maxStateTokens: 1_500,
maxQuestions: 64,
nonAsciiTokensPerChar: 1.5,
digitTokensPerChar: 1,
symbolTokensPerChar: 0.75,
astralTokensPerChar: 3,
// Images only. Exactly four tiny images succeeded and five were refused
// on both models. image/jpg also succeeded and is normalized to JPEG.
// Retain the conservative 256,000-byte encoded-body cap: on 2026-10-04
// both models accepted 520,000 text characters and refused 525,000 with
// 413/code 5021. On 2026-10-03, clef-flash accepted 262,000 and refused
// 270,000. Estimates match encoded body characters / 4, rounded up. The
// threshold moved from 65,527 accepted / 67,527 refused to 130,026 /
// 131,276 (clef) and 130,027 / 131,277 (flash); errors still print 65,536.
// Inference: the new interval contains 131,072 (twice the printed
// figure); reason unknown.
// The new image-byte ceiling was not measured; the old 195/202 KB PNG boundary
// is historical. See the guide for exact model and date coverage.
media: { maxImages: 4, video: false, maxRequestBytes: 256_000 },
},
setupUrl: "https://dash.cloudflare.com/profile/api-tokens",
},
];
/**
* Builds a ProviderDescriptor for every JSON-catalog provider. Every field
* is derived from the catalog JSON (src/lib/providers/catalog/<id>.json),
* never hand-typed.
*
* defaultModel prefers models.registryDefaultModel over models.default —
* only mistral sets registryDefaultModel today (its long-shipped descriptor's
* defaultModel was MISTRAL_LARGE_LATEST, while models.default is the smaller
* mistral-small-2506 the OpenAI-compat runtime path actually defaults to via
* MISTRAL_MODEL); every other provider's registryDefaultModel is absent, so
* `?? entry.models.default` is a no-op for them.
*
* timeouts and autoSelectPriority are included only when the JSON sets
* them — today only mistral does, preserving its pre-migration hand-typed
* values.
*/
function buildCatalogDescriptor(entry) {
// Cloudflare is the only catalog provider whose wire config is a
// baseURLTemplate + extra credential (accountId) rather than a plain
// baseURL env var — mirrors loader.ts's buildCatalogEntries()
// computedBaseURL.envVar formula exactly. Duplicated intentionally, the
// same way loader.ts itself duplicates toCamelCase from
// tools/codegen-catalog.ts (src/ must not import from tools/).
const fallbacks = entry.wire.apiKeyFallbackEnvVars?.length
? { fallbacks: [...entry.wire.apiKeyFallbackEnvVars] }
: {};
const envVars = entry.wire.baseURLTemplate
? {
apiKey: catalogEnvVar(entry, "apiKey"),
...fallbacks,
extraRequired: [
`${entry.id.toUpperCase().replace(/-/g, "_")}_${(entry.wire.extraCredentials?.[0] ?? "accountId")
.replace(/([A-Z])/g, "_$1")
.toUpperCase()}`,
],
model: catalogEnvVar(entry, "model"),
}
: {
apiKey: catalogEnvVar(entry, "apiKey"),
...fallbacks,
baseURL: catalogEnvVar(entry, "baseURL"),
model: catalogEnvVar(entry, "model"),
};
return {
name: entry.id,
aliases: entry.aliases,
credentialsKey: catalogCredentialsKey(entry),
envVars,
defaultModel: entry.models.registryDefaultModel ?? entry.models.default,
// "none" (not an invented literal — it's the union's own
// no-tool-support member, the same one
// REPLICATE/VOYAGE/JINA/STABILITY/IDEOGRAM/RECRAFT use above) is
// mancer's branch; "model-dependent" is huggingface's (matches
// ProviderDescriptor.toolSupport's own union member, same value
// Ollama's hand descriptor already uses).
toolSupport: entry.capabilities.tools === "model-dependent"
? "model-dependent"
: entry.capabilities.tools
? "native"
: "none",
localRuntime: false,
healthCheck: "env-only",
setupUrl: entry.setup.url,
...(entry.setup.apiKeyFormat
? { apiKeyFormatPattern: new RegExp(entry.setup.apiKeyFormat) }
: {}),
...(entry.timeouts ? { timeouts: entry.timeouts } : {}),
...(entry.autoSelectPriority !== undefined
? { autoSelectPriority: entry.autoSelectPriority }
: {}),
};
}
function buildCatalogDescriptors() {
return getCatalogJsonEntries().map(buildCatalogDescriptor);
}
/**
* Single source of truth for provider identity, credentials, defaults, and
* runtime behavior classification — the hand-maintained providers plus the
* JSON-catalog providers derived above.
*/
export const PROVIDER_DESCRIPTORS = [
...HAND_DESCRIPTORS,
...buildCatalogDescriptors(),
];
/** O(1) canonical-name → descriptor lookup. */
export const PROVIDER_DESCRIPTORS_BY_NAME = new Map(PROVIDER_DESCRIPTORS.map((d) => [d.name, d]));
/**
* O(1) alias → canonical-name lookup, covering both `aliases` and each
* descriptor's own lowercased `name`. Replaces the O(n) linear scan in
* ProviderFactory.normalizeProviderName().
*/
export const PROVIDER_ALIAS_INDEX = new Map(PROVIDER_DESCRIPTORS.flatMap((d) => [
[d.name.toLowerCase(), d.name],
...d.aliases.map((alias) => [alias.toLowerCase(), d.name]),
]));
/**
* Providers that serve the `decide` inference type, derived from each
* descriptor's `inferenceKinds`. A provider that declares nothing is a text
* provider, so it is not here.
*/
export const DECISION_PROVIDERS = PROVIDER_DESCRIPTORS.filter((d) => (d.inferenceKinds ?? DEFAULT_INFERENCE_KINDS).includes("decide"));
/**
* Whether a decision provider is fully configured: its key and every
* extraRequired variable are set, each either in the environment or in
* `credentials` — the same config a caller passes to
* `new NeuroLink({ credentials })` or per call. Whitespace counts as unset.
*/
function isDecisionProviderConfigured(descriptor, credentials) {
const isSet = (value) => typeof value === "string" && value.trim() !== "";
const inEnv = (name) => name !== undefined && isSet(process.env[name]);
const slice = descriptor.credentialsKey
? credentials?.[descriptor.credentialsKey]
: undefined;
// `gatewayApiKey` is TypeSafe's config form of AI_GATEWAY_API_KEY.
const hasKey = [descriptor.envVars.apiKey, ...(descriptor.envVars.fallbacks ?? [])].some(inEnv) ||
isSet(slice?.apiKey) ||
isSet(slice?.gatewayApiKey);
// A required base URL can also come from credentials.<key>.baseURL, and any
// other required value the descriptor maps to a field of that slice.
const hasRequired = (descriptor.envVars.extraRequired ?? []).every((name) => {
const field = descriptor.envVars.extraRequiredCredentialFields?.[name];
return (inEnv(name) ||
(name === descriptor.envVars.baseURL && isSet(slice?.baseURL)) ||
(field !== undefined && isSet(slice?.[field])));
});
return hasKey && hasRequired;
}
/**
* The decision provider to use when a caller names none: the first
* DECISION_PROVIDERS entry that is fully configured, from the environment or
* from `credentials`.
*
* This is where "if somebody configures it, we start using it" is
* implemented. Returns undefined when none is configured, which every
* internal consumer treats as "carry on exactly as before".
*/
export function resolveDefaultDecisionProvider(credentials) {
return DECISION_PROVIDERS.find((descriptor) => isDecisionProviderConfigured(descriptor, credentials))?.name;
}
/**
* Names of the decision providers that accept images, or, with `video`, the
* ones that accept video too: a hint that names a provider which would refuse
* the same request is worse than none.
*/
export function listMediaDecisionProviders(needs) {
return DECISION_PROVIDERS.filter((descriptor) => {
const media = descriptor.decisionLimits?.media;
return media !== undefined && (!needs?.video || media.video);
}).map((descriptor) => descriptor.name);
}
/**
* The variables that would configure each decision provider, one clause per
* provider in precedence order, e.g. "TYPESAFE_API_KEY or AI_GATEWAY_API_KEY
* for typesafe, or LAYA_API_KEY and LAYA_BASE_URL for laya". Derived from
* DECISION_PROVIDERS so a new decision provider appears in every "nothing is
* configured" message without anyone editing one.
*/
export function describeDecisionProviderKeys() {
return DECISION_PROVIDERS.map((descriptor) => {
const keys = [
descriptor.envVars.apiKey,
...(descriptor.envVars.fallbacks ?? []),
].filter((name) => typeof name === "string" && name !== "");
const required = descriptor.envVars.extraRequired ?? [];
return `${[keys.join(" or "), ...required].join(" and ")} for ${descriptor.name}`;
}).join(", or ");
}