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

380 lines (379 loc) • 15.1 kB
import { PerplexityDeciderModels } from "../constants/enums.js"; import { sniffImageMimeType } from "../utils/imageDetection.js"; import { logger } from "../utils/logger.js"; import { redactUrlForError } from "../utils/logSanitize.js"; import { getProviderModel } from "../utils/providerConfig.js"; import { isRecord, redactCredentials, SystemOneDecisionProvider, } from "./systemOneDecision.js"; const PERPLEXITY_DEFAULT_BASE_URL = "https://api.perplexity.ai"; const PERPLEXITY_ROUTE = "/v1/decisions"; // The same attribution the Perplexity text provider sends (see // configuredOpenAICompat.ts): Perplexity asks to identify NeuroLink on its own // API host only, so a custom endpoint gets no such header. const PERPLEXITY_API_HOST = "api.perplexity.ai"; const PERPLEXITY_INTEGRATION = "neurolink"; /** The only image formats the API reads; anything else is a 400. */ const SUPPORTED_IMAGE_TYPES = new Set([ "image/png", "image/jpeg", "image/webp", ]); const IMAGE_TILE_PIXELS = 32; const MAX_IMAGE_TILES = 2048; /** * Added to the descriptor's `decideMs` for each question. Latency follows the * question count as well as the state: 128 minimal questions took 8.9 s on a * 12,000-token request, about 65 ms for each after the first, so a request at * the 128-question cap would otherwise outlast a flat 10 s and be retried into * the same wait. */ const TIMEOUT_MS_PER_QUESTION = 100; /** * The override is an origin, but the API's own documentation spells its server * as `https://api.perplexity.ai/v1`, and the route already carries `/v1`. Both * spellings reduce to the same base. */ function normalizeBaseURL(raw) { return raw.replace(/\/+$/, "").replace(/\/v1$/i, ""); } /** * A base URL that cannot work is refused up front, and its text is never * repeated: `fetch` rejects a URL with userinfo and echoes it in the error, the * route is appended after a query string or fragment, and any of them can hold a * credential. */ function baseURLProblem(baseURL) { const fix = `Set PERPLEXITY_DECIDER_BASE_URL or pass credentials.perplexityDecider.baseURL to an origin, or leave both unset to use ${PERPLEXITY_DEFAULT_BASE_URL}.`; try { const url = new URL(baseURL); // `new URL()` accepts any scheme, so a host:port with no scheme parses as // one and would reach `fetch` as a network error instead of this message. if (url.protocol !== "https:" && url.protocol !== "http:") { return `The Perplexity base URL must start with https:// or http://. ${fix}`; } return url.username || url.password || url.search || url.hash ? `The Perplexity base URL must not carry credentials, a query string or a fragment. ${fix}` : undefined; } catch { return `The Perplexity base URL is not a valid absolute URL. ${fix}`; } } function isPerplexityApiHost(baseURL) { try { return new URL(baseURL).hostname === PERPLEXITY_API_HOST; } catch { return false; } } /** * The model server answers input past its limit with a 400, not the 413 the * gateway documents, so the kind comes from the text. `error.code` cannot be * used: it is a string, a number or null depending on which layer answered. * * The wording depends on what was sent. Text alone is refused with "Input * length (262144) exceeds or equals model's maximum context length (262144)", * the figure in brackets being the limit and not the size sent; a request with * images is refused with "Total input tokens (A text + B vision = C) exceeds * maximum context length (262144)". Both are matched. */ const OVER_LENGTH_MESSAGE = /exceeds(?: or equals)?(?: model's)? maximum context length/i; /** * Only 401 is `authentication`, and so only 401 trips the breaker. Every other * 4xx is something the caller or an account owner can fix without restarting * the process, so it is a non-retried `invalid_request` rather than a latch. * A 5xx and a 504 (the model not answering in time) are retryable. */ function perplexityErrorKind(status, message) { if (status === 401) { return "authentication"; } if (status === 413 || (status === 400 && OVER_LENGTH_MESSAGE.test(message))) { return "max_tokens_exceeded"; } if (status === 429) { return "rate_limit"; } if (status === 503) { return "overloaded"; } return status >= 500 ? "server" : "invalid_request"; } /** * `{"error":{"message","type","code","param"}}`. A 404 and a 405 have an empty * body and a 504 can be an HTML page, so a body that is not that envelope reads * as undefined and the caller falls back to the status. `param` names the * offending field on some 400s. */ function readPerplexityErrorMessage(body) { if (!isRecord(body) || !isRecord(body.error)) { return undefined; } const { message, param } = body.error; if (typeof message !== "string" || message.length === 0) { return undefined; } return typeof param === "string" && param !== "" && !message.includes(param) ? `${message} (field: ${param})` : message; } function readPngSize(bytes) { return bytes.length >= 24 && bytes.toString("ascii", 12, 16) === "IHDR" ? { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) } : undefined; } /** Walks the marker segments to the first frame header (SOF0-SOF15). */ function readJpegSize(bytes) { let offset = 2; while (offset + 4 <= bytes.length) { if (bytes[offset] !== 0xff) { return undefined; } const marker = bytes[offset + 1]; if (marker === 0xff) { offset += 1; } else if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) { offset += 2; } else if (marker === 0xd9 || marker === 0xda) { return undefined; } else if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { return offset + 9 <= bytes.length ? { height: bytes.readUInt16BE(offset + 5), width: bytes.readUInt16BE(offset + 7), } : undefined; } else { offset += 2 + bytes.readUInt16BE(offset + 2); } } return undefined; } function readWebpSize(bytes) { if (bytes.length < 30) { return undefined; } switch (bytes.toString("ascii", 12, 16)) { case "VP8X": return { width: 1 + bytes.readUIntLE(24, 3), height: 1 + bytes.readUIntLE(27, 3), }; case "VP8L": { const bits = bytes.readUInt32LE(21); return bytes[20] === 0x2f ? { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 } : undefined; } case "VP8 ": return { width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff, }; default: return undefined; } } function readImageSize(bytes) { switch (sniffImageMimeType(bytes)) { case "image/png": return readPngSize(bytes); case "image/jpeg": return readJpegSize(bytes); case "image/webp": return readWebpSize(bytes); default: return undefined; } } /** * Why this image cannot be sent, or undefined when it can. The data URL is * never repeated: it is megabytes of base64. * * The size check exists because the API does not refuse an oversized image: it * holds the request for about a minute and then answers 504. An image whose * dimensions cannot be read is sent as is. */ function imageProblem(dataUrl, position) { const header = /^data:([a-z]+\/[a-z0-9.+-]+);base64,/i.exec(dataUrl); if (!header || !SUPPORTED_IMAGE_TYPES.has(header[1].toLowerCase())) { return `Image ${position} is not a PNG, JPEG or WebP image, the only formats Perplexity reads.`; } const size = readImageSize(Buffer.from(dataUrl.slice(header[0].length), "base64")); if (!size) { return undefined; } const tiles = Math.round(size.width / IMAGE_TILE_PIXELS) * Math.round(size.height / IMAGE_TILE_PIXELS); return tiles > MAX_IMAGE_TILES ? `Image ${position} is ${size.width}x${size.height} pixels; Perplexity reads at most ${MAX_IMAGE_TILES} tiles of ${IMAGE_TILE_PIXELS}x${IMAGE_TILE_PIXELS} pixels per image (1440x1440 and 2048x1024 fit), and a larger one stalls until the request times out. Resize it first.` : undefined; } /** * The API takes a string, an object or an array as `state`, so a number or a * boolean is sent as its text. */ function wireState(state) { return typeof state === "number" || typeof state === "boolean" ? String(state) : state; } /** * Perplexity Provider — the `decide` inference type only. * * `pplx-decider-v1-27b` answers the same typed `noul` / `choice` / `score` * questions as the other decision providers, in one batched pass, and also reads * images. It is a hosted API with a public endpoint, so unlike the self-hosted * providers it needs only a key. The request loop, retries, auth circuit * breaker and answer parsing are shared with every decision provider in * {@link SystemOneDecisionProvider}. * * The key is the same `PERPLEXITY_API_KEY` the Sonar text provider reads, so a * host that has configured that provider has also configured this one. * * @see https://docs.perplexity.ai/api-reference/decisions-post */ export class PerplexityDeciderProvider extends SystemOneDecisionProvider { apiKey; baseURL; constructor(modelName, sdk, _region, credentials) { super(modelName, "perplexity-decider", sdk); this.apiKey = credentials?.apiKey?.trim() || (process.env.PERPLEXITY_API_KEY?.trim() ?? ""); // `||`, not `??`, so a blank override counts as unset. this.baseURL = normalizeBaseURL(credentials?.baseURL?.trim() || process.env.PERPLEXITY_DECIDER_BASE_URL?.trim() || PERPLEXITY_DEFAULT_BASE_URL); // A base URL that `baseURLProblem` refuses is not logged at all: it can // carry `user:pass@` or a `?token=`, and `redactUrlForError` cannot be // trusted with one that has no scheme, because `user:pass@host:80` parses as // the scheme `user:` and is rebuilt with the password intact. logger.debug("Perplexity decision provider initialized (decide only)", { modelName: this.modelName, baseURL: baseURLProblem(this.baseURL) ? "(invalid)" : redactUrlForError(this.baseURL), }); } getDefaultModel() { return getProviderModel("PERPLEXITY_DECIDER_MODEL", PerplexityDeciderModels.PPLX_DECIDER_V1_27B); } vendorLabel() { return "Perplexity"; } vendorDisplayName() { return "Perplexity Decisions"; } decisionApiKey() { return this.apiKey; } missingKeyMessage() { return "Perplexity requires an API key. Set PERPLEXITY_API_KEY or pass credentials.perplexityDecider.apiKey."; } missingConfigMessage() { return baseURLProblem(this.baseURL); } decisionEndpoint() { return `${this.baseURL}${PERPLEXITY_ROUTE}`; } decisionHeaders() { return { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json", ...(isPerplexityApiHost(this.baseURL) ? { "X-Pplx-Integration": PERPLEXITY_INTEGRATION } : {}), }; } /** * `model` is always sent: the API answers 400 to a request that names none. * Images travel inside `state`, which becomes an array of the caller's state * followed by one OpenAI-style `image_url` part per image; the API has no * separate images field and rejects unknown ones. */ buildDecisionBody(state, questions, model, media) { const images = media?.images ?? []; if (images.length === 0) { return { model, state: wireState(state), questions }; } images.forEach((dataUrl, index) => { const problem = imageProblem(dataUrl, index + 1); if (problem) { throw this.decisionError({ kind: "invalid_request", message: problem, retryable: false, }); } }); const content = Array.isArray(state) ? state : state === "" ? [] : [wireState(state)]; return { model, state: [ ...content, ...images.map((url) => ({ type: "image_url", image_url: { url } })), ], questions, }; } parseDecisionError(status, payload, requestId) { const fallback = `Perplexity request failed with HTTP ${status}`; // Strip anything shaped like a Perplexity key as well as the configured // one. Keys carry `_` and `-`, so the class includes them. A key has 48 // characters after the prefix, and the floor sits well under that but above // any model name: the 400 that rejects `pplx-decider-v1-27b-latest` has to // keep naming it. const message = redactCredentials(readPerplexityErrorMessage(payload) ?? fallback, this.apiKey) .replace(/\bpplx-[A-Za-z0-9_-]{30,}/g, "[redacted]") .trim() || fallback; const kind = perplexityErrorKind(status, message); return { kind, message, status, requestId, retryable: kind === "rate_limit" || kind === "overloaded" || kind === "server", }; } /** * A 401, a 404 and a 504 carry no request id, so this is undefined for them. */ readRequestId(headers) { return headers.get("x-request-id") ?? undefined; } defaultTimeoutMs(questionCount) { const allowance = this.getDescriptorDecideMs(); return allowance === undefined ? undefined : allowance + questionCount * TIMEOUT_MS_PER_QUESTION; } /** * `Retry-After` is whole seconds on a 429, or an HTTP date. Anything else, a * zero and a date already past all read as undefined, so the retry takes the * default backoff instead of going out at once. `Number()` is not used: it * reads "0x10" as 16 and "1e3" as 1,000. */ readRetryAfterMs(headers) { const raw = headers.get("retry-after")?.trim(); if (!raw) { return undefined; } const ms = /^\d+$/.test(raw) ? Number(raw) * 1000 : Date.parse(raw) - Date.now(); return Number.isFinite(ms) && ms > 0 ? Math.ceil(ms) : undefined; } }