@juspay/neurolink 13.6.1 → 13.6.3

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.
@@ -311,7 +311,13 @@
311
311
  "description": "The rule also needs the error message to match this case-insensitive regular expression (first 2,000 characters, key removed). Bounded: outside a character class no unescaped *, + or {n,}, no quantifier on a group, no count above 255, and the counts side by side multiply to at most 4,096 ways (each ? is 2, each {n,m} is m-n+1)."
312
312
  },
313
313
  "kind": { "$ref": "#/definitions/decisionErrorKind" },
314
- "retryable": { "type": "boolean" }
314
+ "retryable": { "type": "boolean" },
315
+ "hint": {
316
+ "type": "string",
317
+ "minLength": 1,
318
+ "maxLength": 300,
319
+ "description": "Text added after the error message, behind a dash, when this rule matches. For a status the vendor uses for more than one cause. {apiKeyEnvVar} is the only placeholder."
320
+ }
315
321
  }
316
322
  }
317
323
  },
@@ -24,6 +24,7 @@
24
24
  }
25
25
  }
26
26
  },
27
+ "timeouts": { "decideMs": 30000 },
27
28
  "decide": {
28
29
  "dialect": "system-one",
29
30
  "order": 8,
@@ -35,14 +36,27 @@
35
36
  "errors": {
36
37
  "rules": [
37
38
  { "status": 401, "kind": "authentication" },
38
- { "status": [402, 422], "kind": "invalid_request" },
39
+ {
40
+ "status": 402,
41
+ "kind": "invalid_request",
42
+ "hint": "ScaleDown answers HTTP 402 both when the account has no credit left and when it does not recognise the API key; check {apiKeyEnvVar} first."
43
+ },
44
+ { "status": 422, "kind": "invalid_request" },
39
45
  { "status": 429, "kind": "rate_limit" },
40
46
  { "status": 502, "kind": "server" },
41
47
  { "status": 504, "kind": "timeout" },
42
48
  { "statusMin": 500, "kind": "server" }
43
49
  ],
44
50
  "default": "invalid_request",
45
- "bodyShapes": [{ "path": "detail.message" }, { "path": "detail" }]
51
+ "bodyShapes": [
52
+ { "path": "detail.message" },
53
+ { "path": "detail" },
54
+ {
55
+ "path": "detail",
56
+ "isArray": true,
57
+ "formatter": "fastapi-validation"
58
+ }
59
+ ]
46
60
  }
47
61
  },
48
62
  "setup": {
@@ -54,7 +68,7 @@
54
68
  "1. Create an account and an API key, following https://docs.scaledown.ai",
55
69
  "2. Set {apiKeyEnvVar} in your .env file",
56
70
  "3. ScaleDown's documentation says the key goes in an x-api-key header rather than Authorization: Bearer, and NeuroLink sends it that way",
57
- "4. ScaleDown's documentation says a request made with no credit left returns HTTP 402"
71
+ "4. ScaleDown's documentation says a request made with no credit left returns HTTP 402; a live test on 2026-10-20 saw it return 402 for an unrecognised key as well, so check {apiKeyEnvVar} before adding credit"
58
72
  ]
59
73
  },
60
74
  "evidence": {
@@ -482,6 +482,13 @@ const decideMessagePatternSchema = z
482
482
  .refine((pattern) => !hasRiskyQuantifier(pattern), {
483
483
  message: "messagePattern must be bounded: an unescaped * or +, an open-ended {n,}, a {n,m} count above 255, or any quantifier on a group, outside a character class, can backtrack catastrophically on an error message. Put ?, or {n,m} up to 255, only on a plain character, a character class or an escape, and keep the counts side by side to 4096 ways in all (each ? is 2, each {n,m} is m-n+1)",
484
484
  });
485
+ const decideErrorHintSchema = z
486
+ .string()
487
+ .min(1)
488
+ .max(300)
489
+ .refine((hint) => !/[{}]/.test(hint.split("{apiKeyEnvVar}").join("")), {
490
+ message: "hint may only use the {apiKeyEnvVar} placeholder",
491
+ });
485
492
  const decideErrorRuleSchema = z.strictObject({
486
493
  status: z
487
494
  .union([decideStatusSchema, z.array(decideStatusSchema).min(1)])
@@ -490,6 +497,7 @@ const decideErrorRuleSchema = z.strictObject({
490
497
  messagePattern: decideMessagePatternSchema.optional(),
491
498
  kind: decideErrorKindSchema,
492
499
  retryable: z.boolean().optional(),
500
+ hint: decideErrorHintSchema.optional(),
493
501
  });
494
502
  const UNSAFE_PATH_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
495
503
  const HTTP_TOKEN_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
@@ -136,6 +136,7 @@ export class ConfiguredDecisionProvider extends SystemOneDecisionProvider {
136
136
  requestId,
137
137
  label: this.entry.displayName,
138
138
  apiKey: this.apiKey,
139
+ apiKeyEnvVar: catalogEnvVar(this.entry, "apiKey"),
139
140
  });
140
141
  }
141
142
  readRequestId(headers) {
@@ -5,4 +5,5 @@ export declare function classifyDecisionError(spec: DecideErrors, input: {
5
5
  requestId: string | undefined;
6
6
  label: string;
7
7
  apiKey: string;
8
+ apiKeyEnvVar?: string;
8
9
  }): DecisionError;
@@ -52,7 +52,7 @@ function firstShapeText(shapes, payload) {
52
52
  return undefined;
53
53
  }
54
54
  export function classifyDecisionError(spec, input) {
55
- const { status, payload, requestId, label, apiKey } = input;
55
+ const { status, payload, requestId, label, apiKey, apiKeyEnvVar } = input;
56
56
  // Redacted once, here: it stands in for a shape's text that redaction
57
57
  // empties, and the label is catalog data a key can match. Redaction is not
58
58
  // idempotent for a key such as "e" (it matches inside "[redacted]"), so the
@@ -65,9 +65,16 @@ export function classifyDecisionError(spec, input) {
65
65
  const rule = spec.rules.find((candidate) => ruleMatches(candidate, status, message));
66
66
  const ruleKind = rule?.kind ?? spec.default;
67
67
  const ruleRetryable = rule?.retryable ?? isRetryableByDefault(ruleKind);
68
+ // Added after the rule is chosen, so a pattern never has to allow for it,
69
+ // and redacted on its own, once, for the same reason as the fallback above.
70
+ const hinted = rule?.hint
71
+ ? `${message} — ${redactCredentials(rule.hint
72
+ .split("{apiKeyEnvVar}")
73
+ .join(apiKeyEnvVar ?? "the API key variable"), apiKey)}`
74
+ : message;
68
75
  const build = (kind, retryable) => ({
69
76
  kind,
70
- message,
77
+ message: hinted,
71
78
  status,
72
79
  requestId,
73
80
  retryable,
@@ -1838,7 +1838,12 @@ export type NativeGenerateLoopArgs = {
1838
1838
  conversation: Array<Record<string, unknown>>;
1839
1839
  /** Tool declarations in the v3 shape doGenerate already knows how to convert. */
1840
1840
  tools?: Array<Record<string, unknown>>;
1841
- /** Registered tools, used to execute a call the model asks for. */
1841
+ /**
1842
+ * Registered tools, used to execute a call the model asks for. Typed
1843
+ * `Record<string, Tool>`; it was `Record<string, unknown>` before discovery
1844
+ * learned to declare tools that join it mid-turn, so a caller that builds
1845
+ * this record from loosely typed tools now needs a cast to `Tool`.
1846
+ */
1842
1847
  toolsRecord: Record<string, Tool>;
1843
1848
  /**
1844
1849
  * Builds the v3 declaration for one tool. When set, every step declares any
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * See docs/features/livekit-voice-agent.md.
11
11
  */
12
+ import type { EventEmitter } from "node:events";
12
13
  import type { NeuroLinkEvents, TypedEventEmitter } from "./common.js";
13
14
  import type { StreamOptions, StreamResult } from "./stream.js";
14
15
  import type { ConversationSeedMessage, ConversationSeedOptions, RecordConversationTurnOptions, RecordConversationTurnResult, StreamEventSequence } from "./conversation.js";
@@ -437,6 +438,24 @@ export type RealtimeLlm = {
437
438
  label: () => string;
438
439
  close: () => Promise<void>;
439
440
  };
441
+ /**
442
+ * A session of the xAI plugin as far as the xai model's watcher needs it: an
443
+ * event emitter (the plugin republishes every server event as
444
+ * `openai_server_event_received`) that can be closed.
445
+ */
446
+ export type XaiObservedSession = EventEmitter & {
447
+ close: () => Promise<void>;
448
+ };
449
+ /**
450
+ * Token counts of one xAI response. xAI sends them at the top level of
451
+ * `response.done`; the plugin reads `response.usage`, which xAI leaves empty.
452
+ */
453
+ export type XaiResponseUsage = {
454
+ inputTokens: number;
455
+ inputAudioTokens: number;
456
+ outputTokens: number;
457
+ outputAudioTokens: number;
458
+ };
440
459
  /**
441
460
  * Realtime voice configuration resolved from the environment.
442
461
  *
@@ -290,6 +290,14 @@ export type DecideErrorRule = {
290
290
  messagePattern?: string;
291
291
  kind: DecisionErrorKind;
292
292
  retryable?: boolean;
293
+ /**
294
+ * Text added after the error message, behind a dash, when this rule is the
295
+ * one that matches. For a status the vendor uses for more than one cause,
296
+ * where the body's own words name only one of them. `{apiKeyEnvVar}` is the
297
+ * only placeholder. A `messagePattern` is judged on the message without it,
298
+ * and the key is removed from it as from the message.
299
+ */
300
+ hint?: string;
293
301
  };
294
302
  export type DecideBodyShape = {
295
303
  /** Dot path into the parsed error body. */
@@ -68,8 +68,9 @@ export function resolveBrainDefaults() {
68
68
  };
69
69
  }
70
70
  const DEFAULT_REALTIME_MODEL = "gemini-live-2.5-flash";
71
- // xAI's documented flagship Grok Voice id. Never exercised against the real
72
- // service; the plugin's own default is `grok-voice-think-fast-1.0`.
71
+ // xAI's documented flagship Grok Voice id, accepted and honoured by the live
72
+ // service (2026-10-20). The plugin's own default, `grok-voice-think-fast-1.0`,
73
+ // ran as `grok-voice-3.0` there.
73
74
  const DEFAULT_XAI_REALTIME_MODEL = "grok-voice-3.0";
74
75
  const DEFAULT_REALTIME_LOCATION = "global";
75
76
  const DEFAULT_RESPONSE_MODALITY = "AUDIO";
@@ -8,8 +8,8 @@
8
8
  * `@google/genai` are imported dynamically so the core package does not require
9
9
  * them unless the realtime agent is used.
10
10
  *
11
- * The xai backend has only been exercised against a stub WebSocket server. It
12
- * has never talked to the real xAI service.
11
+ * The xai backend was run against the real xAI service on 2026-10-20; what that
12
+ * showed, and what is still unmeasured, is in the document below.
13
13
  *
14
14
  * See docs/features/livekit-voice-agent.md.
15
15
  */
@@ -8,12 +8,13 @@
8
8
  * `@google/genai` are imported dynamically so the core package does not require
9
9
  * them unless the realtime agent is used.
10
10
  *
11
- * The xai backend has only been exercised against a stub WebSocket server. It
12
- * has never talked to the real xAI service.
11
+ * The xai backend was run against the real xAI service on 2026-10-20; what that
12
+ * showed, and what is still unmeasured, is in the document below.
13
13
  *
14
14
  * See docs/features/livekit-voice-agent.md.
15
15
  */
16
16
  import { requireWebSocketUrl } from "./config.js";
17
+ import { observeXaiSession } from "./xaiSessionWatch.js";
17
18
  /**
18
19
  * Fail early, naming the variable, when `XAI_API_KEY` is blank or absent.
19
20
  *
@@ -66,16 +67,34 @@ async function createXaiRealtimeLlm(cfg) {
66
67
  // own defaults, so a key holding `undefined` would erase the default: a
67
68
  // missing `baseURL` would send the xAI key to OPENAI_BASE_URL or
68
69
  // api.openai.com, and no `apiKey` option is passed at all because the plugin
69
- // reads XAI_API_KEY itself. The plugin forwards `baseURL` although its
70
- // declared option type leaves it out.
71
- const modelOptions = { model: cfg.model };
70
+ // reads XAI_API_KEY itself. The plugin forwards `baseURL` and
71
+ // `inputAudioTranscription` although its declared option type leaves them out.
72
+ //
73
+ // `inputAudioTranscription` is the one key set to `undefined` on purpose, to
74
+ // erase the plugin's OpenAI default of `gpt-4o-mini-transcribe`. With it xAI
75
+ // answered one spoken utterance with five "completed" transcripts, which the
76
+ // turn recorder stored as one user message with the utterance repeated;
77
+ // without it xAI sends one. `undefined`, unlike `null`, keeps the plugin's
78
+ // user-transcription capability on, and JSON.stringify leaves the key out of
79
+ // the `session.update` it sends.
80
+ const modelOptions = {
81
+ model: cfg.model,
82
+ inputAudioTranscription: undefined,
83
+ };
72
84
  if (cfg.voice) {
73
85
  modelOptions.voice = cfg.voice;
74
86
  }
75
87
  if (cfg.xaiBaseURL) {
76
88
  modelOptions.baseURL = cfg.xaiBaseURL;
77
89
  }
78
- return new xai.realtime.RealtimeModel(modelOptions);
90
+ class ObservedXaiRealtimeModel extends xai.realtime.RealtimeModel {
91
+ session() {
92
+ const session = super.session();
93
+ observeXaiSession(session, cfg.model);
94
+ return session;
95
+ }
96
+ }
97
+ return new ObservedXaiRealtimeModel(modelOptions);
79
98
  }
80
99
  /**
81
100
  * Build the realtime model for `cfg.backend`.
@@ -343,7 +343,9 @@ export function defineRealtimeVoiceAgent(config = {}) {
343
343
  };
344
344
  session.on(voice.AgentSessionEventTypes.MetricsCollected, (ev) => {
345
345
  const metrics = ev.metrics;
346
- if (metrics.type !== "realtime_model_metrics") {
346
+ // xAI leaves `response.usage` empty, which is where the plugin reads, so
347
+ // its metrics are zero; the xai model logs `realtime.usage` itself.
348
+ if (!isGemini || metrics.type !== "realtime_model_metrics") {
347
349
  return;
348
350
  }
349
351
  const inAudio = metrics.inputTokenDetails.audioTokens;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * What this package adds around an xAI realtime session.
3
+ *
4
+ * The LiveKit xAI plugin is the OpenAI realtime plugin pointed at xAI, and the
5
+ * live service differs from OpenAI in three ways the plugin does not cover
6
+ * (measured 2026-10-20, see docs/features/livekit-voice-agent.md):
7
+ *
8
+ * - xAI sends usage at the top level of `response.done` and leaves
9
+ * `response.usage` an empty object, which is what the plugin reads, so its
10
+ * metrics are zero for every response;
11
+ * - an unknown model id is accepted and runs a default model, with no error;
12
+ * - a wrong key is refused with HTTP 400 at the WebSocket upgrade, and the
13
+ * plugin opens its socket without an 'error' listener, so the refusal is an
14
+ * uncaught exception.
15
+ *
16
+ * `observeXaiSession` reads the stream of server events the plugin republishes
17
+ * as `openai_server_event_received` for the first two, and watches for the
18
+ * third.
19
+ */
20
+ import type { XaiObservedSession } from "../../types/index.js";
21
+ /**
22
+ * Log the usage of every response, warn when the service runs another model
23
+ * than the one asked for, and report a refused upgrade as a session error.
24
+ * `requestedModel` is the id the session was configured with.
25
+ */
26
+ export declare function observeXaiSession(session: XaiObservedSession, requestedModel: string): void;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * What this package adds around an xAI realtime session.
3
+ *
4
+ * The LiveKit xAI plugin is the OpenAI realtime plugin pointed at xAI, and the
5
+ * live service differs from OpenAI in three ways the plugin does not cover
6
+ * (measured 2026-10-20, see docs/features/livekit-voice-agent.md):
7
+ *
8
+ * - xAI sends usage at the top level of `response.done` and leaves
9
+ * `response.usage` an empty object, which is what the plugin reads, so its
10
+ * metrics are zero for every response;
11
+ * - an unknown model id is accepted and runs a default model, with no error;
12
+ * - a wrong key is refused with HTTP 400 at the WebSocket upgrade, and the
13
+ * plugin opens its socket without an 'error' listener, so the refusal is an
14
+ * uncaught exception.
15
+ *
16
+ * `observeXaiSession` reads the stream of server events the plugin republishes
17
+ * as `openai_server_event_received` for the first two, and watches for the
18
+ * third.
19
+ */
20
+ import { logger } from "../../utils/logger.js";
21
+ const TOKEN_COUNT_KEYS = ["input_tokens", "output_tokens"];
22
+ const REFUSED_UPGRADE = /^Unexpected server response: (\d{3})$/;
23
+ const KEY_REFUSAL_STATUSES = new Set([400, 401, 403]);
24
+ function isRecord(value) {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26
+ }
27
+ function countOf(source, key) {
28
+ const value = source[key];
29
+ return typeof value === "number" && Number.isFinite(value) && value > 0
30
+ ? value
31
+ : 0;
32
+ }
33
+ function detailOf(source, group, key) {
34
+ const details = source[group];
35
+ return isRecord(details) ? countOf(details, key) : 0;
36
+ }
37
+ /**
38
+ * The token counts of a `response.done` event, or `undefined` when it reports
39
+ * none. xAI's own place is the top level of the event; `response.usage`, where
40
+ * OpenAI puts them, is the fallback, so a service that fills it still works.
41
+ * A report with seconds but no token count (a cancelled response) is not usage
42
+ * and would log as zeros.
43
+ */
44
+ function readXaiResponseUsage(event) {
45
+ if (!isRecord(event) || event.type !== "response.done") {
46
+ return undefined;
47
+ }
48
+ const nested = isRecord(event.response) ? event.response.usage : undefined;
49
+ const usage = [event.usage, nested].find((candidate) => isRecord(candidate) &&
50
+ TOKEN_COUNT_KEYS.some((key) => typeof candidate[key] === "number"));
51
+ if (usage === undefined) {
52
+ return undefined;
53
+ }
54
+ return {
55
+ inputTokens: countOf(usage, "input_tokens"),
56
+ inputAudioTokens: detailOf(usage, "input_token_details", "audio_tokens"),
57
+ outputTokens: countOf(usage, "output_tokens"),
58
+ outputAudioTokens: detailOf(usage, "output_token_details", "audio_tokens"),
59
+ };
60
+ }
61
+ /** The same record the agent logs for the Gemini backend, built from the same three counts. */
62
+ function logUsage(usage) {
63
+ logger.info("realtime.usage", {
64
+ inputTokens: usage.inputTokens,
65
+ inputAudioTokens: usage.inputAudioTokens,
66
+ inputTextTokens: Math.max(0, usage.inputTokens - usage.inputAudioTokens),
67
+ outputTokens: usage.outputTokens,
68
+ outputAudioTokens: usage.outputAudioTokens,
69
+ outputTextTokens: Math.max(0, usage.outputTokens - usage.outputAudioTokens),
70
+ });
71
+ }
72
+ function describeRefusal(status) {
73
+ const keyHint = KEY_REFUSAL_STATUSES.has(status)
74
+ ? " xAI answers a wrong XAI_API_KEY with HTTP 400, not 401, so check the key first."
75
+ : "";
76
+ return `xAI refused the realtime connection with HTTP ${status}.${keyHint}`;
77
+ }
78
+ /**
79
+ * Turn the plugin's uncaught socket error into the error event the plugin
80
+ * meant to emit. Returns the function that ends the watch.
81
+ *
82
+ * `uncaughtExceptionMonitor` observes a crash without handling it, so the
83
+ * process still fails exactly as it would have; what changes is that the
84
+ * session reports a classified, unrecoverable error first, which the agent
85
+ * entry already logs and publishes. The watch is on only while the connection
86
+ * is being made, so it cannot take a crash elsewhere in the process for a
87
+ * refused upgrade once the call is running.
88
+ *
89
+ * A crash event carries no socket, so a refusal cannot be tied to one session:
90
+ * every session still connecting is told. That is accurate, because the crash
91
+ * ends the process for all of them, and the sessions of one process share
92
+ * `XAI_API_KEY`, so a refused key is refused for each.
93
+ */
94
+ function watchForRefusedUpgrade(session) {
95
+ const onCrash = (error) => {
96
+ const status = error instanceof Error
97
+ ? Number(REFUSED_UPGRADE.exec(error.message)?.[1])
98
+ : Number.NaN;
99
+ if (Number.isNaN(status)) {
100
+ return;
101
+ }
102
+ stop();
103
+ const message = describeRefusal(status);
104
+ logger.error("realtime.xai.connectRefused", { status, error: message });
105
+ if (session.listenerCount("error") === 0) {
106
+ return;
107
+ }
108
+ try {
109
+ session.emit("error", {
110
+ type: "realtime_model_error",
111
+ timestamp: Date.now(),
112
+ label: "xai.RealtimeModel",
113
+ error: new Error(message),
114
+ recoverable: false,
115
+ });
116
+ }
117
+ catch {
118
+ /* the process is already failing; a listener's error must not mask it */
119
+ }
120
+ };
121
+ const stop = () => {
122
+ process.off("uncaughtExceptionMonitor", onCrash);
123
+ };
124
+ process.on("uncaughtExceptionMonitor", onCrash);
125
+ return stop;
126
+ }
127
+ /**
128
+ * Log the usage of every response, warn when the service runs another model
129
+ * than the one asked for, and report a refused upgrade as a session error.
130
+ * `requestedModel` is the id the session was configured with.
131
+ */
132
+ export function observeXaiSession(session, requestedModel) {
133
+ const stopWatching = watchForRefusedUpgrade(session);
134
+ session.on("openai_server_event_received", (event) => {
135
+ if (isRecord(event) && event.type === "session.created") {
136
+ stopWatching();
137
+ const served = isRecord(event.session) ? event.session.model : undefined;
138
+ if (typeof served === "string" && served !== requestedModel) {
139
+ logger.warn("realtime.xai.modelMismatch", {
140
+ requested: requestedModel,
141
+ served,
142
+ note: "xAI runs a default model for an id it does not know, without an error; an alias comes back as the model it points to",
143
+ });
144
+ }
145
+ return;
146
+ }
147
+ const usage = readXaiResponseUsage(event);
148
+ if (usage !== undefined) {
149
+ logUsage(usage);
150
+ }
151
+ });
152
+ const close = session.close.bind(session);
153
+ session.close = async () => {
154
+ stopWatching();
155
+ await close();
156
+ };
157
+ }