@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.
- package/CHANGELOG.md +3 -3
- package/README.md +94 -71
- package/dist/browser/neurolink.min.js +388 -388
- package/dist/cli/commands/decide.d.ts +1 -1
- package/dist/cli/commands/decide.js +3 -3
- package/dist/cli/commands/setup.js +2 -1
- package/dist/cli/factories/commandFactory.js +1 -1
- package/dist/constants/enums.d.ts +13 -0
- package/dist/constants/enums.js +14 -0
- package/dist/factories/providerDescriptors.d.ts +8 -2
- package/dist/factories/providerDescriptors.js +75 -5
- package/dist/factories/providerRegistry.js +11 -1
- package/dist/models/manifestRegistry.js +2 -0
- package/dist/models/manifests/perplexityDecider.d.ts +14 -0
- package/dist/models/manifests/perplexityDecider.js +32 -0
- package/dist/providers/perplexityDecider.d.ts +50 -0
- package/dist/providers/perplexityDecider.js +373 -0
- package/dist/providers/systemOneDecision.d.ts +12 -0
- package/dist/providers/systemOneDecision.js +48 -4
- package/dist/types/classifierRouter.d.ts +7 -5
- package/dist/types/cli.d.ts +2 -1
- package/dist/types/decision.d.ts +5 -0
- package/dist/types/providers.d.ts +11 -0
- package/dist/utils/modelChoices.js +9 -1
- package/dist/utils/pricing.js +8 -0
- package/dist/utils/providerConfig.d.ts +6 -0
- package/dist/utils/providerConfig.js +18 -0
- package/docs-site/static/search-index.json +81 -58
- 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
|
-
|
|
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.
|
|
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
|
-
|
|
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`,
|
|
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`,
|
|
191
|
-
* `XOR_API_KEY` with `XOR_BASE_URL`) and "heuristic"
|
|
192
|
-
* upgrades routing without any code change.
|
|
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. */
|
package/dist/types/cli.d.ts
CHANGED
|
@@ -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,
|
|
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
|
/**
|
package/dist/types/decision.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/utils/pricing.js
CHANGED
|
@@ -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
|
+
}
|