@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
954 lines (953 loc) • 43.3 kB
JavaScript
import { AIProviderName } from "../constants/enums.js";
import { GoogleAIModels, OpenAIModels, AnthropicModels, VertexModels, OllamaModels, LiteLLMModels, NvidiaNimModels, OpenRouterModels, CohereModels, VoyageModels, TypeSafeModels, PerplexityDeciderModels, CloudflareClefModels, JinaModels, StabilityModels, IdeogramModels, RecraftModels, ReplicateModels, } from "../constants/enums.js";
import { API_KEY_FORMATS } from "../utils/providerConfig.js";
import { getCatalogJsonEntries, getDecideCatalogEntries, 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"],
decideOrder: 1,
// 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 },
// ADVISORY, not enforced: Jev's server refuses an over-limit request
// itself (measured by bisection — a 33,002-token state accepted, 33,003
// rejected), so this row exists for `decisionLimits()` to report, and the
// pre-flight check skips it. Refusing locally would turn a measured server
// ceiling into a client one that drifts from it silently. The number
// mirrors TYPESAFE_MAX_STATE_TOKENS in providers/typesafe.ts (which
// cannot be imported here without a provider → descriptor cycle); the
// decide suite pins the two equal. No question COUNT cap has been
// measured — 400 questions beside a near-ceiling state were accepted —
// because TypeSafe bounds a request by tokens (TYPESAFE_MAX_REQUEST_TOKENS,
// 64K on state plus all questions), not by count.
decisionLimits: {
maxStateTokens: 33_000,
advisory: true,
},
setupUrl: "https://console.typesafe.ai/keys",
},
// Decision precedence is decideOrder, not position: typesafe 1, laya 2 and
// xor 3 (from catalog/laya.json and catalog/xor.json), subq 4 (from
// catalog/subq.json), perplexity-decider 5, cloudflare-clef 6 and
// openai-decisions 7 (catalog/openai-decisions.json).
// resolveDefaultDecisionProvider() returns the first configured
// DECISION_PROVIDERS entry in that order, so a host configured for several
// keeps Jev for every built-in consumer and reaches the others only by
// naming them. Renumbering silently changes which model routes, compacts
// and plans for every such host.
// perplexity-decider, cloudflare-clef and openai-decisions come last because
// each shares its environment key with a text provider: a key set only for
// that text provider must not displace a decision provider a host set up on
// purpose. subq is ahead of them only because SUBQ_API_KEY belongs to no other
// provider.
{
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. Its decideOrder (5)
// sits after XOR's (3) and SubQ's (4), which have keys of their own, 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"],
decideOrder: 5,
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"],
decideOrder: 6,
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 a small request and keeps a fail-open consumer from waiting on a
// stuck call. The state adds time, so the provider adds 250 ms for each full
// 1,000 estimated state tokens (9s at the limit below). Measured 2026-10-07 at
// about 0.2 s per 1,000 input tokens from 50,000 tokens up on both models
// (clef-flash 4.9 s at 52,154 tokens, clef 11.8 s, clef-flash 19.2 s at
// 86,819 and 30.9 s at 158,153), and 1.7 to 1.9 s at 20,954 tokens.
timeouts: { decideMs: 5_000 },
// On 2026-10-03 and 2026-10-04 the Workers AI endpoint ignored state text
// past about 2,048 tokens (hosted service or model: unknown), and the local
// limit was 1,500. On 2026-10-07 it no longer did: clef-flash read a fact at
// the very end of a 190,153-token digit array and of an 86,819-token prose
// state, and clef read a 52,154-token prose state, while clef answered
// HTTP 529 (code 5012, "Clef inference failed") after 15 s for the digit
// array. The limit is now 16,000 estimated tokens. The estimate runs 35%
// over real tokens for prose and 14% under for a digit array, whose commas
// cost a token each, so the limit is at most about 18,400 real tokens, 2.8
// times less than the 52,154 clef read in full. It also bounds how long a
// fail-open consumer can wait (the timeout above). The service changed twice
// in four days, so live canaries 19.10 and 19.10b read a fact at the end of a
// state at this limit on both models. Digits cost 1, ASCII symbols 0.75, BMP
// non-ASCII 1.5 and astral characters 3: the rates now approximate real
// tokens and no longer stand for a cut.
// The 2026-10-04 follow-up on the old cut (logs, number lists, digit arrays
// and compact JSON on both models; natural Chinese, Japanese, Korean and
// Hindi prose and emoji-rich English on clef; a many-key object) is in the
// guide; it explains where the rates come from, not what limit applies now.
decisionLimits: {
maxStateTokens: 16_000,
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 are the encoded body bytes / 4, give or take one. 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.
// On 2026-10-05 both models accepted PNG bodies up to 492,485 bytes and
// refused 532,505 (flash) and 532,499 (clef) with the same 413; the old 195/202 KB 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 }
: {}),
};
}
/**
* Builds the ProviderDescriptor of a JSON `decide` provider. It serves only
* `decide`, so it declares no auto-select, preference or health-sweep rank:
* those feed generation fallback chains a text-less provider must never join.
*/
export function buildDecideDescriptor(entry) {
const fallbacks = entry.wire.apiKeyFallbackEnvVars?.length
? { fallbacks: [...entry.wire.apiKeyFallbackEnvVars] }
: {};
return {
name: entry.id,
aliases: entry.aliases,
credentialsKey: catalogCredentialsKey(entry),
envVars: {
apiKey: catalogEnvVar(entry, "apiKey"),
...fallbacks,
baseURL: catalogEnvVar(entry, "baseURL"),
// A key is useless without an endpoint when the provider has no built-in one.
...(entry.decide.baseURLRequired
? { extraRequired: [catalogEnvVar(entry, "baseURL")] }
: {}),
model: catalogEnvVar(entry, "model"),
},
defaultModel: entry.models.default,
inferenceKinds: ["decide"],
decideOrder: entry.decide.order,
...(entry.decide.ambientKeyGuard
? {
ambientKeyGuard: {
baseURLEnvVars: [...entry.decide.ambientKeyGuard.baseURLEnvVars],
trustedOrigins: [...entry.decide.ambientKeyGuard.trustedOrigins],
...(entry.decide.ambientKeyGuard.credentialBaseURLs
? {
credentialBaseURLs: entry.decide.ambientKeyGuard.credentialBaseURLs.map((credentialField) => ({ ...credentialField })),
}
: {}),
},
}
: {}),
toolSupport: "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.decisionLimits ? { decisionLimits: entry.decisionLimits } : {}),
};
}
function buildCatalogDescriptors() {
return [
...getCatalogJsonEntries().map(buildCatalogDescriptor),
...getDecideCatalogEntries().map(buildDecideDescriptor),
];
}
/**
* 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]),
]));
const servesDecide = (descriptor) => (descriptor.inferenceKinds ?? DEFAULT_INFERENCE_KINDS).includes("decide");
/**
* Throws unless every descriptor that serves `decide` has its own finite
* `decideOrder`. The order is the precedence of resolveDefaultDecisionProvider(),
* so a missing or repeated number would make that choice depend on array position.
*/
export function assertUniqueDecideOrder(descriptors) {
const owners = new Map();
for (const descriptor of descriptors.filter(servesDecide)) {
const order = descriptor.decideOrder;
if (order === undefined || !Number.isFinite(order)) {
throw new Error(`decision provider "${descriptor.name}" has no usable decideOrder`);
}
const clash = owners.get(order);
if (clash !== undefined) {
throw new Error(`decideOrder ${order} is used by both "${clash}" and "${descriptor.name}"`);
}
owners.set(order, descriptor.name);
}
}
/** The descriptors that serve `decide`, lowest `decideOrder` first. */
export function orderDecisionProviders(descriptors) {
const rank = (d) => d.decideOrder ?? Number.MAX_SAFE_INTEGER;
return descriptors.filter(servesDecide).sort((a, b) => rank(a) - rank(b));
}
assertUniqueDecideOrder(PROVIDER_DESCRIPTORS);
/**
* Providers that serve the `decide` inference type, derived from each
* descriptor's `inferenceKinds` and sorted by `decideOrder`, lowest first. A
* provider that declares nothing is a text provider, so it is not here.
*/
export const DECISION_PROVIDERS = orderDecisionProviders(PROVIDER_DESCRIPTORS);
/**
* The scheme, host and port of a base URL, lower-cased and without the default
* port, or undefined when the text is not an http(s) URL. The path, query and a
* trailing slash do not matter, and userinfo is not part of an origin, so
* `https://api.openai.com@evil.test/v1` reads as `https://evil.test`.
*/
function originOf(text) {
try {
const url = new URL(text.trim());
return url.protocol === "http:" || url.protocol === "https:"
? url.origin
: undefined;
}
catch {
return undefined;
}
}
/**
* Whether the guard lets a key found in the environment stand. It does not
* when a listed base-URL variable, or a listed credentials field, is set to
* something other than a trusted origin; an unparseable value is untrusted, a
* blank or absent one is unset. A credentials field that is not text cannot be
* read as a URL, so it is untrusted too.
*/
function ambientKeyTrusted(guard, credentials) {
const trusted = new Set(guard.trustedOrigins
.map(originOf)
.filter((origin) => origin !== undefined));
const standsFor = (value) => {
if (value === undefined || value === null) {
return true;
}
if (typeof value !== "string") {
return false;
}
if (value.trim() === "") {
return true;
}
const origin = originOf(value);
return origin !== undefined && trusted.has(origin);
};
const slices = credentials;
return (guard.baseURLEnvVars.every((name) => standsFor(process.env[name])) &&
(guard.credentialBaseURLs ?? []).every(({ key, field }) => standsFor(slices?.[key]?.[field])));
}
/**
* 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.
*
* A key found only in the environment does not count while the descriptor's
* `ambientKeyGuard` finds that it belongs to something else, unless the host
* routed the provider on purpose with its own base-URL variable or a
* `credentials` base URL. A key in `credentials` always counts.
*/
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;
const routedOnPurpose = inEnv(descriptor.envVars.baseURL) || isSet(slice?.baseURL);
const envKeyStands = descriptor.ambientKeyGuard === undefined ||
routedOnPurpose ||
ambientKeyTrusted(descriptor.ambientKeyGuard, credentials);
// `gatewayApiKey` is TypeSafe's config form of AI_GATEWAY_API_KEY.
const hasKey = (envKeyStands &&
[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);
}
/** "a", "a or b", "a, b or c". */
function listOr(items) {
return items.length < 2
? items.join("")
: `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
}
/**
* 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 ?? [];
const guard = descriptor.ambientKeyGuard;
const unlessRouted = descriptor.envVars.baseURL
? `, unless ${descriptor.envVars.baseURL} is set`
: "";
const named = guard
? [
...guard.baseURLEnvVars,
...(guard.credentialBaseURLs ?? []).map(({ key, field }) => `credentials.${key}.${field}`),
]
: [];
const caveat = guard
? ` (ignored while ${listOr(named)} names a host outside ${listOr(guard.trustedOrigins)}${unlessRouted})`
: "";
return `${[keys.join(" or "), ...required].join(" and ")} for ${descriptor.name}${caveat}`;
}).join(", or ");
}