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