UNPKG

@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

301 lines (300 loc) • 13 kB
import { AIProviderName, NvidiaNimModels } from "../../constants/enums.js"; import { AuthenticationError, InvalidModelError, ProviderError, RateLimitError, } from "../../types/index.js"; import { classifyProviderError, DEFAULT_ERROR_RULES, } from "../../utils/errorClassifier.js"; import { logger } from "../../utils/logger.js"; import { redactUrlCredentials } from "../../utils/logSanitize.js"; import { createNvidiaNimConfig, getProviderModel, validateApiKey, } from "../../utils/providerConfig.js"; import { OpenAIChatCompletionsProvider } from "../openaiChatCompletionsBase.js"; import { PROVIDER_DESCRIPTORS_BY_NAME } from "../../factories/providerDescriptors.js"; /** * Decide whether a NIM 400 response body is a rejection of the named * field (as opposed to an unrelated 400 that happens to mention the * field name — e.g. when the user's prompt is echoed back inside the * error envelope). * * A rejection requires both: * - the field name appears in the body, and * - a rejection keyword (`unsupported`, `not supported`, `unknown`, * `invalid`, `unrecognized`, `does not support`) appears within * 80 characters of any occurrence. * * The 80-character window is loose enough to absorb NIM's "Unsupported * argument: `chat_template`" framing and tight enough that a 1KB error * body mentioning the field once in a code sample plus an unrelated * "invalid" elsewhere won't trigger a strip. */ const NIM_REJECTION_KEYWORDS = [ "unsupported", "not supported", "does not support", "unrecognized", "unknown field", "unknown parameter", "unknown argument", "invalid field", "invalid parameter", "invalid argument", ]; const isNimFieldRejection = (body, field) => { if (!body) { return false; } const lower = body.toLowerCase(); const fieldLower = field.toLowerCase(); let idx = lower.indexOf(fieldLower); while (idx !== -1) { const windowStart = Math.max(0, idx - 80); const windowEnd = Math.min(lower.length, idx + fieldLower.length + 80); const slice = lower.slice(windowStart, windowEnd); if (NIM_REJECTION_KEYWORDS.some((kw) => slice.includes(kw))) { return true; } idx = lower.indexOf(fieldLower, idx + fieldLower.length); } return false; }; /** * Strip an offending field from a JSON request body and return the rebuilt * stringified body. Returns `null` if the body isn't JSON-parseable or the * field isn't present (signal: nothing to retry). */ const stripFieldFromJsonBody = (body, field) => { try { const parsed = JSON.parse(body); let mutated = false; if (field === "chat_template" && "chat_template" in parsed) { delete parsed.chat_template; mutated = true; } if (field === "reasoning_budget") { const kw = parsed.chat_template_kwargs; if (kw && "reasoning_budget" in kw) { delete kw.reasoning_budget; mutated = true; if (Object.keys(kw).length === 0) { delete parsed.chat_template_kwargs; } } } if (!mutated) { return null; } return JSON.stringify(parsed); } catch { return null; } }; const NVIDIA_NIM_DEFAULT_BASE_URL = "https://integrate.api.nvidia.com/v1"; const envInt = (k) => { const v = process.env[k]; if (!v) { return undefined; } const parsed = Number.parseInt(v, 10); return Number.isFinite(parsed) ? parsed : undefined; }; const envFloat = (k) => { const v = process.env[k]; if (!v) { return undefined; } const parsed = Number.parseFloat(v); return Number.isFinite(parsed) ? parsed : undefined; }; const buildNvidiaNimExtraBody = (thinkingEnabled, maxTokens) => { const extra = {}; const topK = envInt("NVIDIA_NIM_TOP_K"); if (topK !== undefined && topK !== -1) { extra.top_k = topK; } const minP = envFloat("NVIDIA_NIM_MIN_P"); if (minP !== undefined && minP !== 0) { extra.min_p = minP; } const repPenalty = envFloat("NVIDIA_NIM_REPETITION_PENALTY"); if (repPenalty !== undefined && repPenalty !== 1) { extra.repetition_penalty = repPenalty; } const minTokens = envInt("NVIDIA_NIM_MIN_TOKENS"); if (minTokens !== undefined && minTokens !== 0) { extra.min_tokens = minTokens; } const chatTemplate = process.env.NVIDIA_NIM_CHAT_TEMPLATE; if (chatTemplate) { extra.chat_template = chatTemplate; } if (thinkingEnabled) { extra.chat_template_kwargs = { thinking: true, enable_thinking: true, ...(maxTokens ? { reasoning_budget: maxTokens } : {}), }; } return extra; }; const getNimApiKey = () => { return validateApiKey(createNvidiaNimConfig()); }; const getDefaultNimModel = () => { // NVIDIA retired meta/llama-3.3-70b-instruct upstream on 2026-08-26, so this // fallback answered "no longer available" for anyone who selected nvidia-nim // without naming a model. gpt-oss-20b is on the current roster and was // probed for text, streaming, tool calling and structured output. // Read the descriptor rather than repeating the literal. Review on this PR // pointed out the default lived in four places and that changing only some // of them is a no-op — which happened twice while tracking this down. The // descriptor is the Factory+Registry convention's source of truth. return getProviderModel("NVIDIA_NIM_MODEL", PROVIDER_DESCRIPTORS_BY_NAME.get(AIProviderName.NVIDIA_NIM)?.defaultModel ?? NvidiaNimModels.GPT_OSS_20B); }; /** * NVIDIA NIM Provider — native HTTP+SSE, no AI SDK. * * Wraps NVIDIA's hosted (or self-hosted) inference endpoints. * Passes NIM-specific extras (top_k, min_p, repetition_penalty, min_tokens, * chat_template, chat_template_kwargs.reasoning_budget) to the wire via the * `extraBody` channel of adjustBuildBodyOptions, and retries once without * `chat_template`/`reasoning_budget` when a model server rejects them with a * 400 (adjustBodyAfter400 — applies on both generate and stream paths). * reasoning_content is surfaced automatically by the native base client * (streamed as `{ content: "", reasoning }` chunks; `result.reasoning` on * the non-streaming path); all other behavior is preserved. * * @see https://docs.api.nvidia.com/nim/reference/ */ export class NvidiaNimProvider extends OpenAIChatCompletionsProvider { constructor(modelName, sdk, _region, credentials) { // Trim the override before applying precedence. A blank/whitespace // `credentials.apiKey` should NOT bypass `getNimApiKey()` — that would // build a client with an unusable bearer token and fail at request time // with a confusing 401 instead of at construction time. const overrideApiKey = credentials?.apiKey?.trim(); const apiKey = overrideApiKey && overrideApiKey.length > 0 ? overrideApiKey : getNimApiKey(); const baseURL = credentials?.baseURL ?? process.env.NVIDIA_NIM_BASE_URL ?? NVIDIA_NIM_DEFAULT_BASE_URL; super(AIProviderName.NVIDIA_NIM, modelName, sdk, { baseURL, apiKey }); logger.debug("NVIDIA NIM Provider initialized", { modelName: this.modelName, providerName: this.providerName, baseURL: redactUrlCredentials(this.config.baseURL), }); } getProviderName() { return AIProviderName.NVIDIA_NIM; } getDefaultModel() { return getDefaultNimModel(); } /** * Inject NIM-specific body fields (top_k, min_p, repetition_penalty, * min_tokens, chat_template, chat_template_kwargs) into the wire body. * * The base passes the full StreamOptions as `opts` at runtime (typed * narrowly as OpenAICompatBuildBodyArgs["options"] for standard fields). * We access thinkingLevel via an indexed-access cast since the runtime * value is the full StreamOptions object. */ adjustBuildBodyOptions(_modelId, opts) { // The runtime value of opts is the full StreamOptions; TypeScript types // it narrowly so we cast to access NIM-specific thinking fields. const fullOpts = opts; const thinkingConfigRaw = fullOpts.thinkingConfig; const tl = fullOpts.thinkingLevel ?? thinkingConfigRaw?.thinkingLevel; const thinkingEnabled = tl !== undefined && tl !== "minimal"; const maxTokens = typeof fullOpts.maxTokens === "number" ? fullOpts.maxTokens : undefined; const extra = buildNvidiaNimExtraBody(thinkingEnabled, maxTokens); // Attach via the explicit extraBody channel — buildBody spreads it onto // the wire body. (Loose unknown keys on the returned options object are // dropped by buildBody, which is why the extras previously never reached // the wire.) return Object.keys(extra).length > 0 ? { ...opts, extraBody: extra } : opts; } /** * One-shot 400 retry (base hook): some NIM model servers reject * `chat_template` or `chat_template_kwargs.reasoning_budget` with a 400 * naming the field. Strip the rejected field(s) and let the base retry * once, restoring the pre-migration fetch-level behavior. Returns * undefined for unrelated 400s so they propagate unchanged. */ adjustBodyAfter400(body, error) { const responseBody = error.responseBody ?? ""; let serialized = JSON.stringify(body); const strippedFields = []; for (const field of ["chat_template", "reasoning_budget"]) { if (isNimFieldRejection(responseBody, field)) { const next = stripFieldFromJsonBody(serialized, field); if (next !== null) { serialized = next; strippedFields.push(field); } } } if (strippedFields.length === 0) { return undefined; } logger.debug("NVIDIA NIM: 400 rejected request field(s) — retrying once without them", { strippedFields, model: body.model, rejection: responseBody.slice(0, 200), }); return JSON.parse(serialized); } formatProviderError(error) { const rules = [ // NIM canonically returns HTTP 401/Unauthorized for invalid API keys, // but its OpenAI-compatible gateway sometimes surfaces a bare 400 + // "Bad Request" with no body details for both malformed-credentials // and bad-parameter cases. Because the two are indistinguishable from // the message alone, bare 400/"Bad Request" is deliberately NOT // promoted to "invalid key" here — that would mis-classify legitimate // parameter errors (e.g. unsupported `reasoning_budget`, unsupported // `chat_template`) as auth failures. Tests that probe the auth path // (K1) detect "bad request" / "400" themselves; tests that probe // parameter retry (K5) need the original "Bad Request" message to // surface. { match: (ctx) => /Invalid API key|401|Unauthorized/.test(ctx.message), errorClass: AuthenticationError, message: "Invalid NVIDIA NIM API key. Get one at https://build.nvidia.com/settings/api-keys", }, { match: (ctx) => /rate limit|429/.test(ctx.message), errorClass: RateLimitError, message: "NVIDIA NIM rate limit exceeded", }, { match: (ctx) => /404|model_not_found/.test(ctx.message), errorClass: InvalidModelError, message: () => `NVIDIA NIM model '${this.modelName}' not available. Browse the catalog at https://build.nvidia.com/models`, }, { match: (ctx) => /quota|403/.test(ctx.message), errorClass: ProviderError, message: "NVIDIA NIM quota exceeded for your account", }, ...DEFAULT_ERROR_RULES, ]; return classifyProviderError(error, rules, this.providerName, this.modelName); } async validateConfiguration() { return (typeof this.config.apiKey === "string" && this.config.apiKey.trim().length > 0); } getConfiguration() { return { provider: this.providerName, model: this.modelName, defaultModel: getDefaultNimModel(), baseURL: this.config.baseURL, }; } } // Exported for test suites that probe NIM's 400-retry behaviour directly. export { isNimFieldRejection, stripFieldFromJsonBody };