@kernl-sdk/xai 0.1.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.
Files changed (49) hide show
  1. package/.turbo/turbo-build.log +5 -0
  2. package/dist/__tests__/realtime.integration.test.d.ts +2 -0
  3. package/dist/__tests__/realtime.integration.test.d.ts.map +1 -0
  4. package/dist/__tests__/realtime.integration.test.js +157 -0
  5. package/dist/__tests__/realtime.test.d.ts +2 -0
  6. package/dist/__tests__/realtime.test.d.ts.map +1 -0
  7. package/dist/__tests__/realtime.test.js +263 -0
  8. package/dist/connection.d.ts +47 -0
  9. package/dist/connection.d.ts.map +1 -0
  10. package/dist/connection.js +138 -0
  11. package/dist/convert/event.d.ts +28 -0
  12. package/dist/convert/event.d.ts.map +1 -0
  13. package/dist/convert/event.js +314 -0
  14. package/dist/convert/types.d.ts +212 -0
  15. package/dist/convert/types.d.ts.map +1 -0
  16. package/dist/convert/types.js +1 -0
  17. package/dist/index.d.ts +36 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +16 -0
  20. package/dist/model.d.ts +36 -0
  21. package/dist/model.d.ts.map +1 -0
  22. package/dist/model.js +112 -0
  23. package/dist/protocol.d.ts +212 -0
  24. package/dist/protocol.d.ts.map +1 -0
  25. package/dist/protocol.js +1 -0
  26. package/dist/realtime/connection.d.ts +47 -0
  27. package/dist/realtime/connection.d.ts.map +1 -0
  28. package/dist/realtime/connection.js +138 -0
  29. package/dist/realtime/convert/event.d.ts +28 -0
  30. package/dist/realtime/convert/event.d.ts.map +1 -0
  31. package/dist/realtime/convert/event.js +314 -0
  32. package/dist/realtime/model.d.ts +36 -0
  33. package/dist/realtime/model.d.ts.map +1 -0
  34. package/dist/realtime/model.js +111 -0
  35. package/dist/realtime/protocol.d.ts +212 -0
  36. package/dist/realtime/protocol.d.ts.map +1 -0
  37. package/dist/realtime/protocol.js +1 -0
  38. package/dist/realtime.d.ts +36 -0
  39. package/dist/realtime.d.ts.map +1 -0
  40. package/dist/realtime.js +250 -0
  41. package/package.json +55 -0
  42. package/src/__tests__/realtime.integration.test.ts +203 -0
  43. package/src/__tests__/realtime.test.ts +350 -0
  44. package/src/index.ts +41 -0
  45. package/src/realtime/connection.ts +167 -0
  46. package/src/realtime/convert/event.ts +388 -0
  47. package/src/realtime/model.ts +162 -0
  48. package/src/realtime/protocol.ts +286 -0
  49. package/tsconfig.json +13 -0
@@ -0,0 +1,36 @@
1
+ import type { RealtimeModel, RealtimeConnection, RealtimeConnectOptions, ClientCredential } from "@kernl-sdk/protocol";
2
+ /**
3
+ * Options for creating a Grok realtime model.
4
+ */
5
+ export interface GrokRealtimeOptions {
6
+ /**
7
+ * xAI API key. Defaults to XAI_API_KEY env var.
8
+ */
9
+ apiKey?: string;
10
+ /**
11
+ * Base URL for the realtime API.
12
+ */
13
+ baseUrl?: string;
14
+ }
15
+ /**
16
+ * Grok (xAI) realtime model implementation.
17
+ */
18
+ export declare class GrokRealtimeModel implements RealtimeModel {
19
+ readonly spec: "1.0";
20
+ readonly provider = "xai";
21
+ readonly modelId: string;
22
+ private apiKey;
23
+ private baseUrl;
24
+ constructor(modelId: string, options?: GrokRealtimeOptions);
25
+ /**
26
+ * Create ephemeral credential for client-side connections.
27
+ *
28
+ * Must be called server-side where API key is available.
29
+ */
30
+ authenticate(): Promise<ClientCredential>;
31
+ /**
32
+ * Establish a WebSocket connection to the Grok realtime API.
33
+ */
34
+ connect(options?: RealtimeConnectOptions): Promise<RealtimeConnection>;
35
+ }
36
+ //# sourceMappingURL=realtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"realtime.d.ts","sourceRoot":"","sources":["../src/realtime.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,aAAa,EACb,kBAAkB,EAElB,sBAAsB,EAGtB,gBAAgB,EAEjB,MAAM,qBAAqB,CAAC;AAQ7B;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;OAEG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB;;OAEG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,qBAAa,iBAAkB,YAAW,aAAa;IACrD,QAAQ,CAAC,IAAI,EAAG,KAAK,CAAU;IAC/B,QAAQ,CAAC,QAAQ,SAAS;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAEzB,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,OAAO,CAAS;gBAEZ,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,mBAAmB;IAS1D;;;;OAIG;IACG,YAAY,IAAI,OAAO,CAAC,gBAAgB,CAAC;IAgC/C;;OAEG;IACG,OAAO,CAAC,OAAO,CAAC,EAAE,sBAAsB,GAAG,OAAO,CAAC,kBAAkB,CAAC;CA4E7E"}
@@ -0,0 +1,250 @@
1
+ import { Emitter } from "@kernl-sdk/shared";
2
+ import { CLIENT_EVENT, SERVER_EVENT } from "./convert/event.js";
3
+ const XAI_REALTIME_URL = "wss://api.x.ai/v1/realtime";
4
+ const XAI_CLIENT_SECRETS_URL = "https://api.x.ai/v1/realtime/client_secrets";
5
+ /**
6
+ * Grok (xAI) realtime model implementation.
7
+ */
8
+ export class GrokRealtimeModel {
9
+ spec = "1.0";
10
+ provider = "xai";
11
+ modelId;
12
+ apiKey;
13
+ baseUrl;
14
+ constructor(modelId, options) {
15
+ this.modelId = modelId;
16
+ this.apiKey =
17
+ options?.apiKey ??
18
+ (typeof process !== "undefined" ? process.env?.XAI_API_KEY : null) ??
19
+ null;
20
+ this.baseUrl = options?.baseUrl ?? XAI_REALTIME_URL;
21
+ }
22
+ /**
23
+ * Create ephemeral credential for client-side connections.
24
+ *
25
+ * Must be called server-side where API key is available.
26
+ */
27
+ async authenticate() {
28
+ if (!this.apiKey) {
29
+ throw new Error("API key required for authenticate(). " +
30
+ "Call this server-side where XAI_API_KEY is available.");
31
+ }
32
+ const res = await fetch(XAI_CLIENT_SECRETS_URL, {
33
+ method: "POST",
34
+ headers: {
35
+ Authorization: `Bearer ${this.apiKey}`,
36
+ "Content-Type": "application/json",
37
+ },
38
+ body: JSON.stringify({
39
+ expires_after: { seconds: 300 },
40
+ }),
41
+ });
42
+ if (!res.ok) {
43
+ const text = await res.text();
44
+ throw new Error(`Failed to create credential: ${res.status} ${text}`);
45
+ }
46
+ const data = (await res.json());
47
+ return {
48
+ kind: "token",
49
+ token: data.value,
50
+ expiresAt: new Date(Date.now() + 300_000), // 5 min TTL
51
+ };
52
+ }
53
+ /**
54
+ * Establish a WebSocket connection to the Grok realtime API.
55
+ */
56
+ async connect(options) {
57
+ const credential = options?.credential;
58
+ if (credential && credential.kind !== "token") {
59
+ throw new Error(`Grok requires token credentials, got "${credential.kind}".`);
60
+ }
61
+ const authToken = credential?.token ?? this.apiKey;
62
+ if (!authToken) {
63
+ throw new Error("No API key or credential provided. " +
64
+ "Either set XAI_API_KEY or pass a credential from authenticate().");
65
+ }
66
+ // Use injectable WebSocket or globalThis.WebSocket
67
+ const WS = options?.websocket ?? globalThis.WebSocket;
68
+ if (!WS) {
69
+ throw new Error("No WebSocket available. In Node.js <22, use WebSocketTransport with the 'ws' package:\n" +
70
+ " import WebSocket from 'ws';\n" +
71
+ " import { WebSocketTransport } from 'kernl';\n" +
72
+ " new RealtimeSession(agent, { transport: new WebSocketTransport({ websocket: WebSocket }), ... })");
73
+ }
74
+ // Grok uses standard Authorization header via protocols
75
+ // Browser WebSocket doesn't support custom headers, so we pass token in subprotocol
76
+ const url = this.baseUrl;
77
+ // For browser: use subprotocol to pass auth (similar pattern to OpenAI)
78
+ // For Node.js with 'ws' package: headers can be passed directly
79
+ const protocols = [`token.${authToken}`];
80
+ const ws = new WS(url, protocols);
81
+ const connection = new GrokRealtimeConnection(ws);
82
+ await new Promise((resolve, reject) => {
83
+ if (options?.abort?.aborted) {
84
+ return reject(new Error("Connection aborted"));
85
+ }
86
+ const onOpen = () => {
87
+ cleanup();
88
+ resolve();
89
+ };
90
+ const onError = (event) => {
91
+ cleanup();
92
+ const err = event instanceof Error
93
+ ? event
94
+ : new Error("WebSocket connection failed");
95
+ reject(err);
96
+ };
97
+ const onAbort = () => {
98
+ cleanup();
99
+ ws.close();
100
+ reject(new Error("Connection aborted"));
101
+ };
102
+ const cleanup = () => {
103
+ ws.removeEventListener("open", onOpen);
104
+ ws.removeEventListener("error", onError);
105
+ options?.abort?.removeEventListener("abort", onAbort);
106
+ };
107
+ ws.addEventListener("open", onOpen);
108
+ ws.addEventListener("error", onError);
109
+ options?.abort?.addEventListener("abort", onAbort);
110
+ });
111
+ return connection;
112
+ }
113
+ }
114
+ // WebSocket readyState constants
115
+ const WS_OPEN = 1;
116
+ /**
117
+ * Grok realtime connection implementation.
118
+ */
119
+ class GrokRealtimeConnection extends Emitter {
120
+ ws;
121
+ _status = "connecting";
122
+ _muted = false;
123
+ _sessionId = null;
124
+ // Audio state tracking for interruption
125
+ currid;
126
+ faudtime;
127
+ audlenms = 0;
128
+ responding = false;
129
+ constructor(socket) {
130
+ super();
131
+ this.ws = socket;
132
+ socket.addEventListener("message", (event) => {
133
+ try {
134
+ const data = event && typeof event === "object" && "data" in event
135
+ ? event.data
136
+ : String(event);
137
+ const raw = JSON.parse(data);
138
+ // Track audio state for interruption handling
139
+ if (raw.type === "response.output_audio.delta") {
140
+ this.currid = raw.item_id;
141
+ if (this.faudtime === undefined) {
142
+ this.faudtime = Date.now();
143
+ this.audlenms = 0;
144
+ }
145
+ // Calculate audio length assuming 24kHz PCM16
146
+ const bytes = base64ByteLength(raw.delta);
147
+ this.audlenms += (bytes / 2 / 24000) * 1000;
148
+ }
149
+ else if (raw.type === "response.created") {
150
+ this.responding = true;
151
+ }
152
+ else if (raw.type === "response.done") {
153
+ this.responding = false;
154
+ this.reset();
155
+ }
156
+ else if (raw.type === "input_audio_buffer.speech_started") {
157
+ this.interrupt();
158
+ }
159
+ const event_ = SERVER_EVENT.decode(raw);
160
+ if (event_) {
161
+ if (event_.kind === "session.created") {
162
+ this._sessionId = event_.session.id;
163
+ }
164
+ this.emit("event", event_);
165
+ }
166
+ }
167
+ catch (err) {
168
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
169
+ }
170
+ });
171
+ socket.addEventListener("open", () => {
172
+ this._status = "connected";
173
+ this.emit("status", this._status);
174
+ });
175
+ socket.addEventListener("close", () => {
176
+ this._status = "closed";
177
+ this.reset();
178
+ this.emit("status", this._status);
179
+ });
180
+ socket.addEventListener("error", (event) => {
181
+ const err = event instanceof Error ? event : new Error("WebSocket error");
182
+ this.emit("error", err);
183
+ });
184
+ }
185
+ get status() {
186
+ return this._status;
187
+ }
188
+ get muted() {
189
+ return this._muted;
190
+ }
191
+ get sessionId() {
192
+ return this._sessionId;
193
+ }
194
+ /**
195
+ * Send a client event to the Grok realtime API.
196
+ */
197
+ send(event) {
198
+ const encoded = CLIENT_EVENT.encode(event);
199
+ if (encoded && this.ws.readyState === WS_OPEN) {
200
+ this.ws.send(JSON.stringify(encoded));
201
+ }
202
+ }
203
+ /**
204
+ * Close the WebSocket connection.
205
+ */
206
+ close() {
207
+ this.reset();
208
+ this.ws.close();
209
+ }
210
+ /**
211
+ * Mute audio input.
212
+ */
213
+ mute() {
214
+ this._muted = true;
215
+ }
216
+ /**
217
+ * Unmute audio input.
218
+ */
219
+ unmute() {
220
+ this._muted = false;
221
+ }
222
+ /**
223
+ * Interrupt the current response.
224
+ *
225
+ * Note: Grok doesn't support response.cancel or item.truncate,
226
+ * so we just reset local state and emit the interrupted event.
227
+ */
228
+ interrupt() {
229
+ if (this.responding) {
230
+ this.responding = false;
231
+ }
232
+ this.emit("interrupted");
233
+ this.reset();
234
+ }
235
+ /**
236
+ * Reset audio tracking state.
237
+ */
238
+ reset() {
239
+ this.currid = undefined;
240
+ this.faudtime = undefined;
241
+ this.audlenms = 0;
242
+ }
243
+ }
244
+ /**
245
+ * Get byte length from base64 string without decoding.
246
+ */
247
+ function base64ByteLength(b64) {
248
+ const padding = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
249
+ return (b64.length * 3) / 4 - padding;
250
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@kernl-sdk/xai",
3
+ "version": "0.1.0",
4
+ "description": "xAI (Grok) realtime voice provider for kernl",
5
+ "keywords": [
6
+ "kernl",
7
+ "xai",
8
+ "grok",
9
+ "realtime",
10
+ "voice",
11
+ "ai"
12
+ ],
13
+ "author": "dremnik",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/kernl-sdk/kernl.git",
18
+ "directory": "packages/providers/xai"
19
+ },
20
+ "homepage": "https://github.com/kernl-sdk/kernl#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/kernl-sdk/kernl/issues"
23
+ },
24
+ "type": "module",
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ }
33
+ },
34
+ "scripts": {
35
+ "build": "tsc && tsc-alias --resolve-full-paths",
36
+ "dev": "tsc --watch",
37
+ "check-types": "tsc --noEmit",
38
+ "test": "vitest",
39
+ "test:watch": "vitest --watch",
40
+ "test:run": "vitest run"
41
+ },
42
+ "dependencies": {
43
+ "@kernl-sdk/protocol": "workspace:*",
44
+ "@kernl-sdk/shared": "workspace:*",
45
+ "ws": "^8.18.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/json-schema": "^7.0.15",
49
+ "@types/node": "^24.10.0",
50
+ "@types/ws": "^8.18.0",
51
+ "tsc-alias": "^1.8.10",
52
+ "typescript": "5.9.2",
53
+ "vitest": "^4.0.8"
54
+ }
55
+ }
@@ -0,0 +1,203 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+
3
+ import type {
4
+ RealtimeServerEvent,
5
+ RealtimeConnection,
6
+ } from "@kernl-sdk/protocol";
7
+ import { GrokRealtimeModel } from "../realtime/model";
8
+
9
+ const XAI_API_KEY = process.env.XAI_API_KEY;
10
+
11
+ describe.skipIf(!XAI_API_KEY)("Grok Realtime Integration", () => {
12
+ let model: GrokRealtimeModel;
13
+
14
+ beforeAll(() => {
15
+ model = new GrokRealtimeModel({
16
+ apiKey: XAI_API_KEY,
17
+ });
18
+ });
19
+
20
+ it("should connect and receive conversation.created", async () => {
21
+ const conn = await model.connect();
22
+ const events: RealtimeServerEvent[] = [];
23
+
24
+ const sessionCreated = new Promise<void>((resolve, reject) => {
25
+ const timeout = setTimeout(() => reject(new Error("timeout")), 10000);
26
+ conn.on("event", (e: RealtimeServerEvent) => {
27
+ events.push(e);
28
+ if (e.kind === "session.created") {
29
+ clearTimeout(timeout);
30
+ resolve();
31
+ }
32
+ });
33
+ });
34
+
35
+ await sessionCreated;
36
+ conn.close();
37
+
38
+ expect(events.some((e) => e.kind === "session.created")).toBe(true);
39
+ expect(conn.sessionId).toBeTruthy();
40
+ });
41
+
42
+ it("should complete text round-trip", async () => {
43
+ const conn = await model.connect();
44
+ const events: RealtimeServerEvent[] = [];
45
+
46
+ conn.on("event", (e: RealtimeServerEvent) => {
47
+ events.push(e);
48
+ });
49
+
50
+ // wait for session
51
+ await waitFor(conn, "session.created");
52
+
53
+ // configure text-only mode
54
+ conn.send({
55
+ kind: "session.update",
56
+ config: {
57
+ instructions: "You are a helpful assistant. Be very brief.",
58
+ },
59
+ });
60
+
61
+ await waitFor(conn, "session.updated");
62
+
63
+ // add user message
64
+ conn.send({
65
+ kind: "item.create",
66
+ item: {
67
+ kind: "message",
68
+ id: "test-msg-1",
69
+ role: "user",
70
+ content: [{ kind: "text", text: "Say exactly: hello world" }],
71
+ },
72
+ });
73
+
74
+ // trigger response
75
+ conn.send({ kind: "response.create" });
76
+
77
+ // wait for response to complete
78
+ await waitFor(conn, "response.done", 15000);
79
+
80
+ conn.close();
81
+
82
+ // verify event flow
83
+ const kinds = events.map((e) => e.kind);
84
+ expect(kinds).toContain("session.created");
85
+ expect(kinds).toContain("session.updated");
86
+ expect(kinds).toContain("response.created");
87
+ expect(kinds).toContain("response.done");
88
+
89
+ // verify response completed successfully
90
+ const done = events.find((e) => e.kind === "response.done");
91
+ if (done?.kind === "response.done") {
92
+ expect(done.status).toBe("completed");
93
+ }
94
+ });
95
+
96
+ it("should handle tool calling", { timeout: 20000 }, async () => {
97
+ const conn = await model.connect();
98
+ const events: RealtimeServerEvent[] = [];
99
+
100
+ conn.on("event", (e: RealtimeServerEvent) => {
101
+ events.push(e);
102
+ });
103
+
104
+ await waitFor(conn, "session.created");
105
+
106
+ // configure with a tool
107
+ conn.send({
108
+ kind: "session.update",
109
+ config: {
110
+ instructions: "You have access to tools. Use them when appropriate.",
111
+ tools: [
112
+ {
113
+ kind: "function",
114
+ name: "get_weather",
115
+ description: "Get the current weather for a location",
116
+ parameters: {
117
+ type: "object",
118
+ properties: {
119
+ location: { type: "string", description: "City name" },
120
+ },
121
+ required: ["location"],
122
+ },
123
+ },
124
+ ],
125
+ },
126
+ });
127
+
128
+ await waitFor(conn, "session.updated");
129
+
130
+ // ask about weather
131
+ conn.send({
132
+ kind: "item.create",
133
+ item: {
134
+ kind: "message",
135
+ id: "test-msg-2",
136
+ role: "user",
137
+ content: [{ kind: "text", text: "What is the weather in Tokyo?" }],
138
+ },
139
+ });
140
+
141
+ conn.send({ kind: "response.create" });
142
+
143
+ // wait for tool call
144
+ const toolCall = await waitFor(conn, "tool.call", 15000);
145
+
146
+ expect(toolCall.kind).toBe("tool.call");
147
+ if (toolCall.kind !== "tool.call") {
148
+ throw new Error("Expected tool.call");
149
+ }
150
+
151
+ expect(toolCall.toolId).toBe("get_weather");
152
+ const args = JSON.parse(toolCall.arguments);
153
+ expect(args.location.toLowerCase()).toContain("tokyo");
154
+
155
+ // wait for first response to complete before sending tool result
156
+ await waitFor(conn, "response.done", 15000);
157
+
158
+ // send tool result
159
+ conn.send({
160
+ kind: "tool.result",
161
+ callId: toolCall.callId,
162
+ result: JSON.stringify({ temperature: 22, condition: "sunny" }),
163
+ });
164
+
165
+ // trigger follow-up response
166
+ conn.send({ kind: "response.create" });
167
+
168
+ // wait for second response to complete
169
+ await waitFor(conn, "response.done", 15000);
170
+
171
+ conn.close();
172
+
173
+ // verify we got multiple response.done events (initial + follow-up)
174
+ const doneEvents = events.filter((e) => e.kind === "response.done");
175
+ expect(doneEvents.length).toBeGreaterThanOrEqual(2);
176
+ });
177
+ });
178
+
179
+ /**
180
+ * Wait for a specific event kind.
181
+ */
182
+ function waitFor(
183
+ conn: RealtimeConnection,
184
+ kind: RealtimeServerEvent["kind"],
185
+ timeout = 10000,
186
+ ): Promise<RealtimeServerEvent> {
187
+ return new Promise((resolve, reject) => {
188
+ const timer = setTimeout(() => {
189
+ conn.off("event", handler);
190
+ reject(new Error(`timeout waiting for ${kind}`));
191
+ }, timeout);
192
+
193
+ const handler = (e: RealtimeServerEvent) => {
194
+ if (e.kind === kind) {
195
+ clearTimeout(timer);
196
+ conn.off("event", handler);
197
+ resolve(e);
198
+ }
199
+ };
200
+
201
+ conn.on("event", handler);
202
+ });
203
+ }