@tanstack/ai-grok 0.0.1 → 0.3.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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -28
  3. package/dist/esm/adapters/image.d.ts +77 -0
  4. package/dist/esm/adapters/image.js +63 -0
  5. package/dist/esm/adapters/image.js.map +1 -0
  6. package/dist/esm/adapters/summarize.d.ts +75 -0
  7. package/dist/esm/adapters/summarize.js +86 -0
  8. package/dist/esm/adapters/summarize.js.map +1 -0
  9. package/dist/esm/adapters/text.d.ts +96 -0
  10. package/dist/esm/adapters/text.js +371 -0
  11. package/dist/esm/adapters/text.js.map +1 -0
  12. package/dist/esm/image/image-provider-options.d.ts +66 -0
  13. package/dist/esm/image/image-provider-options.js +39 -0
  14. package/dist/esm/image/image-provider-options.js.map +1 -0
  15. package/dist/esm/index.d.ts +7 -0
  16. package/dist/esm/index.js +18 -0
  17. package/dist/esm/index.js.map +1 -0
  18. package/dist/esm/message-types.d.ts +64 -0
  19. package/dist/esm/model-meta.d.ts +223 -0
  20. package/dist/esm/model-meta.js +47 -0
  21. package/dist/esm/model-meta.js.map +1 -0
  22. package/dist/esm/text/text-provider-options.d.ts +66 -0
  23. package/dist/esm/text/text-provider-options.js +6 -0
  24. package/dist/esm/text/text-provider-options.js.map +1 -0
  25. package/dist/esm/tools/function-tool.d.ts +15 -0
  26. package/dist/esm/tools/function-tool.js +27 -0
  27. package/dist/esm/tools/function-tool.js.map +1 -0
  28. package/dist/esm/tools/index.d.ts +2 -0
  29. package/dist/esm/tools/tool-converter.d.ts +7 -0
  30. package/dist/esm/tools/tool-converter.js +10 -0
  31. package/dist/esm/tools/tool-converter.js.map +1 -0
  32. package/dist/esm/utils/client.d.ts +18 -0
  33. package/dist/esm/utils/client.js +26 -0
  34. package/dist/esm/utils/client.js.map +1 -0
  35. package/dist/esm/utils/index.d.ts +2 -0
  36. package/dist/esm/utils/schema-converter.d.ts +24 -0
  37. package/dist/esm/utils/schema-converter.js +71 -0
  38. package/dist/esm/utils/schema-converter.js.map +1 -0
  39. package/package.json +50 -7
  40. package/src/adapters/image.ts +176 -0
  41. package/src/adapters/summarize.ts +181 -0
  42. package/src/adapters/text.ts +612 -0
  43. package/src/image/image-provider-options.ts +118 -0
  44. package/src/index.ts +55 -0
  45. package/src/message-types.ts +67 -0
  46. package/src/model-meta.ts +298 -0
  47. package/src/text/text-provider-options.ts +77 -0
  48. package/src/tools/function-tool.ts +45 -0
  49. package/src/tools/index.ts +5 -0
  50. package/src/tools/tool-converter.ts +17 -0
  51. package/src/utils/client.ts +45 -0
  52. package/src/utils/index.ts +10 -0
  53. package/src/utils/schema-converter.ts +110 -0
@@ -0,0 +1,612 @@
1
+ import { BaseTextAdapter } from '@tanstack/ai/adapters'
2
+ import { validateTextProviderOptions } from '../text/text-provider-options'
3
+ import { convertToolsToProviderFormat } from '../tools'
4
+ import {
5
+ createGrokClient,
6
+ generateId,
7
+ getGrokApiKeyFromEnv,
8
+ makeGrokStructuredOutputCompatible,
9
+ transformNullsToUndefined,
10
+ } from '../utils'
11
+ import type {
12
+ GROK_CHAT_MODELS,
13
+ ResolveInputModalities,
14
+ ResolveProviderOptions,
15
+ } from '../model-meta'
16
+ import type {
17
+ StructuredOutputOptions,
18
+ StructuredOutputResult,
19
+ } from '@tanstack/ai/adapters'
20
+ import type OpenAI_SDK from 'openai'
21
+ import type {
22
+ ContentPart,
23
+ ModelMessage,
24
+ StreamChunk,
25
+ TextOptions,
26
+ } from '@tanstack/ai'
27
+ import type { InternalTextProviderOptions } from '../text/text-provider-options'
28
+ import type {
29
+ GrokImageMetadata,
30
+ GrokMessageMetadataByModality,
31
+ } from '../message-types'
32
+ import type { GrokClientConfig } from '../utils'
33
+
34
+ /**
35
+ * Configuration for Grok text adapter
36
+ */
37
+ export interface GrokTextConfig extends GrokClientConfig {}
38
+
39
+ /**
40
+ * Alias for TextProviderOptions for external use
41
+ */
42
+ export type { ExternalTextProviderOptions as GrokTextProviderOptions } from '../text/text-provider-options'
43
+
44
+ /**
45
+ * Grok Text (Chat) Adapter
46
+ *
47
+ * Tree-shakeable adapter for Grok chat/text completion functionality.
48
+ * Uses OpenAI-compatible Chat Completions API (not Responses API).
49
+ */
50
+ export class GrokTextAdapter<
51
+ TModel extends (typeof GROK_CHAT_MODELS)[number],
52
+ > extends BaseTextAdapter<
53
+ TModel,
54
+ ResolveProviderOptions<TModel>,
55
+ ResolveInputModalities<TModel>,
56
+ GrokMessageMetadataByModality
57
+ > {
58
+ readonly kind = 'text' as const
59
+ readonly name = 'grok' as const
60
+
61
+ private client: OpenAI_SDK
62
+
63
+ constructor(config: GrokTextConfig, model: TModel) {
64
+ super({}, model)
65
+ this.client = createGrokClient(config)
66
+ }
67
+
68
+ async *chatStream(
69
+ options: TextOptions<ResolveProviderOptions<TModel>>,
70
+ ): AsyncIterable<StreamChunk> {
71
+ const requestParams = this.mapTextOptionsToGrok(options)
72
+ const timestamp = Date.now()
73
+
74
+ // AG-UI lifecycle tracking (mutable state object for ESLint compatibility)
75
+ const aguiState = {
76
+ runId: generateId(this.name),
77
+ messageId: generateId(this.name),
78
+ timestamp,
79
+ hasEmittedRunStarted: false,
80
+ }
81
+
82
+ try {
83
+ const stream = await this.client.chat.completions.create({
84
+ ...requestParams,
85
+ stream: true,
86
+ })
87
+
88
+ yield* this.processGrokStreamChunks(stream, options, aguiState)
89
+ } catch (error: unknown) {
90
+ const err = error as Error & { code?: string }
91
+
92
+ // Emit RUN_STARTED if not yet emitted
93
+ if (!aguiState.hasEmittedRunStarted) {
94
+ aguiState.hasEmittedRunStarted = true
95
+ yield {
96
+ type: 'RUN_STARTED',
97
+ runId: aguiState.runId,
98
+ model: options.model,
99
+ timestamp,
100
+ }
101
+ }
102
+
103
+ // Emit AG-UI RUN_ERROR
104
+ yield {
105
+ type: 'RUN_ERROR',
106
+ runId: aguiState.runId,
107
+ model: options.model,
108
+ timestamp,
109
+ error: {
110
+ message: err.message || 'Unknown error',
111
+ code: err.code,
112
+ },
113
+ }
114
+
115
+ console.error('>>> chatStream: Fatal error during response creation <<<')
116
+ console.error('>>> Error message:', err.message)
117
+ console.error('>>> Error stack:', err.stack)
118
+ console.error('>>> Full error:', err)
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Generate structured output using Grok's JSON Schema response format.
124
+ * Uses stream: false to get the complete response in one call.
125
+ *
126
+ * Grok has strict requirements for structured output (via OpenAI-compatible API):
127
+ * - All properties must be in the `required` array
128
+ * - Optional fields should have null added to their type union
129
+ * - additionalProperties must be false for all objects
130
+ *
131
+ * The outputSchema is already JSON Schema (converted in the ai layer).
132
+ * We apply Grok-specific transformations for structured output compatibility.
133
+ */
134
+ async structuredOutput(
135
+ options: StructuredOutputOptions<ResolveProviderOptions<TModel>>,
136
+ ): Promise<StructuredOutputResult<unknown>> {
137
+ const { chatOptions, outputSchema } = options
138
+ const requestParams = this.mapTextOptionsToGrok(chatOptions)
139
+
140
+ // Apply Grok-specific transformations for structured output compatibility
141
+ const jsonSchema = makeGrokStructuredOutputCompatible(
142
+ outputSchema,
143
+ outputSchema.required || [],
144
+ )
145
+
146
+ try {
147
+ const response = await this.client.chat.completions.create({
148
+ ...requestParams,
149
+ stream: false,
150
+ response_format: {
151
+ type: 'json_schema',
152
+ json_schema: {
153
+ name: 'structured_output',
154
+ schema: jsonSchema,
155
+ strict: true,
156
+ },
157
+ },
158
+ })
159
+
160
+ // Extract text content from the response
161
+ const rawText = response.choices[0]?.message.content || ''
162
+
163
+ // Parse the JSON response
164
+ let parsed: unknown
165
+ try {
166
+ parsed = JSON.parse(rawText)
167
+ } catch {
168
+ throw new Error(
169
+ `Failed to parse structured output as JSON. Content: ${rawText.slice(0, 200)}${rawText.length > 200 ? '...' : ''}`,
170
+ )
171
+ }
172
+
173
+ // Transform null values to undefined to match original Zod schema expectations
174
+ // Grok returns null for optional fields we made nullable in the schema
175
+ const transformed = transformNullsToUndefined(parsed)
176
+
177
+ return {
178
+ data: transformed,
179
+ rawText,
180
+ }
181
+ } catch (error: unknown) {
182
+ const err = error as Error
183
+ console.error('>>> structuredOutput: Error during response creation <<<')
184
+ console.error('>>> Error message:', err.message)
185
+ throw error
186
+ }
187
+ }
188
+
189
+ private async *processGrokStreamChunks(
190
+ stream: AsyncIterable<OpenAI_SDK.Chat.Completions.ChatCompletionChunk>,
191
+ options: TextOptions,
192
+ aguiState: {
193
+ runId: string
194
+ messageId: string
195
+ timestamp: number
196
+ hasEmittedRunStarted: boolean
197
+ },
198
+ ): AsyncIterable<StreamChunk> {
199
+ let accumulatedContent = ''
200
+ const timestamp = aguiState.timestamp
201
+ let hasEmittedTextMessageStart = false
202
+
203
+ // Track tool calls being streamed (arguments come in chunks)
204
+ const toolCallsInProgress = new Map<
205
+ number,
206
+ {
207
+ id: string
208
+ name: string
209
+ arguments: string
210
+ started: boolean // Track if TOOL_CALL_START has been emitted
211
+ }
212
+ >()
213
+
214
+ try {
215
+ for await (const chunk of stream) {
216
+ const choice = chunk.choices[0]
217
+
218
+ if (!choice) continue
219
+
220
+ // Emit RUN_STARTED on first chunk
221
+ if (!aguiState.hasEmittedRunStarted) {
222
+ aguiState.hasEmittedRunStarted = true
223
+ yield {
224
+ type: 'RUN_STARTED',
225
+ runId: aguiState.runId,
226
+ model: chunk.model || options.model,
227
+ timestamp,
228
+ }
229
+ }
230
+
231
+ const delta = choice.delta
232
+ const deltaContent = delta.content
233
+ const deltaToolCalls = delta.tool_calls
234
+
235
+ // Handle content delta
236
+ if (deltaContent) {
237
+ // Emit TEXT_MESSAGE_START on first text content
238
+ if (!hasEmittedTextMessageStart) {
239
+ hasEmittedTextMessageStart = true
240
+ yield {
241
+ type: 'TEXT_MESSAGE_START',
242
+ messageId: aguiState.messageId,
243
+ model: chunk.model || options.model,
244
+ timestamp,
245
+ role: 'assistant',
246
+ }
247
+ }
248
+
249
+ accumulatedContent += deltaContent
250
+
251
+ // Emit AG-UI TEXT_MESSAGE_CONTENT
252
+ yield {
253
+ type: 'TEXT_MESSAGE_CONTENT',
254
+ messageId: aguiState.messageId,
255
+ model: chunk.model || options.model,
256
+ timestamp,
257
+ delta: deltaContent,
258
+ content: accumulatedContent,
259
+ }
260
+ }
261
+
262
+ // Handle tool calls - they come in as deltas
263
+ if (deltaToolCalls) {
264
+ for (const toolCallDelta of deltaToolCalls) {
265
+ const index = toolCallDelta.index
266
+
267
+ // Initialize or update the tool call in progress
268
+ if (!toolCallsInProgress.has(index)) {
269
+ toolCallsInProgress.set(index, {
270
+ id: toolCallDelta.id || '',
271
+ name: toolCallDelta.function?.name || '',
272
+ arguments: '',
273
+ started: false,
274
+ })
275
+ }
276
+
277
+ const toolCall = toolCallsInProgress.get(index)!
278
+
279
+ // Update with any new data from the delta
280
+ if (toolCallDelta.id) {
281
+ toolCall.id = toolCallDelta.id
282
+ }
283
+ if (toolCallDelta.function?.name) {
284
+ toolCall.name = toolCallDelta.function.name
285
+ }
286
+ if (toolCallDelta.function?.arguments) {
287
+ toolCall.arguments += toolCallDelta.function.arguments
288
+ }
289
+
290
+ // Emit TOOL_CALL_START when we have id and name
291
+ if (toolCall.id && toolCall.name && !toolCall.started) {
292
+ toolCall.started = true
293
+ yield {
294
+ type: 'TOOL_CALL_START',
295
+ toolCallId: toolCall.id,
296
+ toolName: toolCall.name,
297
+ model: chunk.model || options.model,
298
+ timestamp,
299
+ index,
300
+ }
301
+ }
302
+
303
+ // Emit TOOL_CALL_ARGS for argument deltas
304
+ if (toolCallDelta.function?.arguments && toolCall.started) {
305
+ yield {
306
+ type: 'TOOL_CALL_ARGS',
307
+ toolCallId: toolCall.id,
308
+ model: chunk.model || options.model,
309
+ timestamp,
310
+ delta: toolCallDelta.function.arguments,
311
+ }
312
+ }
313
+ }
314
+ }
315
+
316
+ // Handle finish reason
317
+ if (choice.finish_reason) {
318
+ // Emit all completed tool calls
319
+ if (
320
+ choice.finish_reason === 'tool_calls' ||
321
+ toolCallsInProgress.size > 0
322
+ ) {
323
+ for (const [, toolCall] of toolCallsInProgress) {
324
+ // Parse arguments for TOOL_CALL_END
325
+ let parsedInput: unknown = {}
326
+ try {
327
+ parsedInput = toolCall.arguments
328
+ ? JSON.parse(toolCall.arguments)
329
+ : {}
330
+ } catch {
331
+ parsedInput = {}
332
+ }
333
+
334
+ // Emit AG-UI TOOL_CALL_END
335
+ yield {
336
+ type: 'TOOL_CALL_END',
337
+ toolCallId: toolCall.id,
338
+ toolName: toolCall.name,
339
+ model: chunk.model || options.model,
340
+ timestamp,
341
+ input: parsedInput,
342
+ }
343
+ }
344
+ }
345
+
346
+ const computedFinishReason =
347
+ choice.finish_reason === 'tool_calls' ||
348
+ toolCallsInProgress.size > 0
349
+ ? 'tool_calls'
350
+ : 'stop'
351
+
352
+ // Emit TEXT_MESSAGE_END if we had text content
353
+ if (hasEmittedTextMessageStart) {
354
+ yield {
355
+ type: 'TEXT_MESSAGE_END',
356
+ messageId: aguiState.messageId,
357
+ model: chunk.model || options.model,
358
+ timestamp,
359
+ }
360
+ }
361
+
362
+ // Emit AG-UI RUN_FINISHED
363
+ yield {
364
+ type: 'RUN_FINISHED',
365
+ runId: aguiState.runId,
366
+ model: chunk.model || options.model,
367
+ timestamp,
368
+ usage: chunk.usage
369
+ ? {
370
+ promptTokens: chunk.usage.prompt_tokens || 0,
371
+ completionTokens: chunk.usage.completion_tokens || 0,
372
+ totalTokens: chunk.usage.total_tokens || 0,
373
+ }
374
+ : undefined,
375
+ finishReason: computedFinishReason,
376
+ }
377
+ }
378
+ }
379
+ } catch (error: unknown) {
380
+ const err = error as Error & { code?: string }
381
+ console.log('[Grok Adapter] Stream ended with error:', err.message)
382
+
383
+ // Emit AG-UI RUN_ERROR
384
+ yield {
385
+ type: 'RUN_ERROR',
386
+ runId: aguiState.runId,
387
+ model: options.model,
388
+ timestamp,
389
+ error: {
390
+ message: err.message || 'Unknown error occurred',
391
+ code: err.code,
392
+ },
393
+ }
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Maps common options to Grok-specific Chat Completions format
399
+ */
400
+ private mapTextOptionsToGrok(
401
+ options: TextOptions,
402
+ ): OpenAI_SDK.Chat.Completions.ChatCompletionCreateParamsStreaming {
403
+ const modelOptions = options.modelOptions as
404
+ | Omit<
405
+ InternalTextProviderOptions,
406
+ 'max_tokens' | 'tools' | 'temperature' | 'input' | 'top_p'
407
+ >
408
+ | undefined
409
+
410
+ if (modelOptions) {
411
+ validateTextProviderOptions({
412
+ ...modelOptions,
413
+ model: options.model,
414
+ })
415
+ }
416
+
417
+ const tools = options.tools
418
+ ? convertToolsToProviderFormat(options.tools)
419
+ : undefined
420
+
421
+ // Build messages array with system prompts
422
+ const messages: Array<OpenAI_SDK.Chat.Completions.ChatCompletionMessageParam> =
423
+ []
424
+
425
+ // Add system prompts first
426
+ if (options.systemPrompts && options.systemPrompts.length > 0) {
427
+ messages.push({
428
+ role: 'system',
429
+ content: options.systemPrompts.join('\n'),
430
+ })
431
+ }
432
+
433
+ // Convert messages
434
+ for (const message of options.messages) {
435
+ messages.push(this.convertMessageToGrok(message))
436
+ }
437
+
438
+ return {
439
+ model: options.model,
440
+ messages,
441
+ temperature: options.temperature,
442
+ max_tokens: options.maxTokens,
443
+ top_p: options.topP,
444
+ tools: tools as Array<OpenAI_SDK.Chat.Completions.ChatCompletionTool>,
445
+ stream: true,
446
+ stream_options: { include_usage: true },
447
+ }
448
+ }
449
+
450
+ private convertMessageToGrok(
451
+ message: ModelMessage,
452
+ ): OpenAI_SDK.Chat.Completions.ChatCompletionMessageParam {
453
+ // Handle tool messages
454
+ if (message.role === 'tool') {
455
+ return {
456
+ role: 'tool',
457
+ tool_call_id: message.toolCallId || '',
458
+ content:
459
+ typeof message.content === 'string'
460
+ ? message.content
461
+ : JSON.stringify(message.content),
462
+ }
463
+ }
464
+
465
+ // Handle assistant messages
466
+ if (message.role === 'assistant') {
467
+ const toolCalls = message.toolCalls?.map((tc) => ({
468
+ id: tc.id,
469
+ type: 'function' as const,
470
+ function: {
471
+ name: tc.function.name,
472
+ arguments:
473
+ typeof tc.function.arguments === 'string'
474
+ ? tc.function.arguments
475
+ : JSON.stringify(tc.function.arguments),
476
+ },
477
+ }))
478
+
479
+ return {
480
+ role: 'assistant',
481
+ content: this.extractTextContent(message.content),
482
+ ...(toolCalls && toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
483
+ }
484
+ }
485
+
486
+ // Handle user messages - support multimodal content
487
+ const contentParts = this.normalizeContent(message.content)
488
+
489
+ // If only text, use simple string format
490
+ if (contentParts.length === 1 && contentParts[0]?.type === 'text') {
491
+ return {
492
+ role: 'user',
493
+ content: contentParts[0].content,
494
+ }
495
+ }
496
+
497
+ // Otherwise, use array format for multimodal
498
+ const parts: Array<OpenAI_SDK.Chat.Completions.ChatCompletionContentPart> =
499
+ []
500
+ for (const part of contentParts) {
501
+ if (part.type === 'text') {
502
+ parts.push({ type: 'text', text: part.content })
503
+ } else if (part.type === 'image') {
504
+ const imageMetadata = part.metadata as GrokImageMetadata | undefined
505
+ parts.push({
506
+ type: 'image_url',
507
+ image_url: {
508
+ url: part.source.value,
509
+ detail: imageMetadata?.detail || 'auto',
510
+ },
511
+ })
512
+ }
513
+ }
514
+
515
+ return {
516
+ role: 'user',
517
+ content: parts.length > 0 ? parts : '',
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Normalizes message content to an array of ContentPart.
523
+ * Handles backward compatibility with string content.
524
+ */
525
+ private normalizeContent(
526
+ content: string | null | Array<ContentPart>,
527
+ ): Array<ContentPart> {
528
+ if (content === null) {
529
+ return []
530
+ }
531
+ if (typeof content === 'string') {
532
+ return [{ type: 'text', content: content }]
533
+ }
534
+ return content
535
+ }
536
+
537
+ /**
538
+ * Extracts text content from a content value that may be string, null, or ContentPart array.
539
+ */
540
+ private extractTextContent(
541
+ content: string | null | Array<ContentPart>,
542
+ ): string {
543
+ if (content === null) {
544
+ return ''
545
+ }
546
+ if (typeof content === 'string') {
547
+ return content
548
+ }
549
+ // It's an array of ContentPart
550
+ return content
551
+ .filter((p) => p.type === 'text')
552
+ .map((p) => p.content)
553
+ .join('')
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Creates a Grok text adapter with explicit API key.
559
+ * Type resolution happens here at the call site.
560
+ *
561
+ * @param model - The model name (e.g., 'grok-3', 'grok-4')
562
+ * @param apiKey - Your xAI API key
563
+ * @param config - Optional additional configuration
564
+ * @returns Configured Grok text adapter instance with resolved types
565
+ *
566
+ * @example
567
+ * ```typescript
568
+ * const adapter = createGrokText('grok-3', "xai-...");
569
+ * // adapter has type-safe providerOptions for grok-3
570
+ * ```
571
+ */
572
+ export function createGrokText<
573
+ TModel extends (typeof GROK_CHAT_MODELS)[number],
574
+ >(
575
+ model: TModel,
576
+ apiKey: string,
577
+ config?: Omit<GrokTextConfig, 'apiKey'>,
578
+ ): GrokTextAdapter<TModel> {
579
+ return new GrokTextAdapter({ apiKey, ...config }, model)
580
+ }
581
+
582
+ /**
583
+ * Creates a Grok text adapter with automatic API key detection from environment variables.
584
+ * Type resolution happens here at the call site.
585
+ *
586
+ * Looks for `XAI_API_KEY` in:
587
+ * - `process.env` (Node.js)
588
+ * - `window.env` (Browser with injected env)
589
+ *
590
+ * @param model - The model name (e.g., 'grok-3', 'grok-4')
591
+ * @param config - Optional configuration (excluding apiKey which is auto-detected)
592
+ * @returns Configured Grok text adapter instance with resolved types
593
+ * @throws Error if XAI_API_KEY is not found in environment
594
+ *
595
+ * @example
596
+ * ```typescript
597
+ * // Automatically uses XAI_API_KEY from environment
598
+ * const adapter = grokText('grok-3');
599
+ *
600
+ * const stream = chat({
601
+ * adapter,
602
+ * messages: [{ role: "user", content: "Hello!" }]
603
+ * });
604
+ * ```
605
+ */
606
+ export function grokText<TModel extends (typeof GROK_CHAT_MODELS)[number]>(
607
+ model: TModel,
608
+ config?: Omit<GrokTextConfig, 'apiKey'>,
609
+ ): GrokTextAdapter<TModel> {
610
+ const apiKey = getGrokApiKeyFromEnv()
611
+ return createGrokText(model, apiKey, config)
612
+ }