@juspay/neurolink 12.43.3 → 12.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,373 @@
1
+ import { PerplexityDeciderModels } from "../constants/enums.js";
2
+ import { sniffImageMimeType } from "../utils/imageDetection.js";
3
+ import { logger } from "../utils/logger.js";
4
+ import { redactUrlForError } from "../utils/logSanitize.js";
5
+ import { getProviderModel } from "../utils/providerConfig.js";
6
+ import { isRecord, redactCredentials, SystemOneDecisionProvider, } from "./systemOneDecision.js";
7
+ const PERPLEXITY_DEFAULT_BASE_URL = "https://api.perplexity.ai";
8
+ const PERPLEXITY_ROUTE = "/v1/decisions";
9
+ // The same attribution the Perplexity text provider sends (see
10
+ // configuredOpenAICompat.ts): Perplexity asks to identify NeuroLink on its own
11
+ // API host only, so a custom endpoint gets no such header.
12
+ const PERPLEXITY_API_HOST = "api.perplexity.ai";
13
+ const PERPLEXITY_INTEGRATION = "neurolink";
14
+ /** The only image formats the API reads; anything else is a 400. */
15
+ const SUPPORTED_IMAGE_TYPES = new Set([
16
+ "image/png",
17
+ "image/jpeg",
18
+ "image/webp",
19
+ ]);
20
+ const IMAGE_TILE_PIXELS = 32;
21
+ const MAX_IMAGE_TILES = 2048;
22
+ /**
23
+ * Added to the descriptor's `decideMs` for each question. Latency follows the
24
+ * question count as well as the state: 128 minimal questions took 8.9 s on a
25
+ * 12,000-token request, about 65 ms for each after the first, so a request at
26
+ * the 128-question cap would otherwise outlast a flat 10 s and be retried into
27
+ * the same wait.
28
+ */
29
+ const TIMEOUT_MS_PER_QUESTION = 100;
30
+ /**
31
+ * The override is an origin, but the API's own documentation spells its server
32
+ * as `https://api.perplexity.ai/v1`, and the route already carries `/v1`. Both
33
+ * spellings reduce to the same base.
34
+ */
35
+ function normalizeBaseURL(raw) {
36
+ return raw.replace(/\/+$/, "").replace(/\/v1$/i, "");
37
+ }
38
+ /**
39
+ * A base URL that cannot work is refused up front, and its text is never
40
+ * repeated: `fetch` rejects a URL with userinfo and echoes it in the error, the
41
+ * route is appended after a query string or fragment, and any of them can hold a
42
+ * credential.
43
+ */
44
+ function baseURLProblem(baseURL) {
45
+ 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}.`;
46
+ try {
47
+ const url = new URL(baseURL);
48
+ // `new URL()` accepts any scheme, so a host:port with no scheme parses as
49
+ // one and would reach `fetch` as a network error instead of this message.
50
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
51
+ return `The Perplexity base URL must start with https:// or http://. ${fix}`;
52
+ }
53
+ return url.username || url.password || url.search || url.hash
54
+ ? `The Perplexity base URL must not carry credentials, a query string or a fragment. ${fix}`
55
+ : undefined;
56
+ }
57
+ catch {
58
+ return `The Perplexity base URL is not a valid absolute URL. ${fix}`;
59
+ }
60
+ }
61
+ function isPerplexityApiHost(baseURL) {
62
+ try {
63
+ return new URL(baseURL).hostname === PERPLEXITY_API_HOST;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ /**
70
+ * The model server answers input past its limit with a 400, not the 413 the
71
+ * gateway documents, so the kind comes from the text. `error.code` cannot be
72
+ * used: it is a string, a number or null depending on which layer answered.
73
+ */
74
+ const OVER_LENGTH_MESSAGE = /exceeds or equals model's maximum context length/i;
75
+ /**
76
+ * Only 401 is `authentication`, and so only 401 trips the breaker. Every other
77
+ * 4xx is something the caller or an account owner can fix without restarting
78
+ * the process, so it is a non-retried `invalid_request` rather than a latch.
79
+ * A 5xx and a 504 (the model not answering in time) are retryable.
80
+ */
81
+ function perplexityErrorKind(status, message) {
82
+ if (status === 401) {
83
+ return "authentication";
84
+ }
85
+ if (status === 413 || (status === 400 && OVER_LENGTH_MESSAGE.test(message))) {
86
+ return "max_tokens_exceeded";
87
+ }
88
+ if (status === 429) {
89
+ return "rate_limit";
90
+ }
91
+ if (status === 503) {
92
+ return "overloaded";
93
+ }
94
+ return status >= 500 ? "server" : "invalid_request";
95
+ }
96
+ /**
97
+ * `{"error":{"message","type","code","param"}}`. A 404 and a 405 have an empty
98
+ * body and a 504 can be an HTML page, so a body that is not that envelope reads
99
+ * as undefined and the caller falls back to the status. `param` names the
100
+ * offending field on some 400s.
101
+ */
102
+ function readPerplexityErrorMessage(body) {
103
+ if (!isRecord(body) || !isRecord(body.error)) {
104
+ return undefined;
105
+ }
106
+ const { message, param } = body.error;
107
+ if (typeof message !== "string" || message.length === 0) {
108
+ return undefined;
109
+ }
110
+ return typeof param === "string" && param !== "" && !message.includes(param)
111
+ ? `${message} (field: ${param})`
112
+ : message;
113
+ }
114
+ function readPngSize(bytes) {
115
+ return bytes.length >= 24 && bytes.toString("ascii", 12, 16) === "IHDR"
116
+ ? { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }
117
+ : undefined;
118
+ }
119
+ /** Walks the marker segments to the first frame header (SOF0-SOF15). */
120
+ function readJpegSize(bytes) {
121
+ let offset = 2;
122
+ while (offset + 4 <= bytes.length) {
123
+ if (bytes[offset] !== 0xff) {
124
+ return undefined;
125
+ }
126
+ const marker = bytes[offset + 1];
127
+ if (marker === 0xff) {
128
+ offset += 1;
129
+ }
130
+ else if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) {
131
+ offset += 2;
132
+ }
133
+ else if (marker === 0xd9 || marker === 0xda) {
134
+ return undefined;
135
+ }
136
+ else if (marker >= 0xc0 &&
137
+ marker <= 0xcf &&
138
+ marker !== 0xc4 &&
139
+ marker !== 0xc8 &&
140
+ marker !== 0xcc) {
141
+ return offset + 9 <= bytes.length
142
+ ? {
143
+ height: bytes.readUInt16BE(offset + 5),
144
+ width: bytes.readUInt16BE(offset + 7),
145
+ }
146
+ : undefined;
147
+ }
148
+ else {
149
+ offset += 2 + bytes.readUInt16BE(offset + 2);
150
+ }
151
+ }
152
+ return undefined;
153
+ }
154
+ function readWebpSize(bytes) {
155
+ if (bytes.length < 30) {
156
+ return undefined;
157
+ }
158
+ switch (bytes.toString("ascii", 12, 16)) {
159
+ case "VP8X":
160
+ return {
161
+ width: 1 + bytes.readUIntLE(24, 3),
162
+ height: 1 + bytes.readUIntLE(27, 3),
163
+ };
164
+ case "VP8L": {
165
+ const bits = bytes.readUInt32LE(21);
166
+ return bytes[20] === 0x2f
167
+ ? { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }
168
+ : undefined;
169
+ }
170
+ case "VP8 ":
171
+ return {
172
+ width: bytes.readUInt16LE(26) & 0x3fff,
173
+ height: bytes.readUInt16LE(28) & 0x3fff,
174
+ };
175
+ default:
176
+ return undefined;
177
+ }
178
+ }
179
+ function readImageSize(bytes) {
180
+ switch (sniffImageMimeType(bytes)) {
181
+ case "image/png":
182
+ return readPngSize(bytes);
183
+ case "image/jpeg":
184
+ return readJpegSize(bytes);
185
+ case "image/webp":
186
+ return readWebpSize(bytes);
187
+ default:
188
+ return undefined;
189
+ }
190
+ }
191
+ /**
192
+ * Why this image cannot be sent, or undefined when it can. The data URL is
193
+ * never repeated: it is megabytes of base64.
194
+ *
195
+ * The size check exists because the API does not refuse an oversized image: it
196
+ * holds the request for about a minute and then answers 504. An image whose
197
+ * dimensions cannot be read is sent as is.
198
+ */
199
+ function imageProblem(dataUrl, position) {
200
+ const header = /^data:([a-z]+\/[a-z0-9.+-]+);base64,/i.exec(dataUrl);
201
+ if (!header || !SUPPORTED_IMAGE_TYPES.has(header[1].toLowerCase())) {
202
+ return `Image ${position} is not a PNG, JPEG or WebP image, the only formats Perplexity reads.`;
203
+ }
204
+ const size = readImageSize(Buffer.from(dataUrl.slice(header[0].length), "base64"));
205
+ if (!size) {
206
+ return undefined;
207
+ }
208
+ const tiles = Math.round(size.width / IMAGE_TILE_PIXELS) *
209
+ Math.round(size.height / IMAGE_TILE_PIXELS);
210
+ return tiles > MAX_IMAGE_TILES
211
+ ? `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.`
212
+ : undefined;
213
+ }
214
+ /**
215
+ * The API takes a string, an object or an array as `state`, so a number or a
216
+ * boolean is sent as its text.
217
+ */
218
+ function wireState(state) {
219
+ return typeof state === "number" || typeof state === "boolean"
220
+ ? String(state)
221
+ : state;
222
+ }
223
+ /**
224
+ * Perplexity Provider — the `decide` inference type only.
225
+ *
226
+ * `pplx-decider-v1-27b` answers the same typed `noul` / `choice` / `score`
227
+ * questions as the other decision providers, in one batched pass, and also reads
228
+ * images. It is a hosted API with a public endpoint, so unlike the self-hosted
229
+ * providers it needs only a key. The request loop, retries, auth circuit
230
+ * breaker and answer parsing are shared with every decision provider in
231
+ * {@link SystemOneDecisionProvider}.
232
+ *
233
+ * The key is the same `PERPLEXITY_API_KEY` the Sonar text provider reads, so a
234
+ * host that has configured that provider has also configured this one.
235
+ *
236
+ * @see https://docs.perplexity.ai/api-reference/decisions-post
237
+ */
238
+ export class PerplexityDeciderProvider extends SystemOneDecisionProvider {
239
+ apiKey;
240
+ baseURL;
241
+ constructor(modelName, sdk, _region, credentials) {
242
+ super(modelName, "perplexity-decider", sdk);
243
+ this.apiKey =
244
+ credentials?.apiKey?.trim() ||
245
+ (process.env.PERPLEXITY_API_KEY?.trim() ?? "");
246
+ // `||`, not `??`, so a blank override counts as unset.
247
+ this.baseURL = normalizeBaseURL(credentials?.baseURL?.trim() ||
248
+ process.env.PERPLEXITY_DECIDER_BASE_URL?.trim() ||
249
+ PERPLEXITY_DEFAULT_BASE_URL);
250
+ // A base URL that `baseURLProblem` refuses is not logged at all: it can
251
+ // carry `user:pass@` or a `?token=`, and `redactUrlForError` cannot be
252
+ // trusted with one that has no scheme, because `user:pass@host:80` parses as
253
+ // the scheme `user:` and is rebuilt with the password intact.
254
+ logger.debug("Perplexity decision provider initialized (decide only)", {
255
+ modelName: this.modelName,
256
+ baseURL: baseURLProblem(this.baseURL)
257
+ ? "(invalid)"
258
+ : redactUrlForError(this.baseURL),
259
+ });
260
+ }
261
+ getDefaultModel() {
262
+ return getProviderModel("PERPLEXITY_DECIDER_MODEL", PerplexityDeciderModels.PPLX_DECIDER_V1_27B);
263
+ }
264
+ vendorLabel() {
265
+ return "Perplexity";
266
+ }
267
+ vendorDisplayName() {
268
+ return "Perplexity Decisions";
269
+ }
270
+ decisionApiKey() {
271
+ return this.apiKey;
272
+ }
273
+ missingKeyMessage() {
274
+ return "Perplexity requires an API key. Set PERPLEXITY_API_KEY or pass credentials.perplexityDecider.apiKey.";
275
+ }
276
+ missingConfigMessage() {
277
+ return baseURLProblem(this.baseURL);
278
+ }
279
+ decisionEndpoint() {
280
+ return `${this.baseURL}${PERPLEXITY_ROUTE}`;
281
+ }
282
+ decisionHeaders() {
283
+ return {
284
+ Authorization: `Bearer ${this.apiKey}`,
285
+ "Content-Type": "application/json",
286
+ ...(isPerplexityApiHost(this.baseURL)
287
+ ? { "X-Pplx-Integration": PERPLEXITY_INTEGRATION }
288
+ : {}),
289
+ };
290
+ }
291
+ /**
292
+ * `model` is always sent: the API answers 400 to a request that names none.
293
+ * Images travel inside `state`, which becomes an array of the caller's state
294
+ * followed by one OpenAI-style `image_url` part per image; the API has no
295
+ * separate images field and rejects unknown ones.
296
+ */
297
+ buildDecisionBody(state, questions, model, media) {
298
+ const images = media?.images ?? [];
299
+ if (images.length === 0) {
300
+ return { model, state: wireState(state), questions };
301
+ }
302
+ images.forEach((dataUrl, index) => {
303
+ const problem = imageProblem(dataUrl, index + 1);
304
+ if (problem) {
305
+ throw this.decisionError({
306
+ kind: "invalid_request",
307
+ message: problem,
308
+ retryable: false,
309
+ });
310
+ }
311
+ });
312
+ const content = Array.isArray(state)
313
+ ? state
314
+ : state === ""
315
+ ? []
316
+ : [wireState(state)];
317
+ return {
318
+ model,
319
+ state: [
320
+ ...content,
321
+ ...images.map((url) => ({ type: "image_url", image_url: { url } })),
322
+ ],
323
+ questions,
324
+ };
325
+ }
326
+ parseDecisionError(status, payload, requestId) {
327
+ const fallback = `Perplexity request failed with HTTP ${status}`;
328
+ // Strip anything shaped like a Perplexity key as well as the configured
329
+ // one. Keys carry `_` and `-`, so the class includes them. A key has 48
330
+ // characters after the prefix, and the floor sits well under that but above
331
+ // any model name: the 400 that rejects `pplx-decider-v1-27b-latest` has to
332
+ // keep naming it.
333
+ const message = redactCredentials(readPerplexityErrorMessage(payload) ?? fallback, this.apiKey)
334
+ .replace(/\bpplx-[A-Za-z0-9_-]{30,}/g, "[redacted]")
335
+ .trim() || fallback;
336
+ const kind = perplexityErrorKind(status, message);
337
+ return {
338
+ kind,
339
+ message,
340
+ status,
341
+ requestId,
342
+ retryable: kind === "rate_limit" || kind === "overloaded" || kind === "server",
343
+ };
344
+ }
345
+ /**
346
+ * A 401, a 404 and a 504 carry no request id, so this is undefined for them.
347
+ */
348
+ readRequestId(headers) {
349
+ return headers.get("x-request-id") ?? undefined;
350
+ }
351
+ defaultTimeoutMs(questionCount) {
352
+ const allowance = this.getDescriptorDecideMs();
353
+ return allowance === undefined
354
+ ? undefined
355
+ : allowance + questionCount * TIMEOUT_MS_PER_QUESTION;
356
+ }
357
+ /**
358
+ * `Retry-After` is whole seconds on a 429, or an HTTP date. Anything else, a
359
+ * zero and a date already past all read as undefined, so the retry takes the
360
+ * default backoff instead of going out at once. `Number()` is not used: it
361
+ * reads "0x10" as 16 and "1e3" as 1,000.
362
+ */
363
+ readRetryAfterMs(headers) {
364
+ const raw = headers.get("retry-after")?.trim();
365
+ if (!raw) {
366
+ return undefined;
367
+ }
368
+ const ms = /^\d+$/.test(raw)
369
+ ? Number(raw) * 1000
370
+ : Date.parse(raw) - Date.now();
371
+ return Number.isFinite(ms) && ms > 0 ? Math.ceil(ms) : undefined;
372
+ }
373
+ }
@@ -69,6 +69,18 @@ export declare abstract class SystemOneDecisionProvider extends BaseProvider {
69
69
  * every SDK exposing it call it `boolean`.
70
70
  */
71
71
  protected encodeQuestion(question: DecisionQuestion): Record<string, unknown>;
72
+ /**
73
+ * How long a rate-limited or unavailable response asks the caller to wait.
74
+ * Undefined keeps the default backoff. A wait longer than what is left of the
75
+ * attempt's timeout is not taken: the error is thrown at once, so a fail-open
76
+ * consumer is not held for the length of a vendor's cooldown.
77
+ */
78
+ protected readRetryAfterMs(_headers: Headers): number | undefined;
79
+ /**
80
+ * The per-attempt timeout when the caller sets none. A provider whose latency
81
+ * grows with the number of questions scales the descriptor's allowance here.
82
+ */
83
+ protected defaultTimeoutMs(_questionCount: number): number | undefined;
72
84
  /** Per-question confidence a transport reports outside the answer objects. */
73
85
  protected reportedConfidence(_payload: unknown): Record<string, number>;
74
86
  protected resolveResponseModel(payload: Record<string, unknown>, requestedModel: string): string;
@@ -149,7 +149,28 @@ export function redactCredentials(message, apiKey) {
149
149
  .replace(/data:[a-z]+\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/gi, "[media]")
150
150
  .trim();
151
151
  }
152
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
152
+ /**
153
+ * Rejects with the signal's reason when it aborts, so a caller tearing a turn
154
+ * down is not held, nor its process kept alive, for the length of a vendor's
155
+ * Retry-After wait. The half-second backoff after a transport error is waited
156
+ * out without a signal: a rejection there would leave the catch block that is
157
+ * already handling that error.
158
+ */
159
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
160
+ if (signal?.aborted) {
161
+ reject(signal.reason);
162
+ return;
163
+ }
164
+ const timer = setTimeout(() => {
165
+ signal?.removeEventListener("abort", onAbort);
166
+ resolve();
167
+ }, ms);
168
+ function onAbort() {
169
+ clearTimeout(timer);
170
+ reject(signal?.reason);
171
+ }
172
+ signal?.addEventListener("abort", onAbort, { once: true });
173
+ });
153
174
  /**
154
175
  * `estimateTokens` assumes ~4 characters per token. That holds for English
155
176
  * and is several times too generous for other scripts on an English
@@ -214,6 +235,22 @@ export class SystemOneDecisionProvider extends BaseProvider {
214
235
  }
215
236
  return question;
216
237
  }
238
+ /**
239
+ * How long a rate-limited or unavailable response asks the caller to wait.
240
+ * Undefined keeps the default backoff. A wait longer than what is left of the
241
+ * attempt's timeout is not taken: the error is thrown at once, so a fail-open
242
+ * consumer is not held for the length of a vendor's cooldown.
243
+ */
244
+ readRetryAfterMs(_headers) {
245
+ return undefined;
246
+ }
247
+ /**
248
+ * The per-attempt timeout when the caller sets none. A provider whose latency
249
+ * grows with the number of questions scales the descriptor's allowance here.
250
+ */
251
+ defaultTimeoutMs(_questionCount) {
252
+ return this.getDescriptorDecideMs();
253
+ }
217
254
  /** Per-question confidence a transport reports outside the answer objects. */
218
255
  reportedConfidence(_payload) {
219
256
  return {};
@@ -306,7 +343,7 @@ export class SystemOneDecisionProvider extends BaseProvider {
306
343
  const body = JSON.stringify(this.buildDecisionBody(request.state, wireQuestions, resolvedModel, media));
307
344
  this.assertWithinRequestBytes(body);
308
345
  const timeoutMs = request.timeoutMs ??
309
- this.getDescriptorDecideMs() ??
346
+ this.defaultTimeoutMs(questionEntries.length) ??
310
347
  this.defaultTimeout ??
311
348
  DEFAULT_DECISION_TIMEOUT_MS;
312
349
  let lastError;
@@ -346,7 +383,14 @@ export class SystemOneDecisionProvider extends BaseProvider {
346
383
  if (!lastError.retryable || attempt === this.maxRetries) {
347
384
  throw this.decisionError(lastError);
348
385
  }
349
- await sleep(2 ** attempt * 250 + Math.random() * 250);
386
+ const retryAfterMs = this.readRetryAfterMs(response.headers);
387
+ // The wait comes out of this attempt's own budget, so a slow answer
388
+ // with a long Retry-After cannot be followed by a second full attempt.
389
+ if (retryAfterMs !== undefined &&
390
+ retryAfterMs > timeoutMs - (Date.now() - startedAt)) {
391
+ throw this.decisionError(lastError);
392
+ }
393
+ await sleep(retryAfterMs ?? 2 ** attempt * 250 + Math.random() * 250, request.signal);
350
394
  continue;
351
395
  }
352
396
  if (!isRecord(payload) || !isRecord(payload.answers)) {
@@ -426,7 +470,7 @@ export class SystemOneDecisionProvider extends BaseProvider {
426
470
  */
427
471
  async prepareMedia(request) {
428
472
  const limits = PROVIDER_DESCRIPTORS_BY_NAME.get(this.providerName)?.decisionLimits;
429
- const prepared = await prepareDecisionMedia(request, limits?.media, this.vendorLabel(), listMediaDecisionProviders());
473
+ const prepared = await prepareDecisionMedia(request, limits?.media, this.vendorLabel(), listMediaDecisionProviders({ video: request.video !== undefined }));
430
474
  if (prepared.status === "refused") {
431
475
  throw this.decisionError({
432
476
  kind: "invalid_request",
@@ -26,7 +26,8 @@ export type ClassifierDifficulty = "trivial" | "simple" | "moderate" | "hard" |
26
26
  * *calibrated* confidence rather than a self-reported one.
27
27
  * - `auto` — `jev` when a decision provider is configured, in the environment
28
28
  * or in SDK credentials (`TYPESAFE_API_KEY`, `LAYA_API_KEY` with
29
- * `LAYA_BASE_URL`, or `XOR_API_KEY` with `XOR_BASE_URL`), otherwise
29
+ * `LAYA_BASE_URL`, `XOR_API_KEY` with `XOR_BASE_URL`, or
30
+ * `PERPLEXITY_API_KEY`), otherwise
30
31
  * `heuristic`.
31
32
  */
32
33
  export type ClassifierStrategyKind = "heuristic" | "llm" | "jev" | "auto";
@@ -187,10 +188,11 @@ export type ClassifierRouterConfig = {
187
188
  /**
188
189
  * Classification strategy. Default: "auto" — which resolves to "jev" when a
189
190
  * decision provider is configured, in the environment or in SDK credentials
190
- * (`TYPESAFE_API_KEY`, `LAYA_API_KEY` with `LAYA_BASE_URL`, or
191
- * `XOR_API_KEY` with `XOR_BASE_URL`) and "heuristic" otherwise, so configuring one
192
- * upgrades routing without any code change. Behaviour for callers with no
193
- * key is unchanged.
191
+ * (`TYPESAFE_API_KEY`, `LAYA_API_KEY` with `LAYA_BASE_URL`,
192
+ * `XOR_API_KEY` with `XOR_BASE_URL`, or `PERPLEXITY_API_KEY`) and "heuristic"
193
+ * otherwise, so configuring one upgrades routing without any code change.
194
+ * Behaviour for callers with no key is unchanged. `PERPLEXITY_API_KEY` is also
195
+ * the Perplexity text provider's key, so it counts here too.
194
196
  */
195
197
  classifier?: ClassifierStrategyKind;
196
198
  /** Model used by the "llm" strategy. Defaults to provider/model auto. */
@@ -1727,7 +1727,8 @@ export type CliClassifierRouterFlags = {
1727
1727
  * Strategy: "auto" (default), "heuristic", "llm" or "jev"
1728
1728
  * (--classifier-strategy). "auto" resolves to "jev" when a decision
1729
1729
  * provider is configured (TYPESAFE_API_KEY, LAYA_API_KEY with
1730
- * LAYA_BASE_URL, or XOR_API_KEY with XOR_BASE_URL) and "heuristic" otherwise.
1730
+ * LAYA_BASE_URL, XOR_API_KEY with XOR_BASE_URL, or PERPLEXITY_API_KEY) and
1731
+ * "heuristic" otherwise.
1731
1732
  */
1732
1733
  classifierStrategy?: string;
1733
1734
  /**
@@ -169,6 +169,11 @@ export type DecisionMediaLimits = {
169
169
  /** Ceiling on the encoded request body, in bytes. */
170
170
  maxRequestBytes: number;
171
171
  };
172
+ /** An image's size in pixels, read from its header. */
173
+ export type DecisionImageDimensions = {
174
+ width: number;
175
+ height: number;
176
+ };
172
177
  /** Media after preparation: everything is a `data:` URL, ready for the wire. */
173
178
  export type DecisionPreparedMedia = {
174
179
  images: string[];
@@ -568,6 +568,17 @@ export type NeurolinkCredentials = {
568
568
  apiKey?: string;
569
569
  baseURL?: string;
570
570
  };
571
+ /**
572
+ * Perplexity Decisions API — the `decide` inference type. The key is the same
573
+ * `PERPLEXITY_API_KEY` the `perplexity` text provider reads, but this slice
574
+ * is separate so the two cannot be mixed up. `baseURL` is optional and
575
+ * defaults to `https://api.perplexity.ai`; requests go to
576
+ * `<baseURL>/v1/decisions`.
577
+ */
578
+ perplexityDecider?: {
579
+ apiKey?: string;
580
+ baseURL?: string;
581
+ };
571
582
  };
572
583
  /**
573
584
  * Voyage AI /embeddings response shape.
@@ -2,7 +2,7 @@
2
2
  * Centralized model choices for CLI commands
3
3
  * Derives choices from model enums to ensure consistency
4
4
  */
5
- import { AIProviderName, OpenAIModels, AnthropicModels, GoogleAIModels, BedrockModels, VertexModels, OllamaModels, AzureOpenAIModels, LiteLLMModels, SageMakerModels, OpenRouterModels, NvidiaNimModels, CohereModels, VoyageModels, JinaModels, StabilityModels, IdeogramModels, RecraftModels, TypeSafeModels, LayaModels, XorModels, ReplicateModels, } from "../constants/enums.js";
5
+ import { AIProviderName, OpenAIModels, AnthropicModels, GoogleAIModels, BedrockModels, VertexModels, OllamaModels, AzureOpenAIModels, LiteLLMModels, SageMakerModels, OpenRouterModels, NvidiaNimModels, CohereModels, VoyageModels, JinaModels, StabilityModels, IdeogramModels, RecraftModels, TypeSafeModels, LayaModels, XorModels, PerplexityDeciderModels, ReplicateModels, } from "../constants/enums.js";
6
6
  import { getCatalogJsonEntries } from "../providers/catalog/loader.js";
7
7
  /**
8
8
  * Looks up the JSON catalog entry for a provider, if it is one of the 11
@@ -393,6 +393,12 @@ const TOP_MODELS_CONFIG = {
393
393
  description: "Recommended - XOR 1.1, decision model that also reads images and video",
394
394
  },
395
395
  ],
396
+ [AIProviderName.PERPLEXITY_DECIDER]: [
397
+ {
398
+ model: PerplexityDeciderModels.PPLX_DECIDER_V1_27B,
399
+ description: "Recommended - Perplexity decision model that also reads images",
400
+ },
401
+ ],
396
402
  [AIProviderName.AUTO]: [],
397
403
  };
398
404
  /**
@@ -434,6 +440,7 @@ export const DEFAULT_MODELS = {
434
440
  [AIProviderName.TYPESAFE]: TypeSafeModels.JEV_LATEST,
435
441
  [AIProviderName.LAYA]: LayaModels.TYPED_DECISIONS,
436
442
  [AIProviderName.XOR]: XorModels.XOR_1_1,
443
+ [AIProviderName.PERPLEXITY_DECIDER]: PerplexityDeciderModels.PPLX_DECIDER_V1_27B,
437
444
  };
438
445
  /**
439
446
  * Model enum mappings for getAllModels.
@@ -468,6 +475,7 @@ const MODEL_ENUMS = {
468
475
  [AIProviderName.TYPESAFE]: TypeSafeModels,
469
476
  [AIProviderName.LAYA]: LayaModels,
470
477
  [AIProviderName.XOR]: XorModels,
478
+ [AIProviderName.PERPLEXITY_DECIDER]: PerplexityDeciderModels,
471
479
  [AIProviderName.AUTO]: null,
472
480
  };
473
481
  /**
@@ -780,6 +780,14 @@ const PRICING = {
780
780
  _default: { input: 0.042 / 1_000_000, output: 0 },
781
781
  "jev-latest": { input: 0.042 / 1_000_000, output: 0 },
782
782
  },
783
+ "perplexity-decider": {
784
+ // The Decisions API bills $0.04 per million INPUT tokens (image tokens
785
+ // included) and charges nothing for output or per request. `output: 0` is
786
+ // a price, not a token count, so do not "correct" it from
787
+ // `usage.output_tokens`. Rate from the API reference (checked 2026-10-02).
788
+ _default: { input: 0.04 / 1_000_000, output: 0 },
789
+ "pplx-decider-v1-27b": { input: 0.04 / 1_000_000, output: 0 },
790
+ },
783
791
  stability: {
784
792
  // Stability AI bills per image; symbolic per-token rate.
785
793
  _default: { input: 0, output: 0.04 / 1_000 },
@@ -410,3 +410,9 @@ export declare function createLayaConfig(): ProviderConfigOptions;
410
410
  * built-in endpoint: XOR_BASE_URL is required alongside the key.
411
411
  */
412
412
  export declare function createXorConfig(): ProviderConfigOptions;
413
+ /**
414
+ * Perplexity Decisions API — the `decide` inference type, not the Sonar text
415
+ * models. It reads the same PERPLEXITY_API_KEY as the `perplexity` text
416
+ * provider and has a public endpoint, so the key alone configures it.
417
+ */
418
+ export declare function createPerplexityDeciderConfig(): ProviderConfigOptions;
@@ -1358,3 +1358,21 @@ export function createXorConfig() {
1358
1358
  ],
1359
1359
  };
1360
1360
  }
1361
+ /**
1362
+ * Perplexity Decisions API — the `decide` inference type, not the Sonar text
1363
+ * models. It reads the same PERPLEXITY_API_KEY as the `perplexity` text
1364
+ * provider and has a public endpoint, so the key alone configures it.
1365
+ */
1366
+ export function createPerplexityDeciderConfig() {
1367
+ return {
1368
+ providerName: "Perplexity Decisions",
1369
+ envVarName: "PERPLEXITY_API_KEY",
1370
+ setupUrl: "https://console.perplexity.ai",
1371
+ description: "API key for the Perplexity Decisions API",
1372
+ instructions: [
1373
+ "1. Create an API key in the Perplexity console (https://console.perplexity.ai)",
1374
+ "2. Set PERPLEXITY_API_KEY; it is the same key the Perplexity text provider reads, so setting it also lets decide() use Perplexity when no other decision provider is configured",
1375
+ "3. Optionally set PERPLEXITY_DECIDER_BASE_URL to an origin other than https://api.perplexity.ai (NeuroLink calls <base>/v1/decisions)",
1376
+ ],
1377
+ };
1378
+ }