@juspay/neurolink 12.43.3 → 12.44.1

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +3 -2
  2. package/README.md +94 -71
  3. package/dist/browser/neurolink.min.js +393 -393
  4. package/dist/cli/commands/decide.d.ts +1 -1
  5. package/dist/cli/commands/decide.js +3 -3
  6. package/dist/cli/commands/setup.js +2 -1
  7. package/dist/cli/factories/commandFactory.js +1 -1
  8. package/dist/constants/enums.d.ts +13 -0
  9. package/dist/constants/enums.js +14 -0
  10. package/dist/factories/providerDescriptors.d.ts +8 -2
  11. package/dist/factories/providerDescriptors.js +75 -5
  12. package/dist/factories/providerRegistry.js +11 -1
  13. package/dist/models/manifestRegistry.js +2 -0
  14. package/dist/models/manifests/perplexityDecider.d.ts +14 -0
  15. package/dist/models/manifests/perplexityDecider.js +32 -0
  16. package/dist/neurolink.d.ts +13 -0
  17. package/dist/neurolink.js +57 -2
  18. package/dist/providers/perplexityDecider.d.ts +50 -0
  19. package/dist/providers/perplexityDecider.js +373 -0
  20. package/dist/providers/systemOneDecision.d.ts +12 -0
  21. package/dist/providers/systemOneDecision.js +48 -4
  22. package/dist/types/classifierRouter.d.ts +7 -5
  23. package/dist/types/cli.d.ts +2 -1
  24. package/dist/types/decision.d.ts +5 -0
  25. package/dist/types/providers.d.ts +11 -0
  26. package/dist/utils/decisionBatches.d.ts +24 -0
  27. package/dist/utils/decisionBatches.js +58 -0
  28. package/dist/utils/logSanitize.js +12 -0
  29. package/dist/utils/modelChoices.js +9 -1
  30. package/dist/utils/pricing.js +8 -0
  31. package/dist/utils/providerConfig.d.ts +6 -0
  32. package/dist/utils/providerConfig.js +18 -0
  33. package/docs-site/static/search-index.json +82 -59
  34. package/package.json +2 -1
@@ -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.
@@ -0,0 +1,24 @@
1
+ import type { DecisionOptions, DecisionResult } from "../types/index.js";
2
+ /**
3
+ * How many batches of one split request are in flight at once. A request that
4
+ * splits into many must not meet the vendor's rate limit first: Perplexity, for
5
+ * one, allows ten requests a second.
6
+ */
7
+ export declare const MAX_PARALLEL_DECISION_BATCHES = 4;
8
+ /**
9
+ * Cut a request whose question map is longer than a provider's cap into
10
+ * requests the provider takes, keeping the questions in the order they were
11
+ * given. A request that fits, or a provider with no cap, comes back as the one
12
+ * request it was.
13
+ *
14
+ * `state` and any images or video travel with every batch, since each batch is
15
+ * a decision of its own.
16
+ */
17
+ export declare function splitDecisionRequest(options: DecisionOptions, maxQuestions: number | undefined): DecisionOptions[];
18
+ /**
19
+ * One result from the results of several batches: the answers joined, usage and
20
+ * media summed, the latency of the slowest (the batches ran side by side), and
21
+ * the request ids listed. Model and provider are the first batch's, which is
22
+ * the same for all of them.
23
+ */
24
+ export declare function mergeDecisionResults(results: readonly [DecisionResult, ...DecisionResult[]]): DecisionResult;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * How many batches of one split request are in flight at once. A request that
3
+ * splits into many must not meet the vendor's rate limit first: Perplexity, for
4
+ * one, allows ten requests a second.
5
+ */
6
+ export const MAX_PARALLEL_DECISION_BATCHES = 4;
7
+ /**
8
+ * Cut a request whose question map is longer than a provider's cap into
9
+ * requests the provider takes, keeping the questions in the order they were
10
+ * given. A request that fits, or a provider with no cap, comes back as the one
11
+ * request it was.
12
+ *
13
+ * `state` and any images or video travel with every batch, since each batch is
14
+ * a decision of its own.
15
+ */
16
+ export function splitDecisionRequest(options, maxQuestions) {
17
+ const entries = Object.entries(options.questions);
18
+ if (maxQuestions === undefined ||
19
+ maxQuestions < 1 ||
20
+ entries.length <= maxQuestions) {
21
+ return [options];
22
+ }
23
+ const batches = [];
24
+ for (let from = 0; from < entries.length; from += maxQuestions) {
25
+ batches.push({
26
+ ...options,
27
+ questions: Object.fromEntries(entries.slice(from, from + maxQuestions)),
28
+ });
29
+ }
30
+ return batches;
31
+ }
32
+ /**
33
+ * One result from the results of several batches: the answers joined, usage and
34
+ * media summed, the latency of the slowest (the batches ran side by side), and
35
+ * the request ids listed. Model and provider are the first batch's, which is
36
+ * the same for all of them.
37
+ */
38
+ export function mergeDecisionResults(results) {
39
+ const [first] = results;
40
+ const upstream = results.flatMap((r) => r.upstreamMs === undefined ? [] : [r.upstreamMs]);
41
+ const media = results.flatMap((r) => r.mediaBytes === undefined ? [] : [r.mediaBytes]);
42
+ const requestIds = results.flatMap((r) => (r.requestId ? [r.requestId] : []));
43
+ return {
44
+ model: first.model,
45
+ provider: first.provider,
46
+ answers: Object.assign({}, ...results.map((r) => r.answers)),
47
+ usage: {
48
+ inputTokens: results.reduce((sum, r) => sum + r.usage.inputTokens, 0),
49
+ outputTokens: results.reduce((sum, r) => sum + r.usage.outputTokens, 0),
50
+ },
51
+ latencyMs: Math.max(...results.map((r) => r.latencyMs)),
52
+ ...(requestIds.length > 0 ? { requestId: requestIds.join(",") } : {}),
53
+ ...(upstream.length > 0 ? { upstreamMs: Math.max(...upstream) } : {}),
54
+ ...(media.length > 0
55
+ ? { mediaBytes: media.reduce((sum, bytes) => sum + bytes, 0) }
56
+ : {}),
57
+ };
58
+ }
@@ -214,6 +214,18 @@ export function redactUrlForError(url) {
214
214
  const truncate = (s) => s.length > 200 ? `${s.slice(0, 200)}…` : s;
215
215
  try {
216
216
  const parsed = new URL(url);
217
+ // `user:pass@host:80` has no `//`, so it is not an authority: it parses as
218
+ // the scheme `user:` with the opaque path `pass@host:80`, and rebuilding it
219
+ // from scheme and path would hand the password back. An opaque path that
220
+ // holds an `@` is read as credentials and cut at the last one. A file URL
221
+ // has a path that starts with `/`, and a Windows drive path has a one-letter
222
+ // scheme, so neither is touched.
223
+ if (parsed.host === "" &&
224
+ !parsed.pathname.startsWith("/") &&
225
+ parsed.pathname.includes("@") &&
226
+ !/^[a-z]:$/i.test(parsed.protocol)) {
227
+ return truncate(`[redacted]@${parsed.pathname.slice(parsed.pathname.lastIndexOf("@") + 1)}`);
228
+ }
217
229
  // Reconstruct explicitly from protocol + host + pathname — never
218
230
  // parsed.username/parsed.password, and never parsed.href (which
219
231
  // re-serializes any embedded userinfo) — so a `user:pass@host` in the