@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.
@@ -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?.data)
46
- .filter((d) => typeof d === "string" && d.length > 0);
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, localRefusal.code, { errorCode: localRefusal.code, retryable: false });
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, ServerAgentRouteOptions } from "../../types/index.js";
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?: ServerAgentRouteOptions): Promise<ServerAgentResolvedInput>;
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 &&
@@ -3598,6 +3598,7 @@ export type OpenAIErrorResponse = {
3598
3598
  export type ProxyGeminiPart = {
3599
3599
  text?: string;
3600
3600
  inlineData?: {
3601
+ mimeType?: string;
3601
3602
  data?: string;
3602
3603
  };
3603
3604
  };
@@ -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
- * Refuse a `ws://` URL to a host other than loopback unless
28
- * `XAI_REALTIME_ALLOW_INSECURE=true`: the xAI plugin sends `XAI_API_KEY` to the
29
- * URL it is given, and `ws://` sends it unencrypted. Anything else passes,
30
- * including a value that is not a ws or wss URL, which is for the caller's own
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 requireNoCleartextCrossHost(value: string, name: string): void;
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
- export function requireNoCleartextCrossHost(value, name) {
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
- /** A ws or wss URL that passes `requireNoCleartextCrossHost`. The error names the setting and does not repeat the value. */
146
- function requireWebSocketUrl(value, name) {
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 also
32
- * throws before touching the plugin for a `ws://` base URL to a host other than
33
- * loopback, unless `XAI_REALTIME_ALLOW_INSECURE=true`, as config resolution does.
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 { requireNoCleartextCrossHost } from "./config.js";
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
- // key-leak check runs here as well as there.
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
- requireNoCleartextCrossHost(cfg.xaiBaseURL, "xaiBaseURL");
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 also
85
- * throws before touching the plugin for a `ws://` base URL to a host other than
86
- * loopback, unless `XAI_REALTIME_ALLOW_INSECURE=true`, as config resolution does.
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) {