@juspay/neurolink 13.4.7 → 13.4.9
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 +2 -2
- package/dist/browser/neurolink.min.js +391 -391
- package/dist/proxy/geminiFormat.js +59 -2
- package/dist/proxy/proxyImageInput.d.ts +70 -0
- package/dist/proxy/proxyImageInput.js +168 -0
- package/dist/proxy/proxyTranslationEngine.js +11 -1
- package/dist/server/routes/claudeProxyRoutes.js +7 -1
- package/dist/server/utils/agentInput.d.ts +2 -2
- package/dist/server/utils/agentInput.js +12 -4
- package/dist/types/proxy.d.ts +1 -0
- package/dist/types/server.d.ts +13 -0
- package/dist/voice/livekit/config.d.ts +5 -7
- package/dist/voice/livekit/config.js +8 -3
- package/dist/voice/livekit/realtimeModel.d.ts +6 -3
- package/dist/voice/livekit/realtimeModel.js +11 -6
- package/docs-site/static/search-index.json +89 -85
- package/package.json +3 -1
|
@@ -37,13 +37,70 @@ function partsToText(parts) {
|
|
|
37
37
|
.filter(Boolean)
|
|
38
38
|
.join("");
|
|
39
39
|
}
|
|
40
|
+
/** `image/<subtype>` as a MIME restricted-name (RFC 6838): no space, comma or semicolon. */
|
|
41
|
+
const IMAGE_MIME_TYPE = /^image\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/i;
|
|
42
|
+
/** Standard or URL-safe base64, the two alphabets Google's JSON `bytes` fields accept. */
|
|
43
|
+
const RAW_BASE64 = /^[A-Za-z0-9+/_-]+={0,2}$/;
|
|
44
|
+
/**
|
|
45
|
+
* `data` as padded standard-alphabet base64, which is what a `data:` URI carries,
|
|
46
|
+
* or undefined when it is not well-formed base64.
|
|
47
|
+
*
|
|
48
|
+
* The SDK forwards the base64 text of a `data:` URI to the provider as it is, so
|
|
49
|
+
* a URL-safe or unpadded payload would reach a provider that expects neither.
|
|
50
|
+
* The alphabet check alone lets through a payload no decoder takes: one
|
|
51
|
+
* character over a multiple of four is no base64 length at all, and padding
|
|
52
|
+
* must be exactly what the length calls for.
|
|
53
|
+
*/
|
|
54
|
+
function toStandardBase64(data) {
|
|
55
|
+
const standard = /[-_]/.test(data)
|
|
56
|
+
? data.replace(/-/g, "+").replace(/_/g, "/")
|
|
57
|
+
: data;
|
|
58
|
+
const body = standard.replace(/=+$/, "");
|
|
59
|
+
const padding = standard.length - body.length;
|
|
60
|
+
const missing = (4 - (body.length % 4)) % 4;
|
|
61
|
+
if (missing === 3 || (padding > 0 && padding !== missing)) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
return `${body}${"=".repeat(missing)}`;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The `input.images` entry for one `inlineData`, or undefined when it has none.
|
|
68
|
+
*
|
|
69
|
+
* Google's wire format puts raw base64 in `data` and the type in a sibling
|
|
70
|
+
* `mimeType`, while the image vetter takes only `data:` URIs and https URLs, so
|
|
71
|
+
* raw base64 of an image type is wrapped into a `data:` URI here. The SDK reads
|
|
72
|
+
* a `data:` string as inline bytes and never as a path or URL.
|
|
73
|
+
*
|
|
74
|
+
* Anything else is passed on as it came and left to the vetter: a value that
|
|
75
|
+
* is not base64 (a `data:` URI, an https URL, a path) is judged as the
|
|
76
|
+
* reference it names, and base64 with a missing, non-image or malformed
|
|
77
|
+
* `mimeType`, an impossible length or inconsistent padding is refused there as
|
|
78
|
+
* `invalid_image_reference`. Audio and PDF parts land in the same place; this
|
|
79
|
+
* parser has never told them from images.
|
|
80
|
+
*/
|
|
81
|
+
function inlineDataToImage(inlineData) {
|
|
82
|
+
const data = inlineData?.data;
|
|
83
|
+
if (typeof data !== "string" || data.length === 0) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const mimeType = inlineData?.mimeType;
|
|
87
|
+
if (typeof mimeType !== "string" ||
|
|
88
|
+
!IMAGE_MIME_TYPE.test(mimeType) ||
|
|
89
|
+
!RAW_BASE64.test(data)) {
|
|
90
|
+
return data;
|
|
91
|
+
}
|
|
92
|
+
const standard = toStandardBase64(data);
|
|
93
|
+
return standard === undefined
|
|
94
|
+
? data
|
|
95
|
+
: `data:${mimeType.toLowerCase()};base64,${standard}`;
|
|
96
|
+
}
|
|
40
97
|
function partsToImages(parts) {
|
|
41
98
|
if (!Array.isArray(parts)) {
|
|
42
99
|
return [];
|
|
43
100
|
}
|
|
44
101
|
return parts
|
|
45
|
-
.map((p) => p?.inlineData
|
|
46
|
-
.filter((d) =>
|
|
102
|
+
.map((p) => inlineDataToImage(p?.inlineData))
|
|
103
|
+
.filter((d) => d !== undefined);
|
|
47
104
|
}
|
|
48
105
|
/**
|
|
49
106
|
* Parse a `generateContent` body into the shape the translation engine takes.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client image references on the proxy's translation routes, vetted before the
|
|
3
|
+
* SDK sees them.
|
|
4
|
+
*
|
|
5
|
+
* The OpenAI, Gemini and (once a model is routed or falls back to another
|
|
6
|
+
* provider) Claude routes copy whatever a client put in an image slot into
|
|
7
|
+
* `input.images`, and the SDK resolves a string itself: an `http(s)` URL is
|
|
8
|
+
* fetched with redirects followed and no address check, and anything else that
|
|
9
|
+
* names an existing file is read from the local disk. Both suit a program that
|
|
10
|
+
* names its own files. Neither suits a request that arrived over a socket.
|
|
11
|
+
*
|
|
12
|
+
* So the proxy resolves every reference itself and hands the SDK nothing but
|
|
13
|
+
* `data:` URIs and downloaded bytes:
|
|
14
|
+
* - `data:` URIs pass through untouched, they carry their own content;
|
|
15
|
+
* - `https://` URLs are downloaded with the same helper the agent routes use
|
|
16
|
+
* (`resolveAgentInput` -> `safeDownload`): redirects refused, addresses
|
|
17
|
+
* checked, size capped;
|
|
18
|
+
* - everything else (local paths, `http://`, `file://`, any other scheme) is
|
|
19
|
+
* refused before the file system or the network is touched, so the answer
|
|
20
|
+
* for a path that exists and one that does not is the same.
|
|
21
|
+
*
|
|
22
|
+
* Unlike the agent routes there is no allowed-roots setting: the proxy listens
|
|
23
|
+
* on loopback without credentials by default, and a path allow-list on a server
|
|
24
|
+
* with no caller identity would still hand its owner's disk to every local
|
|
25
|
+
* process, and to every holder of a share token. Local paths are refused
|
|
26
|
+
* outright.
|
|
27
|
+
*
|
|
28
|
+
* Count and size are bounded as the agent routes bound them: at most 20 images,
|
|
29
|
+
* each at most 50 MB, all together at most 100 MB, with a `data:` URI charged
|
|
30
|
+
* its string length.
|
|
31
|
+
*
|
|
32
|
+
* The refusal is a {@link ProxyContextPreflightError}, so every site that
|
|
33
|
+
* already answers a local admission refusal (400, no retry, no fallback) answers
|
|
34
|
+
* this one in the client's own error format.
|
|
35
|
+
*
|
|
36
|
+
* @module proxy/proxyImageInput
|
|
37
|
+
*/
|
|
38
|
+
import { ProxyContextPreflightError } from "./proxyContextPreflight.js";
|
|
39
|
+
/** An image reference the proxy refuses; answers 400 in the client's format. */
|
|
40
|
+
export declare class ProxyImageInputError extends ProxyContextPreflightError {
|
|
41
|
+
constructor(code: string, message: string);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Resolve one list of client image references to what the SDK may be given.
|
|
45
|
+
*
|
|
46
|
+
* `signal` cancels the work: a download in flight is aborted and no further
|
|
47
|
+
* reference is started. A cancellation rejects with the signal's own error, not
|
|
48
|
+
* with a {@link ProxyImageInputError}, since nothing was refused.
|
|
49
|
+
*
|
|
50
|
+
* @throws {ProxyImageInputError} for the first reference that is refused, or
|
|
51
|
+
* for a list that carries too many images or too many bytes.
|
|
52
|
+
*/
|
|
53
|
+
export declare function vetProxyImages(images: readonly unknown[], signal?: AbortSignal): Promise<Array<string | Buffer>>;
|
|
54
|
+
/**
|
|
55
|
+
* Per-request holder of vetted images.
|
|
56
|
+
*
|
|
57
|
+
* A translated request can make several attempts, each handing the SDK the same
|
|
58
|
+
* references, so each reference is resolved once and looked up by its text
|
|
59
|
+
* afterwards. `apply` fails closed: a list that carries a reference `vet` has
|
|
60
|
+
* not seen is refused, never forwarded.
|
|
61
|
+
*/
|
|
62
|
+
export declare function createProxyImageVetter(): {
|
|
63
|
+
/**
|
|
64
|
+
* Vet the images `body` would hand the SDK that no earlier call vetted.
|
|
65
|
+
* `signal` cancels downloads still running; see {@link vetProxyImages}.
|
|
66
|
+
*/
|
|
67
|
+
vet(body: object, signal?: AbortSignal): Promise<void>;
|
|
68
|
+
/** `body` with its `input.images` replaced by the vetted content. */
|
|
69
|
+
apply<T extends object>(body: T): T;
|
|
70
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client image references on the proxy's translation routes, vetted before the
|
|
3
|
+
* SDK sees them.
|
|
4
|
+
*
|
|
5
|
+
* The OpenAI, Gemini and (once a model is routed or falls back to another
|
|
6
|
+
* provider) Claude routes copy whatever a client put in an image slot into
|
|
7
|
+
* `input.images`, and the SDK resolves a string itself: an `http(s)` URL is
|
|
8
|
+
* fetched with redirects followed and no address check, and anything else that
|
|
9
|
+
* names an existing file is read from the local disk. Both suit a program that
|
|
10
|
+
* names its own files. Neither suits a request that arrived over a socket.
|
|
11
|
+
*
|
|
12
|
+
* So the proxy resolves every reference itself and hands the SDK nothing but
|
|
13
|
+
* `data:` URIs and downloaded bytes:
|
|
14
|
+
* - `data:` URIs pass through untouched, they carry their own content;
|
|
15
|
+
* - `https://` URLs are downloaded with the same helper the agent routes use
|
|
16
|
+
* (`resolveAgentInput` -> `safeDownload`): redirects refused, addresses
|
|
17
|
+
* checked, size capped;
|
|
18
|
+
* - everything else (local paths, `http://`, `file://`, any other scheme) is
|
|
19
|
+
* refused before the file system or the network is touched, so the answer
|
|
20
|
+
* for a path that exists and one that does not is the same.
|
|
21
|
+
*
|
|
22
|
+
* Unlike the agent routes there is no allowed-roots setting: the proxy listens
|
|
23
|
+
* on loopback without credentials by default, and a path allow-list on a server
|
|
24
|
+
* with no caller identity would still hand its owner's disk to every local
|
|
25
|
+
* process, and to every holder of a share token. Local paths are refused
|
|
26
|
+
* outright.
|
|
27
|
+
*
|
|
28
|
+
* Count and size are bounded as the agent routes bound them: at most 20 images,
|
|
29
|
+
* each at most 50 MB, all together at most 100 MB, with a `data:` URI charged
|
|
30
|
+
* its string length.
|
|
31
|
+
*
|
|
32
|
+
* The refusal is a {@link ProxyContextPreflightError}, so every site that
|
|
33
|
+
* already answers a local admission refusal (400, no retry, no fallback) answers
|
|
34
|
+
* this one in the client's own error format.
|
|
35
|
+
*
|
|
36
|
+
* @module proxy/proxyImageInput
|
|
37
|
+
*/
|
|
38
|
+
import { AgentInputError, resolveAgentInput, } from "../server/utils/agentInput.js";
|
|
39
|
+
import { logger } from "../utils/logger.js";
|
|
40
|
+
import { ProxyContextPreflightError } from "./proxyContextPreflight.js";
|
|
41
|
+
const INVALID_REFERENCE_MESSAGE = "Image references must be data: URIs or public https URLs. Local file paths, http URLs and other schemes are not accepted by this proxy.";
|
|
42
|
+
const UNFETCHABLE_URL_MESSAGE = "An image URL could not be fetched. It must be a public https URL, answer without a redirect and fit the size limit.";
|
|
43
|
+
/** An image reference the proxy refuses; answers 400 in the client's format. */
|
|
44
|
+
export class ProxyImageInputError extends ProxyContextPreflightError {
|
|
45
|
+
constructor(code, message) {
|
|
46
|
+
super(code, message);
|
|
47
|
+
this.name = "ProxyImageInputError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const isDataUri = (entry) => entry.startsWith("data:");
|
|
51
|
+
const isHttpsUrl = (entry) => /^https:\/\//i.test(entry);
|
|
52
|
+
/**
|
|
53
|
+
* What kind of reference was refused, for the operator's log. The reference
|
|
54
|
+
* itself is never logged: it can be a server path, or a URL with credentials.
|
|
55
|
+
*/
|
|
56
|
+
function describeRefusal(entry) {
|
|
57
|
+
if (typeof entry !== "string") {
|
|
58
|
+
return "non-string";
|
|
59
|
+
}
|
|
60
|
+
if (/^https?:\/\//i.test(entry)) {
|
|
61
|
+
return "http-url";
|
|
62
|
+
}
|
|
63
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]+:/.test(entry)) {
|
|
64
|
+
return "other-scheme";
|
|
65
|
+
}
|
|
66
|
+
return "path";
|
|
67
|
+
}
|
|
68
|
+
function refuseReference(entry) {
|
|
69
|
+
logger.warn("[proxy] refused a client image reference", {
|
|
70
|
+
kind: describeRefusal(entry),
|
|
71
|
+
});
|
|
72
|
+
return new ProxyImageInputError("invalid_image_reference", INVALID_REFERENCE_MESSAGE);
|
|
73
|
+
}
|
|
74
|
+
function asProxyImageInputError(error) {
|
|
75
|
+
if (!(error instanceof AgentInputError)) {
|
|
76
|
+
return error;
|
|
77
|
+
}
|
|
78
|
+
// agentInput's messages name a limit, never the input, so the size and count
|
|
79
|
+
// refusals pass through. Its download refusal is already logged there.
|
|
80
|
+
if (error.status === 413) {
|
|
81
|
+
return new ProxyImageInputError("image_input_too_large", error.message);
|
|
82
|
+
}
|
|
83
|
+
return new ProxyImageInputError("image_url_unfetchable", UNFETCHABLE_URL_MESSAGE);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Resolve one list of client image references to what the SDK may be given.
|
|
87
|
+
*
|
|
88
|
+
* `signal` cancels the work: a download in flight is aborted and no further
|
|
89
|
+
* reference is started. A cancellation rejects with the signal's own error, not
|
|
90
|
+
* with a {@link ProxyImageInputError}, since nothing was refused.
|
|
91
|
+
*
|
|
92
|
+
* @throws {ProxyImageInputError} for the first reference that is refused, or
|
|
93
|
+
* for a list that carries too many images or too many bytes.
|
|
94
|
+
*/
|
|
95
|
+
export async function vetProxyImages(images, signal) {
|
|
96
|
+
const references = [];
|
|
97
|
+
for (const entry of images) {
|
|
98
|
+
if (typeof entry !== "string" || !(isDataUri(entry) || isHttpsUrl(entry))) {
|
|
99
|
+
throw refuseReference(entry);
|
|
100
|
+
}
|
|
101
|
+
references.push(entry);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const resolved = await resolveAgentInput({ text: "", images: references }, { signal });
|
|
105
|
+
return resolved.images ?? [];
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
throw asProxyImageInputError(error);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
112
|
+
/** The `input.images` list of an SDK options object, when it has one. */
|
|
113
|
+
function imagesOf(body) {
|
|
114
|
+
const input = Reflect.get(body, "input");
|
|
115
|
+
return isRecord(input) && Array.isArray(input.images)
|
|
116
|
+
? input.images
|
|
117
|
+
: undefined;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Per-request holder of vetted images.
|
|
121
|
+
*
|
|
122
|
+
* A translated request can make several attempts, each handing the SDK the same
|
|
123
|
+
* references, so each reference is resolved once and looked up by its text
|
|
124
|
+
* afterwards. `apply` fails closed: a list that carries a reference `vet` has
|
|
125
|
+
* not seen is refused, never forwarded.
|
|
126
|
+
*/
|
|
127
|
+
export function createProxyImageVetter() {
|
|
128
|
+
const resolved = new Map();
|
|
129
|
+
return {
|
|
130
|
+
/**
|
|
131
|
+
* Vet the images `body` would hand the SDK that no earlier call vetted.
|
|
132
|
+
* `signal` cancels downloads still running; see {@link vetProxyImages}.
|
|
133
|
+
*/
|
|
134
|
+
async vet(body, signal) {
|
|
135
|
+
const pending = (imagesOf(body) ?? []).filter((entry) => typeof entry !== "string" || !resolved.has(entry));
|
|
136
|
+
if (pending.length === 0) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const vetted = await vetProxyImages(pending, signal);
|
|
140
|
+
pending.forEach((entry, index) => {
|
|
141
|
+
if (typeof entry === "string") {
|
|
142
|
+
resolved.set(entry, vetted[index]);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
/** `body` with its `input.images` replaced by the vetted content. */
|
|
147
|
+
apply(body) {
|
|
148
|
+
const input = Reflect.get(body, "input");
|
|
149
|
+
const images = imagesOf(body);
|
|
150
|
+
if (!isRecord(input) || images === undefined) {
|
|
151
|
+
return body;
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
...body,
|
|
155
|
+
input: {
|
|
156
|
+
...input,
|
|
157
|
+
images: images.map((entry) => {
|
|
158
|
+
const vetted = typeof entry === "string" ? resolved.get(entry) : undefined;
|
|
159
|
+
if (vetted === undefined) {
|
|
160
|
+
throw refuseReference(entry);
|
|
161
|
+
}
|
|
162
|
+
return vetted;
|
|
163
|
+
}),
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -18,6 +18,7 @@ import { prepareProxyRequestContext, ProxyContextPreflightError, } from "./proxy
|
|
|
18
18
|
import { reserveProxyTokenBudget, settleProxyTokenBudget, getProxyTokenBudgetPolicy, getProxyTokenBudgetSessionKey, getProxyTokenBudgetError, } from "./proxyTokenBudget.js";
|
|
19
19
|
import { isProxyRequestFinalized, getProxyRequestAccounting, registerProxyResponseObserver, } from "./proxyActivity.js";
|
|
20
20
|
import { getProxyUpstreamFailure } from "./proxyFailureDetails.js";
|
|
21
|
+
import { createProxyImageVetter } from "./proxyImageInput.js";
|
|
21
22
|
import { sanitizeForLog } from "../utils/logSanitize.js";
|
|
22
23
|
import { createProxyRouteBodyCapture } from "./proxyRouteBodyCapture.js";
|
|
23
24
|
import { DEFAULT_PROXY_MODEL_IDS } from "../constants/proxyModels.js";
|
|
@@ -290,6 +291,10 @@ function createTranslationRequest(args) {
|
|
|
290
291
|
let budgetLease;
|
|
291
292
|
let budgetSettlement;
|
|
292
293
|
let terminalWork;
|
|
294
|
+
// The SDK reads a local path or fetches an http(s) URL named in an image slot,
|
|
295
|
+
// so a client's references are resolved here, once per request, and the SDK is
|
|
296
|
+
// handed only what this vetted.
|
|
297
|
+
const imageVetter = createProxyImageVetter();
|
|
293
298
|
let attempt;
|
|
294
299
|
let iterator;
|
|
295
300
|
let firstUsefulOutputMs;
|
|
@@ -431,6 +436,10 @@ function createTranslationRequest(args) {
|
|
|
431
436
|
cleanupIterator();
|
|
432
437
|
},
|
|
433
438
|
stream,
|
|
439
|
+
// The race is kept so an abort still rejects at once, as it did before the
|
|
440
|
+
// vetter could be cancelled; the signal is what stops the download itself.
|
|
441
|
+
vetImages: (body, abort) => raceWithAbort(imageVetter.vet(body, abort), abort),
|
|
442
|
+
withVettedImages: (body) => imageVetter.apply(body),
|
|
434
443
|
cancel(reason) {
|
|
435
444
|
controller.abort(reason);
|
|
436
445
|
},
|
|
@@ -499,6 +508,7 @@ async function* runTranslationAttempts(args) {
|
|
|
499
508
|
...args.options,
|
|
500
509
|
abortSignal: signal,
|
|
501
510
|
};
|
|
511
|
+
await request.vetImages(options, signal);
|
|
502
512
|
const prepared = prepareProxyRequestContext({
|
|
503
513
|
provider: attempt.provider ?? "auto",
|
|
504
514
|
model: attempt.model ?? parsed.model,
|
|
@@ -572,7 +582,7 @@ async function* runTranslationAttempts(args) {
|
|
|
572
582
|
},
|
|
573
583
|
});
|
|
574
584
|
recordAttempt(attempt.label ?? "translation", "translation");
|
|
575
|
-
const result = await raceWithAbort(ctx.neurolink.stream(prepared.body), signal);
|
|
585
|
+
const result = await raceWithAbort(ctx.neurolink.stream(request.withVettedImages(prepared.body)), signal);
|
|
576
586
|
request.setResult(result);
|
|
577
587
|
const iterator = result.stream[Symbol.asyncIterator]();
|
|
578
588
|
request.setIterator(iterator);
|
|
@@ -45,6 +45,7 @@ import { evaluateResidentAccount, getResidentGrantForAccount, recordResidentSpen
|
|
|
45
45
|
import { buildProvisionClaim, isLeaseRefusal, issueLease, } from "../../proxy/shareLease.js";
|
|
46
46
|
import { forwardToPeer } from "../../proxy/peerTransport.js";
|
|
47
47
|
import { getShareContext } from "../../proxy/shareContext.js";
|
|
48
|
+
import { ProxyImageInputError } from "../../proxy/proxyImageInput.js";
|
|
48
49
|
import { buildShareRefusal, extractShareToken } from "../../proxy/shareGate.js";
|
|
49
50
|
import { debitShareGrantCoins, getNodePublicUrl, getNoteSecret, getShareGrant, resolveShareToken, setShareGrantState, } from "../../proxy/shareGrants.js";
|
|
50
51
|
import { recordAuditObservation } from "../../proxy/shareAudit.js";
|
|
@@ -8022,7 +8023,12 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
|
|
|
8022
8023
|
error.evidence) {
|
|
8023
8024
|
ctx.metadata.contextPreflight = error.evidence;
|
|
8024
8025
|
}
|
|
8025
|
-
const refusal = buildLoggedClaudeError(localRefusal.status, localRefusal.message,
|
|
8026
|
+
const refusal = buildLoggedClaudeError(localRefusal.status, localRefusal.message,
|
|
8027
|
+
// A refused image reference is a malformed request in
|
|
8028
|
+
// Anthropic's vocabulary; its code stays in `error.code`.
|
|
8029
|
+
error instanceof ProxyImageInputError
|
|
8030
|
+
? "invalid_request_error"
|
|
8031
|
+
: localRefusal.code, { errorCode: localRefusal.code, retryable: false });
|
|
8026
8032
|
return new Response(JSON.stringify({
|
|
8027
8033
|
...refusal,
|
|
8028
8034
|
error: {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
*
|
|
34
34
|
* @module server/utils/agentInput
|
|
35
35
|
*/
|
|
36
|
-
import type { ServerAgentResolvedInput,
|
|
36
|
+
import type { ServerAgentResolvedInput, ServerAgentResolveOptions } from "../../types/index.js";
|
|
37
37
|
/** An attachment the server refuses, with the HTTP status that says why. */
|
|
38
38
|
export declare class AgentInputError extends Error {
|
|
39
39
|
readonly status: number;
|
|
@@ -50,4 +50,4 @@ export declare function resolveAgentInput(input: {
|
|
|
50
50
|
text: string;
|
|
51
51
|
images?: string[];
|
|
52
52
|
files?: string[];
|
|
53
|
-
}, options?:
|
|
53
|
+
}, options?: ServerAgentResolveOptions): Promise<ServerAgentResolvedInput>;
|
|
@@ -201,7 +201,7 @@ async function readWithinLimit(handle, limit) {
|
|
|
201
201
|
}
|
|
202
202
|
return Buffer.concat(chunks, total);
|
|
203
203
|
}
|
|
204
|
-
async function downloadAttachment(url, limit) {
|
|
204
|
+
async function downloadAttachment(url, limit, signal) {
|
|
205
205
|
// Dynamic import keeps undici off the static import graph; see
|
|
206
206
|
// test/continuous-test-suite-import-cost.ts.
|
|
207
207
|
const { safeDownload } = await import("../../utils/safeFetch.js");
|
|
@@ -209,9 +209,15 @@ async function downloadAttachment(url, limit) {
|
|
|
209
209
|
return await safeDownload(url, {
|
|
210
210
|
maxBytes: limit,
|
|
211
211
|
label: "agent attachment",
|
|
212
|
+
signal,
|
|
212
213
|
});
|
|
213
214
|
}
|
|
214
215
|
catch (error) {
|
|
216
|
+
// A cancelled download is not a refused URL: the caller is gone, and
|
|
217
|
+
// reporting it as one would put a refusal in the log that never happened.
|
|
218
|
+
if (signal?.aborted) {
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
215
221
|
// The reason can name the address a host resolved to; the caller gets none
|
|
216
222
|
// of it, the operator gets it in the log.
|
|
217
223
|
const reason = error instanceof Error ? error.message : String(error);
|
|
@@ -222,9 +228,9 @@ async function downloadAttachment(url, limit) {
|
|
|
222
228
|
}
|
|
223
229
|
}
|
|
224
230
|
/** The bytes of one attachment that is not a `data:` URI, read within `limit`. */
|
|
225
|
-
async function resolveAttachment(entry, roots, limit) {
|
|
231
|
+
async function resolveAttachment(entry, roots, limit, signal) {
|
|
226
232
|
if (/^https?:\/\//i.test(entry)) {
|
|
227
|
-
return downloadAttachment(entry, limit);
|
|
233
|
+
return downloadAttachment(entry, limit, signal);
|
|
228
234
|
}
|
|
229
235
|
if (URL_SCHEME.test(entry)) {
|
|
230
236
|
throw new AgentInputError("attachment must be a data: URI, an https URL, or a path under an allowed directory", 400, "VALIDATION_ERROR");
|
|
@@ -270,6 +276,8 @@ export async function resolveAgentInput(input, options) {
|
|
|
270
276
|
}
|
|
271
277
|
const resolved = [];
|
|
272
278
|
for (const entry of list) {
|
|
279
|
+
// A caller that has gone away starts nothing further.
|
|
280
|
+
options?.signal?.throwIfAborted();
|
|
273
281
|
// Exactly the prefix the SDK keys on, so nothing that reaches it as a
|
|
274
282
|
// string is anything but a data URI.
|
|
275
283
|
if (isDataUri(entry)) {
|
|
@@ -279,7 +287,7 @@ export async function resolveAgentInput(input, options) {
|
|
|
279
287
|
const limit = Math.min(perAttachment, remaining);
|
|
280
288
|
let bytes;
|
|
281
289
|
try {
|
|
282
|
-
bytes = await resolveAttachment(entry, roots, limit);
|
|
290
|
+
bytes = await resolveAttachment(entry, roots, limit, options?.signal);
|
|
283
291
|
}
|
|
284
292
|
catch (error) {
|
|
285
293
|
if (limit < perAttachment &&
|
package/dist/types/proxy.d.ts
CHANGED
package/dist/types/server.d.ts
CHANGED
|
@@ -463,6 +463,19 @@ export type ServerAgentRouteOptions = {
|
|
|
463
463
|
*/
|
|
464
464
|
maxInputTotalBytes?: number;
|
|
465
465
|
};
|
|
466
|
+
/**
|
|
467
|
+
* The route options plus the one thing that belongs to a single call:
|
|
468
|
+
* `resolveAgentInput` options. A signal is not route configuration, so it is
|
|
469
|
+
* kept off {@link ServerAgentRouteOptions}, where `createAgentRoutes` would
|
|
470
|
+
* apply it to every request.
|
|
471
|
+
*/
|
|
472
|
+
export type ServerAgentResolveOptions = ServerAgentRouteOptions & {
|
|
473
|
+
/**
|
|
474
|
+
* Cancels the resolution: an `https` download in flight is aborted and no
|
|
475
|
+
* further attachment is started. Omitted, nothing can cancel it.
|
|
476
|
+
*/
|
|
477
|
+
signal?: AbortSignal;
|
|
478
|
+
};
|
|
466
479
|
/**
|
|
467
480
|
* An agent request's `input` once every attachment has been vetted: URLs and
|
|
468
481
|
* allowed local paths are replaced by the bytes the server read itself, `data:`
|
|
@@ -24,14 +24,12 @@ export declare function resolveLiveKitServerConfig(): LiveKitServerConfig;
|
|
|
24
24
|
*/
|
|
25
25
|
export declare function resolveBrainDefaults(): LiveKitBrainDefaults;
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* check. The error names the setting and the opt-in and does not repeat the
|
|
32
|
-
* value.
|
|
27
|
+
* A ws or wss URL that passes `requireNoCleartextCrossHost`. The error names the
|
|
28
|
+
* setting and does not repeat the value. Exported so the model factory applies
|
|
29
|
+
* the same check to a hand-built config as config resolution applies to the
|
|
30
|
+
* variable and to a caller's override.
|
|
33
31
|
*/
|
|
34
|
-
export declare function
|
|
32
|
+
export declare function requireWebSocketUrl(value: string, name: string): string;
|
|
35
33
|
/**
|
|
36
34
|
* Resolve the realtime (speech-to-speech) voice configuration from the
|
|
37
35
|
* environment.
|
|
@@ -134,7 +134,7 @@ function isLoopbackHost(hostname) {
|
|
|
134
134
|
* check. The error names the setting and the opt-in and does not repeat the
|
|
135
135
|
* value.
|
|
136
136
|
*/
|
|
137
|
-
|
|
137
|
+
function requireNoCleartextCrossHost(value, name) {
|
|
138
138
|
const url = parseWebSocketUrl(value);
|
|
139
139
|
if (url?.protocol === "ws:" &&
|
|
140
140
|
!isLoopbackHost(url.hostname) &&
|
|
@@ -142,8 +142,13 @@ export function requireNoCleartextCrossHost(value, name) {
|
|
|
142
142
|
throw new Error(`${name} is a ws:// URL to a host other than localhost, 127.0.0.0/8 or [::1], which would send XAI_API_KEY unencrypted. Use wss://, or set ${ALLOW_INSECURE_ENV}=true to accept that`);
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
|
-
/**
|
|
146
|
-
|
|
145
|
+
/**
|
|
146
|
+
* A ws or wss URL that passes `requireNoCleartextCrossHost`. The error names the
|
|
147
|
+
* setting and does not repeat the value. Exported so the model factory applies
|
|
148
|
+
* the same check to a hand-built config as config resolution applies to the
|
|
149
|
+
* variable and to a caller's override.
|
|
150
|
+
*/
|
|
151
|
+
export function requireWebSocketUrl(value, name) {
|
|
147
152
|
if (parseWebSocketUrl(value) === undefined) {
|
|
148
153
|
throw new Error(`${name} must be a ws:// or wss:// URL`);
|
|
149
154
|
}
|
|
@@ -28,8 +28,11 @@ export declare function requireXaiApiKey(): void;
|
|
|
28
28
|
* The gemini branch builds exactly what the agent has always built: Vertex AI,
|
|
29
29
|
* the configured model, transcripts of both sides, and project, location,
|
|
30
30
|
* voice and modality when set. The xai branch needs `XAI_API_KEY` in the
|
|
31
|
-
* environment and throws before touching the plugin when it is missing. It
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* environment and throws before touching the plugin when it is missing. It then
|
|
32
|
+
* checks `xaiBaseURL` as config resolution does, and throws before touching the
|
|
33
|
+
* plugin for a value that is not a `ws://` or `wss://` URL, and for a `ws://`
|
|
34
|
+
* URL to a host other than localhost, 127.0.0.0/8 or [::1] unless
|
|
35
|
+
* `XAI_REALTIME_ALLOW_INSECURE=true`. That opt-in never admits a value that is
|
|
36
|
+
* not a ws or wss URL. The errors name `xaiBaseURL` and do not repeat the value.
|
|
34
37
|
*/
|
|
35
38
|
export declare function createRealtimeLlm(cfg: RealtimeVoiceConfig): Promise<RealtimeLlm>;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* See docs/features/livekit-voice-agent.md.
|
|
15
15
|
*/
|
|
16
|
-
import {
|
|
16
|
+
import { requireWebSocketUrl } from "./config.js";
|
|
17
17
|
/**
|
|
18
18
|
* Fail early, naming the variable, when `XAI_API_KEY` is blank or absent.
|
|
19
19
|
*
|
|
@@ -55,9 +55,11 @@ async function createGeminiRealtimeLlm(cfg) {
|
|
|
55
55
|
async function createXaiRealtimeLlm(cfg) {
|
|
56
56
|
requireXaiApiKey();
|
|
57
57
|
// `cfg` can be built by hand, without `resolveRealtimeVoiceConfig`, so the
|
|
58
|
-
//
|
|
58
|
+
// whole base URL check runs here as well as there: the ws or wss shape, and
|
|
59
|
+
// the cross-host rule that keeps XAI_API_KEY off an unencrypted link. The
|
|
60
|
+
// cross-host rule alone lets an http(s) or unparseable value reach the plugin.
|
|
59
61
|
if (cfg.xaiBaseURL) {
|
|
60
|
-
|
|
62
|
+
requireWebSocketUrl(cfg.xaiBaseURL, "xaiBaseURL");
|
|
61
63
|
}
|
|
62
64
|
const xai = await import("@livekit/agents-plugin-xai");
|
|
63
65
|
// Keys are added only when set. The plugin spreads these options over its
|
|
@@ -81,9 +83,12 @@ async function createXaiRealtimeLlm(cfg) {
|
|
|
81
83
|
* The gemini branch builds exactly what the agent has always built: Vertex AI,
|
|
82
84
|
* the configured model, transcripts of both sides, and project, location,
|
|
83
85
|
* voice and modality when set. The xai branch needs `XAI_API_KEY` in the
|
|
84
|
-
* environment and throws before touching the plugin when it is missing. It
|
|
85
|
-
*
|
|
86
|
-
*
|
|
86
|
+
* environment and throws before touching the plugin when it is missing. It then
|
|
87
|
+
* checks `xaiBaseURL` as config resolution does, and throws before touching the
|
|
88
|
+
* plugin for a value that is not a `ws://` or `wss://` URL, and for a `ws://`
|
|
89
|
+
* URL to a host other than localhost, 127.0.0.0/8 or [::1] unless
|
|
90
|
+
* `XAI_REALTIME_ALLOW_INSECURE=true`. That opt-in never admits a value that is
|
|
91
|
+
* not a ws or wss URL. The errors name `xaiBaseURL` and do not repeat the value.
|
|
87
92
|
*/
|
|
88
93
|
export async function createRealtimeLlm(cfg) {
|
|
89
94
|
switch (cfg.backend) {
|