@juspay/neurolink 13.2.0 → 13.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * See docs/features/livekit-voice-agent.md.
9
9
  */
10
- import type { LiveKitServerConfig, LiveKitBrainDefaults, RealtimeVoiceConfig } from "../../types/index.js";
10
+ import type { LiveKitServerConfig, LiveKitBrainDefaults, RealtimeVoiceAgentConfig, RealtimeVoiceConfig } from "../../types/index.js";
11
11
  /**
12
12
  * Resolve LiveKit server connection settings from the environment.
13
13
  *
@@ -24,16 +24,21 @@ export declare function resolveLiveKitServerConfig(): LiveKitServerConfig;
24
24
  */
25
25
  export declare function resolveBrainDefaults(): LiveKitBrainDefaults;
26
26
  /**
27
- * Resolve the realtime (Gemini Live speech-to-speech) voice configuration from
28
- * the environment.
27
+ * Resolve the realtime (speech-to-speech) voice configuration from the
28
+ * environment.
29
29
  *
30
30
  * Every value has a safe default so a worker can run with no realtime-specific
31
- * env at all. Vertex `project` is resolved from `VERTEX_PROJECT`, then the
32
- * service-account project (`GOOGLE_AUTH_BREEZE_PROJECT_ID`), then
33
- * `GOOGLE_CLOUD_PROJECT_ID`. The MCP URL is `VOICE_MCP_URL` if set, otherwise
34
- * `${LIGHTHOUSE_URL}/ai/mcp/v2`.
31
+ * env at all. `VOICE_REALTIME_BACKEND` picks the model family (`gemini`, the
32
+ * default, or `xai`); `backend` here overrides it and then the environment's
33
+ * value is not read. A non-blank `xaiBaseURL` here likewise replaces
34
+ * `XAI_REALTIME_BASE_URL` after the same ws or wss check.
35
+ * `VOICE_LIVE_MODEL` overrides the backend's default model.
36
+ * Vertex `project` is resolved from `VERTEX_PROJECT`, then the service-account
37
+ * project (`GOOGLE_AUTH_BREEZE_PROJECT_ID`), then `GOOGLE_CLOUD_PROJECT_ID`;
38
+ * project, location and modality only matter to the gemini backend. The MCP URL
39
+ * is `VOICE_MCP_URL` if set, otherwise `${LIGHTHOUSE_URL}/ai/mcp/v2`.
35
40
  */
36
- export declare function resolveRealtimeVoiceConfig(): RealtimeVoiceConfig;
41
+ export declare function resolveRealtimeVoiceConfig(overrides?: Pick<RealtimeVoiceAgentConfig, "backend" | "xaiBaseURL">): RealtimeVoiceConfig;
37
42
  /**
38
43
  * Resolve the semantic end-of-utterance (EOU) turn-detection settings.
39
44
  *
@@ -68,6 +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`.
73
+ const DEFAULT_XAI_REALTIME_MODEL = "grok-voice-3.0";
71
74
  const DEFAULT_REALTIME_LOCATION = "global";
72
75
  const DEFAULT_RESPONSE_MODALITY = "AUDIO";
73
76
  const DEFAULT_SYSTEM_PROMPT = "You are a concise voice assistant for a merchant dashboard. Keep replies short and spoken.";
@@ -75,25 +78,86 @@ const DEFAULT_GREETING = "Briefly greet the merchant and ask how you can help wi
75
78
  const DEFAULT_LIGHTHOUSE_URL = "http://localhost:5173";
76
79
  const DEFAULT_MCP_PATH = "/ai/mcp/v2";
77
80
  /**
78
- * Resolve the realtime (Gemini Live speech-to-speech) voice configuration from
79
- * the environment.
81
+ * The backend named by `VOICE_REALTIME_BACKEND`: `gemini` or `xai`, matched
82
+ * case-insensitively, with blank or absent meaning `gemini`. The error names the
83
+ * variable and the choices and repeats nothing from the environment.
84
+ */
85
+ function readRealtimeBackend() {
86
+ const value = readEnv("VOICE_REALTIME_BACKEND", "gemini").toLowerCase();
87
+ if (value === "gemini" || value === "xai") {
88
+ return value;
89
+ }
90
+ throw new Error('VOICE_REALTIME_BACKEND must be "gemini" or "xai" (leave it unset for gemini)');
91
+ }
92
+ /**
93
+ * A backend passed in code. The type already limits it, but a plain-JavaScript
94
+ * caller is not held by the type, and a typo would otherwise take the xai
95
+ * branch and fail on a missing key. The error repeats nothing it was given.
96
+ */
97
+ function requireKnownBackend(value) {
98
+ if (value === "gemini" || value === "xai") {
99
+ return value;
100
+ }
101
+ throw new Error('The realtime voice backend must be "gemini" or "xai"');
102
+ }
103
+ function isWebSocketUrl(value) {
104
+ try {
105
+ const { protocol } = new URL(value);
106
+ return protocol === "ws:" || protocol === "wss:";
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ }
112
+ /** A ws or wss URL. The error names the setting and does not repeat the value. */
113
+ function requireWebSocketUrl(value, name) {
114
+ if (!isWebSocketUrl(value)) {
115
+ throw new Error(`${name} must be a ws:// or wss:// URL`);
116
+ }
117
+ return value;
118
+ }
119
+ /** `XAI_REALTIME_BASE_URL`, which must be a ws or wss URL when set. */
120
+ function readXaiBaseUrl() {
121
+ const value = readOptionalEnv("XAI_REALTIME_BASE_URL");
122
+ return value === undefined
123
+ ? undefined
124
+ : requireWebSocketUrl(value, "XAI_REALTIME_BASE_URL");
125
+ }
126
+ /** A caller-supplied `xaiBaseURL` gets the same check as the variable; blank means unset, so the variable applies. */
127
+ function resolveXaiBaseUrl(override) {
128
+ const value = override?.trim();
129
+ return value ? requireWebSocketUrl(value, "xaiBaseURL") : readXaiBaseUrl();
130
+ }
131
+ /**
132
+ * Resolve the realtime (speech-to-speech) voice configuration from the
133
+ * environment.
80
134
  *
81
135
  * Every value has a safe default so a worker can run with no realtime-specific
82
- * env at all. Vertex `project` is resolved from `VERTEX_PROJECT`, then the
83
- * service-account project (`GOOGLE_AUTH_BREEZE_PROJECT_ID`), then
84
- * `GOOGLE_CLOUD_PROJECT_ID`. The MCP URL is `VOICE_MCP_URL` if set, otherwise
85
- * `${LIGHTHOUSE_URL}/ai/mcp/v2`.
136
+ * env at all. `VOICE_REALTIME_BACKEND` picks the model family (`gemini`, the
137
+ * default, or `xai`); `backend` here overrides it and then the environment's
138
+ * value is not read. A non-blank `xaiBaseURL` here likewise replaces
139
+ * `XAI_REALTIME_BASE_URL` after the same ws or wss check.
140
+ * `VOICE_LIVE_MODEL` overrides the backend's default model.
141
+ * Vertex `project` is resolved from `VERTEX_PROJECT`, then the service-account
142
+ * project (`GOOGLE_AUTH_BREEZE_PROJECT_ID`), then `GOOGLE_CLOUD_PROJECT_ID`;
143
+ * project, location and modality only matter to the gemini backend. The MCP URL
144
+ * is `VOICE_MCP_URL` if set, otherwise `${LIGHTHOUSE_URL}/ai/mcp/v2`.
86
145
  */
87
- export function resolveRealtimeVoiceConfig() {
146
+ export function resolveRealtimeVoiceConfig(overrides = {}) {
147
+ const backend = overrides.backend === undefined
148
+ ? readRealtimeBackend()
149
+ : requireKnownBackend(overrides.backend);
88
150
  const lighthouseUrl = readEnv("LIGHTHOUSE_URL", DEFAULT_LIGHTHOUSE_URL);
89
151
  const mcpUrl = readEnv("VOICE_MCP_URL", `${lighthouseUrl}${DEFAULT_MCP_PATH}`);
90
152
  return {
153
+ backend,
91
154
  project: readOptionalEnv("VERTEX_PROJECT") ??
92
155
  readOptionalEnv("GOOGLE_AUTH_BREEZE_PROJECT_ID") ??
93
156
  readOptionalEnv("GOOGLE_CLOUD_PROJECT_ID"),
94
157
  location: readEnv("VERTEX_LOCATION", DEFAULT_REALTIME_LOCATION),
95
- model: readEnv("VOICE_LIVE_MODEL", DEFAULT_REALTIME_MODEL),
158
+ model: readEnv("VOICE_LIVE_MODEL", backend === "xai" ? DEFAULT_XAI_REALTIME_MODEL : DEFAULT_REALTIME_MODEL),
96
159
  voice: readOptionalEnv("VOICE_S2S_VOICE"),
160
+ xaiBaseURL: resolveXaiBaseUrl(overrides.xaiBaseURL),
97
161
  responseModality: readEnv("VOICE_RESPONSE_MODALITY", DEFAULT_RESPONSE_MODALITY).toUpperCase(),
98
162
  systemPrompt: readEnv("VOICE_SYSTEM_PROMPT", DEFAULT_SYSTEM_PROMPT),
99
163
  greeting: readEnv("VOICE_GREETING", DEFAULT_GREETING),
@@ -15,6 +15,7 @@ export { mintJoinToken } from "./tokens.js";
15
15
  export { createVoiceRoom, dispatchVoiceAgent } from "./roomDispatch.js";
16
16
  export { defineVoiceAgent } from "./voiceAgent.js";
17
17
  export { defineRealtimeVoiceAgent } from "./realtimeVoiceAgent.js";
18
+ export { createRealtimeLlm } from "./realtimeModel.js";
18
19
  export { startVoiceAgentWorker, startRealtimeVoiceAgentWorker, installVoiceWorkerProcessGuards, } from "./voiceAgentWorker.js";
19
20
  export { ensureVertexAdc, clearGeminiApiKeyEnv } from "./vertexAuth.js";
20
21
  export { readCallContextFromRoom } from "./roomContext.js";
@@ -15,6 +15,7 @@ export { mintJoinToken } from "./tokens.js";
15
15
  export { createVoiceRoom, dispatchVoiceAgent } from "./roomDispatch.js";
16
16
  export { defineVoiceAgent } from "./voiceAgent.js";
17
17
  export { defineRealtimeVoiceAgent } from "./realtimeVoiceAgent.js";
18
+ export { createRealtimeLlm } from "./realtimeModel.js";
18
19
  export { startVoiceAgentWorker, startRealtimeVoiceAgentWorker, installVoiceWorkerProcessGuards, } from "./voiceAgentWorker.js";
19
20
  export { ensureVertexAdc, clearGeminiApiKeyEnv } from "./vertexAuth.js";
20
21
  export { readCallContextFromRoom } from "./roomContext.js";
@@ -1,12 +1,13 @@
1
1
  /**
2
- * MCP tools → Gemini function tools bridge (realtime mode).
2
+ * MCP tools → realtime function tools bridge (realtime mode).
3
3
  *
4
- * In speech-to-speech mode Gemini owns the tool loop, so an external MCP
5
- * server's tools are registered as LiveKit/Gemini function tools rather than
6
- * NeuroLink tools. This connects to the MCP server as the calling user
7
- * (`x-auth-token` + base64 `x-context`), sanitizes each tool's schema into the
8
- * Gemini-safe subset, wires WRITE-labeled (destructive) tools through a HITL
9
- * confirmation, and forwards tool start/result events to the browser bridge.
4
+ * In speech-to-speech mode the realtime model (Gemini Live, or xAI Grok Voice)
5
+ * owns the tool loop, so an external MCP server's tools are registered as
6
+ * LiveKit function tools rather than NeuroLink tools. This connects to the MCP
7
+ * server as the calling user (`x-auth-token` + base64 `x-context`), sanitizes
8
+ * each tool's schema into the Gemini-safe subset (kept for both backends), wires
9
+ * WRITE-labeled (destructive) tools through a HITL confirmation, and forwards
10
+ * tool start/result events to the browser bridge.
10
11
  *
11
12
  * `@livekit/agents` and `@modelcontextprotocol/sdk` are imported dynamically so
12
13
  * the core package does not require them unless the realtime agent is used.
@@ -1,12 +1,13 @@
1
1
  /**
2
- * MCP tools → Gemini function tools bridge (realtime mode).
2
+ * MCP tools → realtime function tools bridge (realtime mode).
3
3
  *
4
- * In speech-to-speech mode Gemini owns the tool loop, so an external MCP
5
- * server's tools are registered as LiveKit/Gemini function tools rather than
6
- * NeuroLink tools. This connects to the MCP server as the calling user
7
- * (`x-auth-token` + base64 `x-context`), sanitizes each tool's schema into the
8
- * Gemini-safe subset, wires WRITE-labeled (destructive) tools through a HITL
9
- * confirmation, and forwards tool start/result events to the browser bridge.
4
+ * In speech-to-speech mode the realtime model (Gemini Live, or xAI Grok Voice)
5
+ * owns the tool loop, so an external MCP server's tools are registered as
6
+ * LiveKit function tools rather than NeuroLink tools. This connects to the MCP
7
+ * server as the calling user (`x-auth-token` + base64 `x-context`), sanitizes
8
+ * each tool's schema into the Gemini-safe subset (kept for both backends), wires
9
+ * WRITE-labeled (destructive) tools through a HITL confirmation, and forwards
10
+ * tool start/result events to the browser bridge.
10
11
  *
11
12
  * `@livekit/agents` and `@modelcontextprotocol/sdk` are imported dynamically so
12
13
  * the core package does not require them unless the realtime agent is used.
@@ -52,7 +53,7 @@ export function mcpResultToText(result) {
52
53
  return text || "Tool returned no content.";
53
54
  }
54
55
  /**
55
- * The function-result text returned to Gemini when the user rejects a HITL tool.
56
+ * The function-result text returned to the model when the user rejects a HITL tool.
56
57
  * Scope the rejection to THIS attempt — a "never retry" wording made the model
57
58
  * refuse the tool permanently even when the user later asked for it again.
58
59
  */
@@ -163,7 +164,7 @@ export async function buildRealtimeMcpTools(params) {
163
164
  try {
164
165
  // Third argument is RequestOptions; the SDK aborts the in-flight
165
166
  // request when `timeout` elapses. Without it a stalled server holds
166
- // the turn open indefinitely — Gemini blocks on the function
167
+ // the turn open indefinitely — the model waits on the function
167
168
  // result, so the user hears nothing at all rather than an error.
168
169
  const result = await client.callTool({
169
170
  name: mcpTool.name,
@@ -17,6 +17,14 @@
17
17
  * See docs/features/livekit-voice-agent.md.
18
18
  */
19
19
  import type { JobContext } from "@livekit/agents";
20
- import type { RealtimeCallMemory, RealtimeVoiceMemoryConfig } from "../../types/index.js";
21
- /** Verify the call's binding and prepare its recorder; `undefined` means "no memory for this call". */
22
- export declare function setUpRealtimeCallMemory(ctx: Pick<JobContext, "room" | "addShutdownCallback">, config: RealtimeVoiceMemoryConfig | undefined, model?: string): Promise<RealtimeCallMemory | undefined>;
20
+ import type { RealtimeCallMemory, RealtimeVoiceBackend, RealtimeVoiceMemoryConfig } from "../../types/index.js";
21
+ /**
22
+ * Verify the call's binding and prepare its recorder; `undefined` means "no
23
+ * memory for this call". `call` names the model and the realtime backend the
24
+ * call runs on, which the turn spans carry as `model` and `provider`; the
25
+ * backend defaults to `gemini`, the original and default one.
26
+ */
27
+ export declare function setUpRealtimeCallMemory(ctx: Pick<JobContext, "room" | "addShutdownCallback">, config: RealtimeVoiceMemoryConfig | undefined, call?: {
28
+ model?: string;
29
+ backend?: RealtimeVoiceBackend;
30
+ }): Promise<RealtimeCallMemory | undefined>;
@@ -17,6 +17,7 @@
17
17
  * See docs/features/livekit-voice-agent.md.
18
18
  */
19
19
  import { context, trace } from "@opentelemetry/api";
20
+ import { AIProviderName } from "../../constants/enums.js";
20
21
  import { setLangfuseContext } from "../../services/server/ai/observability/instrumentation.js";
21
22
  import { observe, observeTrace } from "../../telemetry/observation.js";
22
23
  import { tracers } from "../../telemetry/tracers.js";
@@ -24,8 +25,13 @@ import { logger } from "../../utils/logger.js";
24
25
  import { VOICE_MEMORY_SECRET_ENV, verifyVoiceMemoryBinding, } from "./memoryBinding.js";
25
26
  import { createVoiceTurnRecorder } from "./turnRecorder.js";
26
27
  const DEFAULT_FLUSH_BUDGET_MS = 800;
28
+ /** The provider a backend's turn spans are attributed to: Gemini Live runs on Vertex AI, Grok Voice on xAI. */
29
+ const TRACE_PROVIDER = {
30
+ gemini: AIProviderName.VERTEX,
31
+ xai: AIProviderName.XAI,
32
+ };
27
33
  function traceTurn(turn, call) {
28
- const { sessionId, userId, roomName, model } = call;
34
+ const { sessionId, userId, roomName, model, provider } = call;
29
35
  try {
30
36
  setLangfuseContext({
31
37
  sessionId,
@@ -55,7 +61,7 @@ function traceTurn(turn, call) {
55
61
  input: [{ role: "user", content: turn.userMessage }],
56
62
  output: [{ role: "assistant", content: turn.aiResponse }],
57
63
  level,
58
- metadata: { provider: "vertex" },
64
+ metadata: { provider },
59
65
  });
60
66
  generation.end(endTime);
61
67
  const events = turn.events ?? [];
@@ -80,8 +86,14 @@ function traceTurn(turn, call) {
80
86
  });
81
87
  }
82
88
  }
83
- /** Verify the call's binding and prepare its recorder; `undefined` means "no memory for this call". */
84
- export async function setUpRealtimeCallMemory(ctx, config, model) {
89
+ /**
90
+ * Verify the call's binding and prepare its recorder; `undefined` means "no
91
+ * memory for this call". `call` names the model and the realtime backend the
92
+ * call runs on, which the turn spans carry as `model` and `provider`; the
93
+ * backend defaults to `gemini`, the original and default one.
94
+ */
95
+ export async function setUpRealtimeCallMemory(ctx, config, call = {}) {
96
+ const { model, backend = "gemini" } = call;
85
97
  if (!config) {
86
98
  return undefined;
87
99
  }
@@ -116,7 +128,13 @@ export async function setUpRealtimeCallMemory(ctx, config, model) {
116
128
  write: async (turn) => {
117
129
  if (!tracedTurns.has(turn.turnId)) {
118
130
  tracedTurns.add(turn.turnId);
119
- traceTurn(turn, { sessionId, userId, roomName: ctx.room.name, model });
131
+ traceTurn(turn, {
132
+ sessionId,
133
+ userId,
134
+ roomName: ctx.room.name,
135
+ model,
136
+ provider: TRACE_PROVIDER[backend],
137
+ });
120
138
  }
121
139
  const result = await neurolink.recordConversationTurn({
122
140
  sessionId,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The realtime model factory: builds the LiveKit speech-to-speech model for the
3
+ * configured backend.
4
+ *
5
+ * `gemini` is Gemini Live on Vertex through `@livekit/agents-plugin-google`;
6
+ * `xai` is Grok Voice through `@livekit/agents-plugin-xai`, which speaks the
7
+ * OpenAI realtime protocol to `wss://api.x.ai/v1`. Both plugins and
8
+ * `@google/genai` are imported dynamically so the core package does not require
9
+ * them unless the realtime agent is used.
10
+ *
11
+ * The xai backend has only been exercised against a stub WebSocket server. It
12
+ * has never talked to the real xAI service.
13
+ *
14
+ * See docs/features/livekit-voice-agent.md.
15
+ */
16
+ import type { RealtimeLlm, RealtimeVoiceConfig } from "../../types/index.js";
17
+ /**
18
+ * Fail early, naming the variable, when `XAI_API_KEY` is blank or absent.
19
+ *
20
+ * The plugin reads the key itself and falls back to `OPENAI_API_KEY` when its
21
+ * own is missing, which would send an OpenAI key to the xAI host, so a missing
22
+ * key must stop here. The message never carries a value.
23
+ */
24
+ export declare function requireXaiApiKey(): void;
25
+ /**
26
+ * Build the realtime model for `cfg.backend`.
27
+ *
28
+ * The gemini branch builds exactly what the agent has always built: Vertex AI,
29
+ * the configured model, transcripts of both sides, and project, location,
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.
32
+ */
33
+ export declare function createRealtimeLlm(cfg: RealtimeVoiceConfig): Promise<RealtimeLlm>;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The realtime model factory: builds the LiveKit speech-to-speech model for the
3
+ * configured backend.
4
+ *
5
+ * `gemini` is Gemini Live on Vertex through `@livekit/agents-plugin-google`;
6
+ * `xai` is Grok Voice through `@livekit/agents-plugin-xai`, which speaks the
7
+ * OpenAI realtime protocol to `wss://api.x.ai/v1`. Both plugins and
8
+ * `@google/genai` are imported dynamically so the core package does not require
9
+ * them unless the realtime agent is used.
10
+ *
11
+ * The xai backend has only been exercised against a stub WebSocket server. It
12
+ * has never talked to the real xAI service.
13
+ *
14
+ * See docs/features/livekit-voice-agent.md.
15
+ */
16
+ /**
17
+ * Fail early, naming the variable, when `XAI_API_KEY` is blank or absent.
18
+ *
19
+ * The plugin reads the key itself and falls back to `OPENAI_API_KEY` when its
20
+ * own is missing, which would send an OpenAI key to the xAI host, so a missing
21
+ * key must stop here. The message never carries a value.
22
+ */
23
+ export function requireXaiApiKey() {
24
+ const key = process.env.XAI_API_KEY;
25
+ if (typeof key !== "string" || key.trim().length === 0) {
26
+ throw new Error("XAI_API_KEY is not set; the xai realtime backend reads its key from that environment variable");
27
+ }
28
+ }
29
+ async function createGeminiRealtimeLlm(cfg) {
30
+ const google = await import("@livekit/agents-plugin-google");
31
+ const { Modality } = await import("@google/genai");
32
+ const modality = Object.values(Modality).find((value) => value === cfg.responseModality);
33
+ const modelOptions = {
34
+ vertexai: true,
35
+ model: cfg.model,
36
+ // Emit text transcripts of BOTH sides; LiveKit forwards them to the room.
37
+ inputAudioTranscription: {},
38
+ outputAudioTranscription: {},
39
+ };
40
+ if (modality !== undefined) {
41
+ modelOptions.modalities = [modality];
42
+ }
43
+ if (cfg.project) {
44
+ modelOptions.project = cfg.project;
45
+ }
46
+ if (cfg.location) {
47
+ modelOptions.location = cfg.location;
48
+ }
49
+ if (cfg.voice) {
50
+ modelOptions.voice = cfg.voice;
51
+ }
52
+ return new google.realtime.RealtimeModel(modelOptions);
53
+ }
54
+ async function createXaiRealtimeLlm(cfg) {
55
+ requireXaiApiKey();
56
+ const xai = await import("@livekit/agents-plugin-xai");
57
+ // Keys are added only when set. The plugin spreads these options over its
58
+ // own defaults, so a key holding `undefined` would erase the default: a
59
+ // missing `baseURL` would send the xAI key to OPENAI_BASE_URL or
60
+ // api.openai.com, and no `apiKey` option is passed at all because the plugin
61
+ // reads XAI_API_KEY itself. The plugin forwards `baseURL` although its
62
+ // declared option type leaves it out.
63
+ const modelOptions = { model: cfg.model };
64
+ if (cfg.voice) {
65
+ modelOptions.voice = cfg.voice;
66
+ }
67
+ if (cfg.xaiBaseURL) {
68
+ modelOptions.baseURL = cfg.xaiBaseURL;
69
+ }
70
+ return new xai.realtime.RealtimeModel(modelOptions);
71
+ }
72
+ /**
73
+ * Build the realtime model for `cfg.backend`.
74
+ *
75
+ * The gemini branch builds exactly what the agent has always built: Vertex AI,
76
+ * the configured model, transcripts of both sides, and project, location,
77
+ * voice and modality when set. The xai branch needs `XAI_API_KEY` in the
78
+ * environment and throws before touching the plugin when it is missing.
79
+ */
80
+ export async function createRealtimeLlm(cfg) {
81
+ switch (cfg.backend) {
82
+ case "gemini":
83
+ return createGeminiRealtimeLlm(cfg);
84
+ case "xai":
85
+ return createXaiRealtimeLlm(cfg);
86
+ default:
87
+ throw new Error('The realtime backend must be "gemini" or "xai"');
88
+ }
89
+ }
@@ -1,21 +1,25 @@
1
1
  /**
2
- * LiveKit Agents agent definition — realtime (Gemini Live speech-to-speech).
2
+ * LiveKit Agents agent definition — realtime (speech-to-speech).
3
3
  *
4
4
  * `defineRealtimeVoiceAgent` returns the agent object placed as the default
5
5
  * export of a worker entry file. Unlike the cascaded `defineVoiceAgent`
6
6
  * (Silero VAD → STT → NeuroLink → TTS), here a single realtime model (Gemini
7
- * Live on Vertex) does STT + reasoning + TTS + turn detection over one
7
+ * Live on Vertex, or xAI Grok Voice) does STT + reasoning + TTS + turn
8
+ * detection over one connection. The backend is chosen by `VOICE_REALTIME_BACKEND`
9
+ * or the `backend` option; Gemini Live is the default.
8
10
  *
9
- * `@livekit/agents`, `@livekit/agents-plugin-google`, `@livekit/rtc-node`, and
10
- * `@google/genai` are imported dynamically so the core package does not require
11
- * them unless the realtime agent is used. Type-only imports are erased at build.
11
+ * `@livekit/agents`, the backend's LiveKit plugin (see `realtimeModel.ts`),
12
+ * `@livekit/rtc-node`, and `@google/genai` are imported dynamically so the core
13
+ * package does not require them unless the realtime agent is used. Type-only
14
+ * imports are erased at build.
12
15
  *
13
16
  * See docs/features/livekit-voice-agent.md.
14
17
  */
15
18
  import type { JobContext } from "@livekit/agents";
16
19
  import type { RealtimeVoiceAgentConfig } from "../../types/index.js";
17
20
  /**
18
- * Define a realtime (Gemini Live S2S) LiveKit voice agent.
21
+ * Define a realtime (speech-to-speech) LiveKit voice agent: Gemini Live by
22
+ * default, xAI Grok Voice with `VOICE_REALTIME_BACKEND=xai` or `backend: "xai"`.
19
23
  *
20
24
  * Place the result as the default export of the worker entry file and launch it
21
25
  * with `startRealtimeVoiceAgentWorker`. With no `config` everything is resolved
@@ -1,14 +1,17 @@
1
1
  /**
2
- * LiveKit Agents agent definition — realtime (Gemini Live speech-to-speech).
2
+ * LiveKit Agents agent definition — realtime (speech-to-speech).
3
3
  *
4
4
  * `defineRealtimeVoiceAgent` returns the agent object placed as the default
5
5
  * export of a worker entry file. Unlike the cascaded `defineVoiceAgent`
6
6
  * (Silero VAD → STT → NeuroLink → TTS), here a single realtime model (Gemini
7
- * Live on Vertex) does STT + reasoning + TTS + turn detection over one
7
+ * Live on Vertex, or xAI Grok Voice) does STT + reasoning + TTS + turn
8
+ * detection over one connection. The backend is chosen by `VOICE_REALTIME_BACKEND`
9
+ * or the `backend` option; Gemini Live is the default.
8
10
  *
9
- * `@livekit/agents`, `@livekit/agents-plugin-google`, `@livekit/rtc-node`, and
10
- * `@google/genai` are imported dynamically so the core package does not require
11
- * them unless the realtime agent is used. Type-only imports are erased at build.
11
+ * `@livekit/agents`, the backend's LiveKit plugin (see `realtimeModel.ts`),
12
+ * `@livekit/rtc-node`, and `@google/genai` are imported dynamically so the core
13
+ * package does not require them unless the realtime agent is used. Type-only
14
+ * imports are erased at build.
12
15
  *
13
16
  * See docs/features/livekit-voice-agent.md.
14
17
  */
@@ -16,6 +19,7 @@ import { z } from "zod";
16
19
  import { logger } from "../../utils/logger.js";
17
20
  import { resolveRealtimeVoiceConfig } from "./config.js";
18
21
  import { ensureVertexAdc, clearGeminiApiKeyEnv } from "./vertexAuth.js";
22
+ import { createRealtimeLlm, requireXaiApiKey } from "./realtimeModel.js";
19
23
  import { readCallContextFromRoom } from "./roomContext.js";
20
24
  import { attachRealtimeEventBridge } from "./realtimeEventBridge.js";
21
25
  import { buildRealtimeMcpTools } from "./realtimeMcpTools.js";
@@ -119,7 +123,10 @@ async function installRealtimeJobLifecycle(ctx, cfg) {
119
123
  }
120
124
  /** Merge caller overrides over the env-resolved realtime config. */
121
125
  function resolveConfig(overrides) {
122
- const base = resolveRealtimeVoiceConfig();
126
+ const base = resolveRealtimeVoiceConfig({
127
+ backend: overrides.backend,
128
+ xaiBaseURL: overrides.xaiBaseURL,
129
+ });
123
130
  return {
124
131
  ...base,
125
132
  project: overrides.project ?? base.project,
@@ -134,7 +141,8 @@ function resolveConfig(overrides) {
134
141
  };
135
142
  }
136
143
  /**
137
- * Define a realtime (Gemini Live S2S) LiveKit voice agent.
144
+ * Define a realtime (speech-to-speech) LiveKit voice agent: Gemini Live by
145
+ * default, xAI Grok Voice with `VOICE_REALTIME_BACKEND=xai` or `backend: "xai"`.
138
146
  *
139
147
  * Place the result as the default export of the worker entry file and launch it
140
148
  * with `startRealtimeVoiceAgentWorker`. With no `config` everything is resolved
@@ -147,16 +155,26 @@ export function defineRealtimeVoiceAgent(config = {}) {
147
155
  const onLog = config.onLog;
148
156
  const memoryConfig = config.memory;
149
157
  async function entry(ctx) {
150
- // The Gemini Live WS authenticates to Vertex via ADC; materialise it and
151
- // force ADC (not an API key) auth before any realtime connection.
152
- ensureVertexAdc();
153
- clearGeminiApiKeyEnv();
158
+ const isGemini = cfg.backend === "gemini";
159
+ if (isGemini) {
160
+ // The Gemini Live WS authenticates to Vertex via ADC; materialise it and
161
+ // force ADC (not an API key) auth before any realtime connection.
162
+ ensureVertexAdc();
163
+ clearGeminiApiKeyEnv();
164
+ }
165
+ else {
166
+ requireXaiApiKey();
167
+ }
154
168
  logger.info("realtime.config", {
169
+ backend: cfg.backend,
155
170
  model: cfg.model,
156
- location: cfg.location,
157
- project: cfg.project ?? null,
171
+ ...(isGemini
172
+ ? { location: cfg.location, project: cfg.project ?? null }
173
+ : {}),
158
174
  voice: cfg.voice ?? null,
159
- modality: cfg.responseModality,
175
+ ...(isGemini
176
+ ? { modality: cfg.responseModality }
177
+ : { customBaseURL: cfg.xaiBaseURL !== undefined }),
160
178
  toolsEnabled: cfg.toolsEnabled,
161
179
  mcpUrl: cfg.mcpUrl,
162
180
  });
@@ -190,17 +208,20 @@ export function defineRealtimeVoiceAgent(config = {}) {
190
208
  }
191
209
  logger.info("realtime.room.joined", {
192
210
  room: ctx.room.name ?? "unknown",
211
+ backend: cfg.backend,
193
212
  model: cfg.model,
194
- location: cfg.location,
213
+ ...(isGemini ? { location: cfg.location } : {}),
195
214
  ms: sinceConnect(),
196
215
  });
197
216
  // Decide whether this call may be remembered BEFORE the lifecycle installs
198
217
  // its process reaper, so the shutdown flush is registered first.
199
- const memory = await setUpRealtimeCallMemory(ctx, memoryConfig, cfg.model);
218
+ const memory = await setUpRealtimeCallMemory(ctx, memoryConfig, {
219
+ model: cfg.model,
220
+ backend: cfg.backend,
221
+ });
200
222
  await installRealtimeJobLifecycle(ctx, cfg);
201
223
  const { voice } = await import("@livekit/agents");
202
- const google = await import("@livekit/agents-plugin-google");
203
- const { Modality } = await import("@google/genai");
224
+ const realtimeLlm = await createRealtimeLlm(cfg);
204
225
  const bridge = await attachRealtimeEventBridge({
205
226
  room: ctx.room,
206
227
  hitlTimeoutMs: cfg.hitlTimeoutMs,
@@ -254,28 +275,10 @@ export function defineRealtimeVoiceAgent(config = {}) {
254
275
  }
255
276
  }
256
277
  }
257
- const modality = Object.values(Modality).find((value) => value === cfg.responseModality);
258
- const modelOptions = {
259
- vertexai: true,
260
- model: cfg.model,
261
- // Emit text transcripts of BOTH sides; LiveKit forwards them to the room.
262
- inputAudioTranscription: {},
263
- outputAudioTranscription: {},
264
- };
265
- if (modality !== undefined) {
266
- modelOptions.modalities = [modality];
267
- }
268
- if (cfg.project) {
269
- modelOptions.project = cfg.project;
270
- }
271
- if (cfg.location) {
272
- modelOptions.location = cfg.location;
273
- }
274
- if (cfg.voice) {
275
- modelOptions.voice = cfg.voice;
276
- }
277
278
  const session = new voice.AgentSession({
278
- llm: new google.realtime.RealtimeModel(modelOptions),
279
+ // `RealtimeLlm` is declared structurally so the published declarations
280
+ // name no optional plugin; both backends return a `RealtimeModel`.
281
+ llm: realtimeLlm,
279
282
  });
280
283
  // The conversation so far, so a call that appends to an open conversation
281
284
  // starts knowing it. Empty (and omitted) when there is nothing to seed.
@@ -9,6 +9,8 @@
9
9
  *
10
10
  * Pure, dependency-free, and assertion-free — values arrive as `unknown` and are
11
11
  * narrowed with the `isRecord` guard. Safe to reuse for any Gemini tool path.
12
+ * The xai realtime backend uses it too: the output is plain JSON Schema, and
13
+ * keeping it avoids sending xAI `$ref` or union shapes it has not been tried with.
12
14
  */
13
15
  /**
14
16
  * Rebuild a JSON Schema node into the Gemini-safe subset. Returns a fresh object
@@ -9,6 +9,8 @@
9
9
  *
10
10
  * Pure, dependency-free, and assertion-free — values arrive as `unknown` and are
11
11
  * narrowed with the `isRecord` guard. Safe to reuse for any Gemini tool path.
12
+ * The xai realtime backend uses it too: the output is plain JSON Schema, and
13
+ * keeping it avoids sending xAI `$ref` or union shapes it has not been tried with.
12
14
  */
13
15
  const GEMINI_TYPES = new Set([
14
16
  "string",