@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.
- package/CHANGELOG.md +2 -6
- package/dist/browser/neurolink.min.js +411 -411
- package/dist/core/baseProvider.d.ts +0 -1
- package/dist/core/baseProvider.js +5 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/middleware/defaults.d.ts +68 -0
- package/dist/middleware/defaults.js +225 -0
- package/dist/middleware/factory.js +4 -15
- package/dist/middleware/presets.d.ts +12 -0
- package/dist/middleware/presets.js +29 -0
- package/dist/neurolink.d.ts +9 -0
- package/dist/neurolink.js +31 -0
- package/dist/types/config.d.ts +22 -0
- package/dist/types/livekit.d.ts +37 -11
- package/dist/voice/livekit/config.d.ts +13 -8
- package/dist/voice/livekit/config.js +72 -8
- package/dist/voice/livekit/index.d.ts +1 -0
- package/dist/voice/livekit/index.js +1 -0
- package/dist/voice/livekit/realtimeMcpTools.d.ts +8 -7
- package/dist/voice/livekit/realtimeMcpTools.js +10 -9
- package/dist/voice/livekit/realtimeMemory.d.ts +11 -3
- package/dist/voice/livekit/realtimeMemory.js +23 -5
- package/dist/voice/livekit/realtimeModel.d.ts +33 -0
- package/dist/voice/livekit/realtimeModel.js +89 -0
- package/dist/voice/livekit/realtimeVoiceAgent.d.ts +10 -6
- package/dist/voice/livekit/realtimeVoiceAgent.js +42 -39
- package/dist/voice/livekit/schemaSanitizer.d.ts +2 -0
- package/dist/voice/livekit/schemaSanitizer.js +2 -0
- package/docs-site/static/search-index.json +47 -36
- package/package.json +7 -3
|
@@ -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 (
|
|
28
|
-
*
|
|
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.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
*
|
|
79
|
-
* the
|
|
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.
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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 →
|
|
2
|
+
* MCP tools → realtime function tools bridge (realtime mode).
|
|
3
3
|
*
|
|
4
|
-
* In speech-to-speech mode
|
|
5
|
-
* server's tools are registered as
|
|
6
|
-
* NeuroLink tools. This connects to the MCP
|
|
7
|
-
* (`x-auth-token` + base64 `x-context`), sanitizes
|
|
8
|
-
* Gemini-safe subset
|
|
9
|
-
*
|
|
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 →
|
|
2
|
+
* MCP tools → realtime function tools bridge (realtime mode).
|
|
3
3
|
*
|
|
4
|
-
* In speech-to-speech mode
|
|
5
|
-
* server's tools are registered as
|
|
6
|
-
* NeuroLink tools. This connects to the MCP
|
|
7
|
-
* (`x-auth-token` + base64 `x-context`), sanitizes
|
|
8
|
-
* Gemini-safe subset
|
|
9
|
-
*
|
|
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
|
|
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 —
|
|
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
|
-
/**
|
|
22
|
-
|
|
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
|
|
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
|
-
/**
|
|
84
|
-
|
|
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, {
|
|
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 (
|
|
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
|
|
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`,
|
|
10
|
-
* `@google/genai` are imported dynamically so the core
|
|
11
|
-
* them unless the realtime agent is used. Type-only
|
|
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 (
|
|
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 (
|
|
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
|
|
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`,
|
|
10
|
-
* `@google/genai` are imported dynamically so the core
|
|
11
|
-
* them unless the realtime agent is used. Type-only
|
|
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 (
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
157
|
-
|
|
171
|
+
...(isGemini
|
|
172
|
+
? { location: cfg.location, project: cfg.project ?? null }
|
|
173
|
+
: {}),
|
|
158
174
|
voice: cfg.voice ?? null,
|
|
159
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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",
|