@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
191 lines (190 loc) • 9.66 kB
JavaScript
import { logger } from "../utils/logger.js";
import { redactUrlCredentials } from "../utils/logSanitize.js";
import { getProviderModel, resolveOpenAICompatConfig, } from "../utils/providerConfig.js";
import { classifyProviderError } from "../utils/errorClassifier.js";
import { TimeoutError } from "../utils/timeout.js";
import { OpenAIChatCompletionsProvider } from "./openaiChatCompletionsBase.js";
const PERPLEXITY_API_HOST = "api.perplexity.ai";
const PERPLEXITY_INTEGRATION = "neurolink";
/**
* Collapse OpenAI's `content` union down to the plain string that
* string-only vendors accept. Image parts carry no string representation and
* are dropped — a provider that cannot accept a content array cannot accept
* inline images either.
*/
const flattenMessageContent = (content) => {
if (typeof content === "string") {
return content;
}
// An assistant message with tool_calls legitimately has null content;
// the empty string is its string-only equivalent.
if (content === null || content === undefined) {
return "";
}
return content
.map((part) => (part.type === "text" ? part.text : ""))
.join("");
};
/**
* Generic OpenAI-compatible provider driven entirely by an
* OpenAICompatCatalogEntry. Replaces a hand-written subclass for any
* provider whose only differences from its siblings are credentials, base
* URL, model defaults, and error-classification rules — see
* OPENAI_COMPAT_CATALOG in openaiCompatCatalog.ts for the entries.
*
* If a provider needs a real hook override (adjustRequestBody,
* adjustBodyAfter400, getChatCompletionsURL, getAuthHeaders, ...) that isn't
* expressible as one of the catalog's data-driven quirks (messageContentFormat,
* responseFormatDowngrade, replayReasoningContent,
* supportsStructuredOutputWithTools, ...) it does NOT belong in the catalog —
* write a dedicated subclass instead (see azureOpenai.ts).
*
* The exception is a WIRE DIALECT: a vendor that speaks OpenAI for ordinary
* chat but encodes one part of the request differently. Expressing that as
* data (the catalog's `messageContentFormat` / `responseFormatDowngrade`)
* keeps the provider a one-JSON-file entry instead of promoting it to a
* hand-written subclass over a single incompatibility.
*
* Today that is Cloudflare Workers AI, whose OpenAI-compatible endpoint
* accepts `messages[].content` only as a plain string — never the
* content-parts array OpenAI allows, and never the `null` that OpenAI uses
* on an assistant message carrying tool_calls. Its schema rejects both with
* HTTP 400 ("Type mismatch of '/messages/N/content'"). That bites precisely
* on the second turn of a tool call, so single-turn chat looks healthy while
* every tool round-trip fails. Normalizing content to a string below is the
* whole fix: tools, tool_choice, the `tool` role and `tool_calls` are all
* accepted as-is.
*
* The same shape covers DeepSeek: its `/chat/completions` rejects
* `response_format: { type: "json_schema" }` outright ("This response_format
* type is unavailable now"), but accepts `{ type: "json_object" }`. The
* catalog's `responseFormatDowngrade` quirk downgrades the request so
* `generate({ schema })` keeps working, matching the
* `supportsStructuredOutputs: false` behavior of the `@ai-sdk/openai-compatible`
* provider this replaced.
*/
export class ConfiguredOpenAICompatProvider extends OpenAIChatCompletionsProvider {
entry;
constructor(entry, modelName, sdk, credentials) {
const { apiKey, baseURL } = resolveOpenAICompatConfig(entry, credentials);
// BaseProvider's constructor calls `this.getDefaultModel()` /
// `this.getProviderName()` synchronously inside `super()`, before this
// class's own constructor body (or field initializers) ever run — so
// `this.entry` is not yet assigned at that point and those overrides
// would read `undefined.modelEnvVar`. `entry.providerName` is always
// defined, so passing it straight through makes the base constructor's
// `providerName || this.getProviderName()` short-circuit; resolving the
// model up front and always passing a truthy `modelName` does the same
// for `getDefaultModel()`. Both overrides remain correct for any call
// made after construction, once `this.entry` is set below.
const resolvedModelName = modelName || getProviderModel(entry.modelEnvVar, entry.defaultModel);
super(entry.providerName, resolvedModelName, sdk, { baseURL, apiKey });
this.entry = entry;
logger.debug(`${entry.configOptions.providerName} Provider initialized`, {
modelName: this.modelName,
providerName: this.providerName,
baseURL: redactUrlCredentials(this.config.baseURL),
});
}
getProviderName() {
return this.entry.providerName;
}
getDefaultModel() {
return getProviderModel(this.entry.modelEnvVar, this.entry.defaultModel);
}
getFallbackModelName() {
return this.entry.fallbackModelName;
}
getFallbackModels() {
return this.entry.fallbackModels;
}
/**
* See CatalogQuirks.authHeaderStyle. The base class's default is
* `Authorization: Bearer <apiKey>`; "x-api-key" vendors (Reka's OpenAPI
* spec declares exactly one security scheme, an apiKey in header
* `X-Api-Key`) get that header instead, with no Authorization header sent
* at all — mirrors AzureOpenAIProvider's own getAuthHeaders() override for
* its `api-key` header.
*/
getAuthHeaders() {
const headers = this.entry.authHeaderStyle === "x-api-key"
? { "X-Api-Key": this.config.apiKey }
: super.getAuthHeaders();
// Perplexity asks to identify NeuroLink on its own API host only; a
// custom Perplexity-compatible endpoint gets no attribution header.
if (this.entry.providerName === "perplexity" &&
new URL(this.config.baseURL).hostname === PERPLEXITY_API_HOST) {
headers["X-Pplx-Integration"] = PERPLEXITY_INTEGRATION;
}
return headers;
}
/**
* The catalog's `capabilities.tools` is the vendor's own answer, probed on
* the wire when the entry was written; the model registry (the base
* default) knows nothing about Tier-2 models and answers "supported" for
* every unknown id. A vendor that declares tools: false must never receive
* a `tools` array — Mancer's free model rejects one with 400 — so the
* declaration wins here and the registry is only consulted otherwise.
* Like the other entry-reading overrides above, this runs only after
* construction: BaseProvider merely closes over it.
*/
supportsTools() {
if (this.entry.supportsTools === false) {
return false;
}
return super.supportsTools();
}
/**
* The base class drops `response_format` from any request carrying tools,
* because a generic OpenAI-compatible backend may honour it over tool
* calling and answer with final-shape JSON on step one instead of running
* the agentic loop. For a catalog entry that is not a guess — the flag
* comes from a live wire probe (`capabilities.structuredOutputWithTools`)
* — so an entry that declares the combination supported sends both and
* gets the object from the vendor's own constrained decoding in ONE
* request, instead of falling back to a tool-free re-ask.
*
* Conservative by default: only an explicit `true` opts in. A vendor that
* declares support but rejects the combination at runtime is still caught
* by `isToolsSchemaConflictError` and retried without structured output,
* so a stale flag costs one request rather than the turn.
*/
suppressResponseFormatWithTools() {
return this.entry.supportsStructuredOutputWithTools !== true;
}
adjustRequestBody(body, modelId) {
const adjusted = super.adjustRequestBody(body, modelId);
if (this.entry.messageContentFormat !== "string") {
return adjusted;
}
return {
...adjusted,
messages: adjusted.messages.map((message) => ({
...message,
content: flattenMessageContent(message.content),
})),
};
}
adjustResponseFormat(rf, modelId) {
if (this.entry.responseFormatDowngrade === "json-schema-to-json-object" &&
rf?.type === "json_schema") {
return { type: "json_object" };
}
return super.adjustResponseFormat(rf, modelId);
}
replayReasoningContent() {
return this.entry.replayReasoningContent === true;
}
formatProviderError(error) {
// classifyProviderError hard-codes TimeoutError -> NetworkError ahead
// of any rule table and does not allow a per-provider override. An
// entry can opt out of that default via timeoutErrorClass (currently
// only Groq, reproducing its pre-migration subclass's own TimeoutError
// interception) — checked here, before ever reaching the shared
// classifier, so every other entry still gets its unmodified default.
if (error instanceof TimeoutError && this.entry.timeoutErrorClass) {
return new this.entry.timeoutErrorClass(`${this.entry.configOptions.providerName} request timed out: ${error.message}`, this.entry.providerName);
}
return classifyProviderError(error, this.entry.errorRules, this.entry.providerName, this.modelName);
}
}