@tanstack/ai-grok 0.6.7 → 0.7.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 (50) hide show
  1. package/dist/esm/adapters/image.js +36 -17
  2. package/dist/esm/adapters/image.js.map +1 -1
  3. package/dist/esm/adapters/summarize.js +51 -22
  4. package/dist/esm/adapters/summarize.js.map +1 -1
  5. package/dist/esm/adapters/text.js +25 -10
  6. package/dist/esm/adapters/text.js.map +1 -1
  7. package/dist/esm/adapters/transcription.d.ts +84 -0
  8. package/dist/esm/adapters/transcription.js +109 -0
  9. package/dist/esm/adapters/transcription.js.map +1 -0
  10. package/dist/esm/adapters/tts.d.ts +70 -0
  11. package/dist/esm/adapters/tts.js +137 -0
  12. package/dist/esm/adapters/tts.js.map +1 -0
  13. package/dist/esm/audio/transcription-provider-options.d.ts +41 -0
  14. package/dist/esm/audio/tts-provider-options.d.ts +42 -0
  15. package/dist/esm/index.d.ts +8 -2
  16. package/dist/esm/index.js +17 -2
  17. package/dist/esm/index.js.map +1 -1
  18. package/dist/esm/model-meta.d.ts +6 -0
  19. package/dist/esm/model-meta.js +22 -1
  20. package/dist/esm/model-meta.js.map +1 -1
  21. package/dist/esm/realtime/adapter.d.ts +21 -0
  22. package/dist/esm/realtime/adapter.js +816 -0
  23. package/dist/esm/realtime/adapter.js.map +1 -0
  24. package/dist/esm/realtime/index.d.ts +4 -0
  25. package/dist/esm/realtime/realtime-contract.d.ts +30 -0
  26. package/dist/esm/realtime/token.d.ts +22 -0
  27. package/dist/esm/realtime/token.js +73 -0
  28. package/dist/esm/realtime/token.js.map +1 -0
  29. package/dist/esm/realtime/types.d.ts +95 -0
  30. package/dist/esm/utils/audio.d.ts +23 -0
  31. package/dist/esm/utils/audio.js +171 -0
  32. package/dist/esm/utils/audio.js.map +1 -0
  33. package/dist/esm/utils/index.d.ts +1 -0
  34. package/package.json +6 -3
  35. package/src/adapters/image.ts +41 -19
  36. package/src/adapters/summarize.ts +56 -25
  37. package/src/adapters/text.ts +26 -9
  38. package/src/adapters/transcription.ts +233 -0
  39. package/src/adapters/tts.ts +260 -0
  40. package/src/audio/transcription-provider-options.ts +54 -0
  41. package/src/audio/tts-provider-options.ts +44 -0
  42. package/src/index.ts +50 -1
  43. package/src/model-meta.ts +54 -0
  44. package/src/realtime/adapter.ts +1215 -0
  45. package/src/realtime/index.ts +18 -0
  46. package/src/realtime/realtime-contract.ts +46 -0
  47. package/src/realtime/token.ts +131 -0
  48. package/src/realtime/types.ts +105 -0
  49. package/src/utils/audio.ts +217 -0
  50. package/src/utils/index.ts +1 -0
@@ -0,0 +1,18 @@
1
+ // Token adapter for server-side use
2
+ export { grokRealtimeToken } from './token'
3
+
4
+ // Client adapter for browser use
5
+ export { grokRealtime } from './adapter'
6
+
7
+ // Types
8
+ export type {
9
+ GrokRealtimeVoice,
10
+ GrokRealtimeTokenOptions,
11
+ GrokRealtimeOptions,
12
+ GrokTurnDetection,
13
+ GrokSemanticVADConfig,
14
+ GrokServerVADConfig,
15
+ } from './types'
16
+
17
+ // Re-export the realtime model type from the single source of truth.
18
+ export type { GrokRealtimeModel } from '../model-meta'
@@ -0,0 +1,46 @@
1
+ import type {
2
+ AnyClientTool,
3
+ AudioVisualization,
4
+ RealtimeEvent,
5
+ RealtimeEventHandler,
6
+ RealtimeSessionConfig,
7
+ RealtimeToken,
8
+ } from '@tanstack/ai'
9
+
10
+ /**
11
+ * Structural contract for the `RealtimeAdapter` / `RealtimeConnection` types
12
+ * from `@tanstack/ai-client`.
13
+ *
14
+ * We duplicate the shapes here (and verify structural compatibility in a
15
+ * dev-only type check — see `tests/realtime-contract.drift.test-d.ts`) so that
16
+ * `@tanstack/ai-grok` does not impose `@tanstack/ai-client` as a `peerDependency`.
17
+ * Consumers only need `@tanstack/ai-client` at the point where they actually
18
+ * construct a `RealtimeClient`, not when they import this adapter.
19
+ *
20
+ * If `@tanstack/ai-client` ever changes these interfaces, the drift check
21
+ * will fail and we must update this file in lockstep.
22
+ */
23
+
24
+ export interface RealtimeAdapter {
25
+ provider: string
26
+ connect: (
27
+ token: RealtimeToken,
28
+ clientTools?: ReadonlyArray<AnyClientTool>,
29
+ ) => Promise<RealtimeConnection>
30
+ }
31
+
32
+ export interface RealtimeConnection {
33
+ disconnect: () => Promise<void>
34
+ startAudioCapture: () => Promise<void>
35
+ stopAudioCapture: () => void
36
+ sendText: (text: string) => void
37
+ sendImage: (imageData: string, mimeType: string) => void
38
+ sendToolResult: (callId: string, result: string) => void
39
+ updateSession: (config: Partial<RealtimeSessionConfig>) => void
40
+ interrupt: () => void
41
+ on: <TEvent extends RealtimeEvent>(
42
+ event: TEvent,
43
+ handler: RealtimeEventHandler<TEvent>,
44
+ ) => () => void
45
+ getAudioVisualization: () => AudioVisualization
46
+ }
@@ -0,0 +1,131 @@
1
+ import { resolveDebugOption } from '@tanstack/ai/adapter-internals'
2
+ import { getGrokApiKeyFromEnv } from '../utils'
3
+ import type { RealtimeToken, RealtimeTokenAdapter } from '@tanstack/ai'
4
+ import type { GrokRealtimeModel } from '../model-meta'
5
+ import type {
6
+ GrokRealtimeSessionResponse,
7
+ GrokRealtimeTokenOptions,
8
+ } from './types'
9
+
10
+ const GROK_REALTIME_CLIENT_SECRETS_URL =
11
+ 'https://api.x.ai/v1/realtime/client_secrets'
12
+
13
+ const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 15_000
14
+
15
+ /**
16
+ * Creates a Grok realtime token adapter.
17
+ *
18
+ * Generates ephemeral client secrets for browser-side WebRTC connections to
19
+ * the xAI Voice Agent API.
20
+ *
21
+ * @param options - Configuration options for the realtime session.
22
+ * @returns A RealtimeTokenAdapter for use with `realtimeToken()`.
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * import { realtimeToken } from '@tanstack/ai'
27
+ * import { grokRealtimeToken } from '@tanstack/ai-grok'
28
+ *
29
+ * const token = await realtimeToken({
30
+ * adapter: grokRealtimeToken({ model: 'grok-voice-fast-1.0' }),
31
+ * })
32
+ * ```
33
+ */
34
+ export function grokRealtimeToken(
35
+ options: GrokRealtimeTokenOptions = {},
36
+ ): RealtimeTokenAdapter {
37
+ const apiKey = getGrokApiKeyFromEnv()
38
+ const logger = resolveDebugOption(options.debug)
39
+
40
+ return {
41
+ provider: 'grok',
42
+
43
+ async generateToken(): Promise<RealtimeToken> {
44
+ const model: GrokRealtimeModel = options.model ?? 'grok-voice-fast-1.0'
45
+
46
+ logger.request(`activity=realtimeToken provider=grok model=${model}`, {
47
+ provider: 'grok',
48
+ model,
49
+ })
50
+
51
+ // xAI docs (docs.x.ai/developers/rest-api-reference/inference/voice)
52
+ // specify the body as `{ session: { model } }`. `expires_after` is
53
+ // available to shorten the default 600s TTL but we don't expose it
54
+ // yet — the caller can still call `generateToken()` more often if
55
+ // they want a shorter-lived session.
56
+ const requestBody: Record<string, unknown> = {
57
+ session: { model },
58
+ }
59
+
60
+ // Abort the fetch if xAI never responds. Without this the whole
61
+ // realtime connect flow hangs forever on a dead endpoint.
62
+ const controller = new AbortController()
63
+ const timeout = setTimeout(
64
+ () =>
65
+ controller.abort(new Error('Grok realtime token request timed out')),
66
+ DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
67
+ )
68
+
69
+ try {
70
+ const response = await fetch(GROK_REALTIME_CLIENT_SECRETS_URL, {
71
+ method: 'POST',
72
+ headers: {
73
+ Authorization: `Bearer ${apiKey}`,
74
+ 'Content-Type': 'application/json',
75
+ },
76
+ body: JSON.stringify(requestBody),
77
+ signal: controller.signal,
78
+ })
79
+
80
+ if (!response.ok) {
81
+ const errorText = await response.text()
82
+ throw new Error(
83
+ `Grok realtime session creation failed: ${response.status} ${errorText}`,
84
+ )
85
+ }
86
+
87
+ const sessionData = (await response.json()) as
88
+ | Partial<GrokRealtimeSessionResponse>
89
+ | undefined
90
+
91
+ // Validate shape before dereferencing — xAI could return an error
92
+ // envelope with 200 status, or a partial response under protocol drift.
93
+ const clientSecret = sessionData?.client_secret
94
+ if (
95
+ !clientSecret ||
96
+ typeof clientSecret.value !== 'string' ||
97
+ typeof clientSecret.expires_at !== 'number' ||
98
+ !Number.isFinite(clientSecret.expires_at)
99
+ ) {
100
+ throw new Error(
101
+ 'Grok realtime session response missing or malformed `client_secret`',
102
+ )
103
+ }
104
+ const sessionModel = sessionData.model ?? model
105
+
106
+ // xAI docs describe `expires_at` as a unix timestamp in seconds, but
107
+ // in practice different deployments have returned milliseconds. Treat
108
+ // any value that already looks like ms (>1e12 ≈ Sep 2001 in ms) as ms.
109
+ const raw = clientSecret.expires_at
110
+ const expiresAt = raw > 1e12 ? raw : raw * 1000
111
+
112
+ return {
113
+ provider: 'grok',
114
+ token: clientSecret.value,
115
+ expiresAt,
116
+ config: {
117
+ model: sessionModel,
118
+ },
119
+ }
120
+ } catch (error) {
121
+ logger.errors('grok.realtimeToken fatal', {
122
+ error,
123
+ source: 'grok.realtimeToken',
124
+ })
125
+ throw error
126
+ } finally {
127
+ clearTimeout(timeout)
128
+ }
129
+ },
130
+ }
131
+ }
@@ -0,0 +1,105 @@
1
+ import type { DebugOption, VADConfig } from '@tanstack/ai'
2
+ import type { GrokRealtimeModel } from '../model-meta'
3
+
4
+ /**
5
+ * Grok realtime voice options (Voice Agent API).
6
+ * https://docs.x.ai/developers/model-capabilities/audio/voice-agent
7
+ */
8
+ export type GrokRealtimeVoice = 'eve' | 'ara' | 'rex' | 'sal' | 'leo'
9
+
10
+ /**
11
+ * Grok semantic VAD configuration.
12
+ */
13
+ export interface GrokSemanticVADConfig {
14
+ type: 'semantic_vad'
15
+ /** Eagerness level for turn detection */
16
+ eagerness?: 'low' | 'medium' | 'high'
17
+ }
18
+
19
+ /**
20
+ * Grok server VAD configuration.
21
+ */
22
+ export interface GrokServerVADConfig extends VADConfig {
23
+ type: 'server_vad'
24
+ }
25
+
26
+ /**
27
+ * Grok turn detection configuration.
28
+ */
29
+ export type GrokTurnDetection =
30
+ | GrokSemanticVADConfig
31
+ | GrokServerVADConfig
32
+ | null
33
+
34
+ /**
35
+ * Options for the Grok realtime token adapter.
36
+ */
37
+ export interface GrokRealtimeTokenOptions {
38
+ /** Model to use (default: 'grok-voice-fast-1.0'). */
39
+ model?: GrokRealtimeModel
40
+ /**
41
+ * Enable debug logging for token creation.
42
+ *
43
+ * - `true`: log all categories via the default `ConsoleLogger`
44
+ * - `false`: silence everything including errors
45
+ * - `DebugConfig`: per-category toggles plus an optional custom `logger`
46
+ * - omitted: only the `errors` category is active (default behaviour)
47
+ */
48
+ debug?: DebugOption
49
+ }
50
+
51
+ /**
52
+ * Options for the Grok realtime client adapter.
53
+ */
54
+ export interface GrokRealtimeOptions {
55
+ /** Connection mode (default: 'webrtc' in browser). */
56
+ connectionMode?: 'webrtc' | 'websocket'
57
+ /**
58
+ * Enable debug logging for this adapter.
59
+ *
60
+ * - `true`: log all categories via the default `ConsoleLogger`
61
+ * - `false`: silence everything including errors
62
+ * - `DebugConfig`: per-category toggles plus an optional custom `logger`
63
+ * - omitted: only the `errors` category is active (default behaviour)
64
+ */
65
+ debug?: DebugOption
66
+ }
67
+
68
+ /**
69
+ * Grok realtime session response from the `/v1/realtime/client_secrets`
70
+ * endpoint. Shape matches OpenAI's `/v1/realtime/sessions` response since
71
+ * xAI advertises its voice agent API as OpenAI-realtime-compatible.
72
+ */
73
+ export interface GrokRealtimeSessionResponse {
74
+ id: string
75
+ object: string
76
+ model: string
77
+ modalities: Array<string>
78
+ instructions: string
79
+ voice: string
80
+ input_audio_format: string
81
+ output_audio_format: string
82
+ input_audio_transcription: {
83
+ model: string
84
+ } | null
85
+ turn_detection: {
86
+ type: string
87
+ threshold?: number
88
+ prefix_padding_ms?: number
89
+ silence_duration_ms?: number
90
+ eagerness?: string
91
+ } | null
92
+ tools: Array<{
93
+ type: string
94
+ name: string
95
+ description: string
96
+ parameters: Record<string, unknown>
97
+ }>
98
+ tool_choice: string
99
+ temperature: number
100
+ max_response_output_tokens: number | string
101
+ client_secret: {
102
+ value: string
103
+ expires_at: number
104
+ }
105
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Coerce the various audio input shapes accepted by `TranscriptionOptions.audio`
3
+ * into a `File` suitable for `multipart/form-data` uploads.
4
+ *
5
+ * For base64 string inputs we require an explicit MIME type — either via a
6
+ * `data:<mime>;base64,<payload>` URI prefix, or via the caller-provided
7
+ * `audioFormat` parameter. Bare base64 without either is rejected, because
8
+ * silently defaulting to `audio/mpeg` misreports non-mp3 audio to the server.
9
+ *
10
+ * The same rule applies to raw `ArrayBuffer` inputs: the caller must supply
11
+ * an `audioFormat` so we know what MIME type and extension to use.
12
+ */
13
+ export function toAudioFile(
14
+ audio: string | File | Blob | ArrayBuffer,
15
+ audioFormat?: string,
16
+ ): File {
17
+ if (typeof File !== 'undefined' && audio instanceof File) {
18
+ // Prefer the caller-supplied `audioFormat` over a potentially empty or
19
+ // incorrect `File.type` — callers pass `audioFormat` precisely because
20
+ // they have more context than the browser does about the payload. If
21
+ // neither is set, fall through to the Blob-style error path below.
22
+ if (audioFormat) {
23
+ const mimeType = toMimeType(audioFormat)
24
+ return new File([audio], `audio.${extensionFor(mimeType)}`, {
25
+ type: mimeType,
26
+ })
27
+ }
28
+ if (audio.type) {
29
+ return audio
30
+ }
31
+ throw new Error(
32
+ 'toAudioFile cannot infer type for File input with empty .type — pass an explicit audioFormat (e.g. "mp3", "wav", "audio/mpeg")',
33
+ )
34
+ }
35
+
36
+ if (typeof Blob !== 'undefined' && audio instanceof Blob) {
37
+ // Mirror the ArrayBuffer / bare-base64 paths: prefer the explicit
38
+ // audioFormat argument over the Blob's (often empty) .type. We refuse to
39
+ // fall back to `application/octet-stream` because that mislabels audio
40
+ // for the server.
41
+ const mimeType = audioFormat
42
+ ? toMimeType(audioFormat)
43
+ : audio.type || undefined
44
+ if (!mimeType) {
45
+ throw new Error(
46
+ 'toAudioFile cannot infer type for Blob input with empty .type — pass an explicit audioFormat (e.g. "mp3", "wav", "audio/mpeg")',
47
+ )
48
+ }
49
+ return new File([audio], `audio.${extensionFor(mimeType)}`, {
50
+ type: mimeType,
51
+ })
52
+ }
53
+
54
+ if (audio instanceof ArrayBuffer) {
55
+ if (!audioFormat) {
56
+ throw new Error(
57
+ 'toAudioFile cannot infer type for ArrayBuffer input — pass an explicit audioFormat (e.g. "mp3", "wav", "audio/mpeg")',
58
+ )
59
+ }
60
+ const mimeType = toMimeType(audioFormat)
61
+ return new File([audio], `audio.${extensionFor(mimeType)}`, {
62
+ type: mimeType,
63
+ })
64
+ }
65
+
66
+ if (typeof audio === 'string') {
67
+ if (audio.startsWith('data:')) {
68
+ const [header, base64Data] = audio.split(',')
69
+ // Fail loudly on malformed data: URIs instead of silently defaulting
70
+ // to `audio/mpeg` — the file's contract is that we never mislabel
71
+ // audio for the server.
72
+ const headerMatch = header?.match(/data:([^;]+)/)
73
+ const uriMimeType = headerMatch?.[1]
74
+ if (!uriMimeType) {
75
+ throw new Error(
76
+ 'Malformed data: URI in toAudioFile: cannot parse MIME type — expected data:<mime>[;charset=…][;base64],<payload>',
77
+ )
78
+ }
79
+ if (base64Data === undefined || base64Data.trim() === '') {
80
+ throw new Error(
81
+ 'Malformed data: URI in toAudioFile: missing base64 payload after comma',
82
+ )
83
+ }
84
+ // Caller-supplied `audioFormat` wins over the URI-embedded MIME: the
85
+ // caller has more context (the URI MIME may be wrong, or a generic
86
+ // `application/octet-stream`).
87
+ const mimeType = audioFormat ? toMimeType(audioFormat) : uriMimeType
88
+ const buffer = base64ToArrayBuffer(base64Data)
89
+ return new File([buffer], `audio.${extensionFor(mimeType)}`, {
90
+ type: mimeType,
91
+ })
92
+ }
93
+
94
+ if (!audioFormat) {
95
+ throw new Error(
96
+ 'toAudioFile requires a data: URI (e.g. data:audio/wav;base64,...) or an explicit audioFormat argument — bare base64 strings have no MIME type to infer',
97
+ )
98
+ }
99
+
100
+ const buffer = base64ToArrayBuffer(audio)
101
+ const mimeType = toMimeType(audioFormat)
102
+ return new File([buffer], `audio.${extensionFor(mimeType)}`, {
103
+ type: mimeType,
104
+ })
105
+ }
106
+
107
+ throw new Error('Invalid audio input type')
108
+ }
109
+
110
+ function toMimeType(audioFormat: string): string {
111
+ // Accept either "audio/…" strings or bare extensions like "mp3".
112
+ if (audioFormat.includes('/')) return audioFormat
113
+ const ext = audioFormat.toLowerCase()
114
+ switch (ext) {
115
+ case 'mp3':
116
+ return 'audio/mpeg'
117
+ case 'wav':
118
+ return 'audio/wav'
119
+ case 'ogg':
120
+ return 'audio/ogg'
121
+ case 'opus':
122
+ return 'audio/opus'
123
+ case 'flac':
124
+ return 'audio/flac'
125
+ case 'aac':
126
+ return 'audio/aac'
127
+ case 'mp4':
128
+ return 'audio/mp4'
129
+ case 'm4a':
130
+ return 'audio/mp4'
131
+ case 'webm':
132
+ return 'audio/webm'
133
+ case 'pcm':
134
+ return 'audio/L16'
135
+ case 'mulaw':
136
+ return 'audio/basic'
137
+ case 'alaw':
138
+ return 'audio/x-alaw-basic'
139
+ default:
140
+ return `audio/${ext}`
141
+ }
142
+ }
143
+
144
+ function extensionFor(mimeType: string): string {
145
+ switch (mimeType) {
146
+ case 'audio/mpeg':
147
+ return 'mp3'
148
+ case 'audio/wav':
149
+ case 'audio/x-wav':
150
+ return 'wav'
151
+ case 'audio/ogg':
152
+ return 'ogg'
153
+ case 'audio/opus':
154
+ return 'opus'
155
+ case 'audio/flac':
156
+ return 'flac'
157
+ case 'audio/aac':
158
+ return 'aac'
159
+ case 'audio/mp4':
160
+ return 'm4a'
161
+ case 'audio/webm':
162
+ return 'webm'
163
+ case 'audio/L16':
164
+ return 'pcm'
165
+ case 'audio/basic':
166
+ return 'mulaw'
167
+ case 'audio/x-alaw-basic':
168
+ return 'alaw'
169
+ default: {
170
+ const slash = mimeType.indexOf('/')
171
+ if (slash === -1) return 'bin'
172
+ return mimeType.slice(slash + 1) || 'bin'
173
+ }
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Cross-runtime ArrayBuffer → base64 conversion.
179
+ *
180
+ * Uses Node's `Buffer` when available (fastest path on server) and falls
181
+ * back to `btoa` + chunked `String.fromCharCode` everywhere else (browser,
182
+ * Cloudflare Workers, Bun, Deno). Chunking is required because a very
183
+ * large audio buffer spread into `String.fromCharCode(...bytes)` in one
184
+ * call can hit `Maximum call stack size exceeded`.
185
+ */
186
+ export function arrayBufferToBase64(buffer: ArrayBuffer): string {
187
+ if (typeof Buffer !== 'undefined') {
188
+ return Buffer.from(buffer).toString('base64')
189
+ }
190
+ const bytes = new Uint8Array(buffer)
191
+ const chunkSize = 0x8000
192
+ let binary = ''
193
+ for (let i = 0; i < bytes.length; i += chunkSize) {
194
+ const end = Math.min(i + chunkSize, bytes.length)
195
+ binary += String.fromCharCode.apply(
196
+ null,
197
+ bytes.subarray(i, end) as unknown as Array<number>,
198
+ )
199
+ }
200
+ return btoa(binary)
201
+ }
202
+
203
+ function base64ToArrayBuffer(base64: string): ArrayBuffer {
204
+ let binary: string
205
+ try {
206
+ binary = atob(base64)
207
+ } catch (err) {
208
+ const msg = err instanceof Error ? err.message : String(err)
209
+ throw new Error(`Invalid base64 input to toAudioFile: ${msg}`)
210
+ }
211
+ const buffer = new ArrayBuffer(binary.length)
212
+ const bytes = new Uint8Array(buffer)
213
+ for (let i = 0; i < binary.length; i++) {
214
+ bytes[i] = binary.charCodeAt(i)
215
+ }
216
+ return buffer
217
+ }
@@ -8,3 +8,4 @@ export {
8
8
  makeGrokStructuredOutputCompatible,
9
9
  transformNullsToUndefined,
10
10
  } from './schema-converter'
11
+ export { toAudioFile, arrayBufferToBase64 } from './audio'