@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.
- package/CHANGELOG.md +3 -2
- package/README.md +94 -71
- package/dist/browser/neurolink.min.js +393 -393
- 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/neurolink.d.ts +13 -0
- package/dist/neurolink.js +57 -2
- 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/decisionBatches.d.ts +24 -0
- package/dist/utils/decisionBatches.js +58 -0
- package/dist/utils/logSanitize.js +12 -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 +82 -59
- 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.
|
|
@@ -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
|