From a350998372fecb0e145b717cadf2ff788916b2e2 Mon Sep 17 00:00:00 2001 From: Dhamivibez Date: Wed, 24 Jun 2026 12:41:50 +0200 Subject: [PATCH 1/2] feat(groq): add tree-shakeable Text-to-Speech (TTS) adapter Adds a Groq TTS adapter (groqSpeech / createGroqSpeech) for the Orpheus English and Arabic voices, driving Groq's OpenAI-compatible /audio/speech endpoint via the OpenAI SDK. Includes provider option types, TTS model metadata, index exports, and unit tests. --- .changeset/groq-tts-adapter.md | 5 + packages/ai-groq/src/adapters/tts.ts | 167 +++++++++++++ .../src/audio/audio-provider-options.ts | 25 ++ .../ai-groq/src/audio/tts-provider-options.ts | 48 ++++ packages/ai-groq/src/index.ts | 22 +- packages/ai-groq/src/model-meta.ts | 65 ++++- packages/ai-groq/tests/groq-tts.test.ts | 224 ++++++++++++++++++ 7 files changed, 550 insertions(+), 6 deletions(-) create mode 100644 .changeset/groq-tts-adapter.md create mode 100644 packages/ai-groq/src/adapters/tts.ts create mode 100644 packages/ai-groq/src/audio/audio-provider-options.ts create mode 100644 packages/ai-groq/src/audio/tts-provider-options.ts create mode 100644 packages/ai-groq/tests/groq-tts.test.ts diff --git a/.changeset/groq-tts-adapter.md b/.changeset/groq-tts-adapter.md new file mode 100644 index 000000000..fc9be43e5 --- /dev/null +++ b/.changeset/groq-tts-adapter.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-groq': minor +--- + +Add tree-shakeable Text-to-Speech (TTS) adapter for Groq with English and Arabic Orpheus voices, multiple output formats (default WAV), configurable speed and sample rate, model metadata, and unit tests. diff --git a/packages/ai-groq/src/adapters/tts.ts b/packages/ai-groq/src/adapters/tts.ts new file mode 100644 index 000000000..3b8b95f7c --- /dev/null +++ b/packages/ai-groq/src/adapters/tts.ts @@ -0,0 +1,167 @@ +import OpenAI from 'openai' +import { BaseTTSAdapter } from '@tanstack/ai/adapters' +import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' +import { arrayBufferToBase64, generateId } from '@tanstack/ai-utils' +import { getGroqApiKeyFromEnv, withGroqDefaults } from '../utils/client' +import { validateAudioInput } from '../audio/audio-provider-options' +import type { TTSOptions, TTSResult } from '@tanstack/ai' +import type OpenAI_SDK from 'openai' +import type { GroqTTSModel } from '../model-meta' +import type { GroqTTSProviderOptions } from '../audio/tts-provider-options' +import type { GroqClientConfig } from '../utils' + +/** + * Configuration for Groq TTS adapter + */ +export interface GroqTTSConfig extends GroqClientConfig {} + +/** + * Groq Text-to-Speech Adapter + * + * Tree-shakeable adapter for Groq TTS functionality. Groq exposes an + * OpenAI-compatible `/audio/speech` endpoint, so the adapter drives it with + * the OpenAI SDK via a `baseURL` override (the same pattern as the Groq text + * adapter). + * + * Supports `canopylabs/orpheus-v1-english` and + * `canopylabs/orpheus-arabic-saudi`. + * + * Features: + * - English voices: autumn(f), diana(f), hannah(f), austin(m), daniel(m), troy(m) + * - Arabic voices: fahad(m), sultan(m), lulwa(f), noura(f) + * - Output formats: flac, mp3, mulaw, ogg, wav (default wav) + * - Speed control + * - Configurable sample rate via `modelOptions` + */ +export class GroqTTSAdapter< + TModel extends GroqTTSModel, +> extends BaseTTSAdapter { + readonly name = 'groq' as const + + protected client: OpenAI + + constructor(config: GroqTTSConfig, model: TModel) { + super(model, {}) + this.client = new OpenAI(withGroqDefaults(config)) + } + + async generateSpeech( + options: TTSOptions, + ): Promise { + const { model, text, voice, format, speed, modelOptions } = options + + validateAudioInput({ input: text, model: this.model }) + + // Spreading optional inputs conditionally keeps the request compatible + // with the vendor SDK shape under exactOptionalPropertyTypes. `sample_rate` + // is a Groq-only body field carried via modelOptions. + const request: OpenAI_SDK.Audio.SpeechCreateParams = { + model, + input: text, + voice: voice ?? 'autumn', + response_format: format ?? 'wav', + ...(speed !== undefined && { speed }), + ...(modelOptions ?? {}), + } + + try { + options.logger.request( + `activity=tts provider=${this.name} model=${model} format=${request.response_format ?? 'default'} voice=${request.voice}`, + { provider: this.name, model }, + ) + const response = await this.client.audio.speech.create(request) + + const arrayBuffer = await response.arrayBuffer() + const base64 = arrayBufferToBase64(arrayBuffer) + + const outputFormat = request.response_format ?? 'wav' + const contentType = this.getContentType(outputFormat) + + return { + id: generateId(this.name), + model, + audio: base64, + format: outputFormat, + contentType, + } + } catch (error: unknown) { + // Narrow before logging: raw SDK errors can carry request metadata + // (including auth headers) which we must never surface to user loggers. + options.logger.errors(`${this.name}.generateSpeech fatal`, { + error: toRunErrorPayload(error, `${this.name}.generateSpeech failed`), + source: `${this.name}.generateSpeech`, + }) + throw error + } + } + + private getContentType(format: string): string { + const contentTypes: Record = { + flac: 'audio/flac', + mp3: 'audio/mpeg', + mulaw: 'audio/basic', + ogg: 'audio/ogg', + wav: 'audio/wav', + } + return contentTypes[format] || 'audio/wav' + } +} + +/** + * Creates a Groq speech adapter with explicit API key. + * Type resolution happens here at the call site. + * + * @param model - The model name (e.g., 'canopylabs/orpheus-v1-english') + * @param apiKey - Your Groq API key + * @param config - Optional additional configuration + * @returns Configured Groq speech adapter instance with resolved types + * + * @example + * ```typescript + * const adapter = createGroqSpeech('canopylabs/orpheus-v1-english', 'gsk_...') + * + * const result = await generateSpeech({ + * adapter, + * text: 'Hello, world!', + * voice: 'autumn', + * }) + * ``` + */ +export function createGroqSpeech( + model: TModel, + apiKey: string, + config?: Omit, +): GroqTTSAdapter { + return new GroqTTSAdapter({ apiKey, ...config }, model) +} + +/** + * Creates a Groq speech adapter with automatic API key detection from + * environment variables. + * + * Looks for `GROQ_API_KEY` in the environment. + * + * @param model - The model name (e.g., 'canopylabs/orpheus-v1-english') + * @param config - Optional configuration (excluding apiKey which is auto-detected) + * @returns Configured Groq speech adapter instance with resolved types + * @throws Error if GROQ_API_KEY is not found in environment + * + * @example + * ```typescript + * const adapter = groqSpeech('canopylabs/orpheus-v1-english') + * + * const result = await generateSpeech({ + * adapter, + * text: 'Welcome to TanStack AI!', + * voice: 'autumn', + * format: 'wav', + * }) + * ``` + */ +export function groqSpeech( + model: TModel, + config?: Omit, +): GroqTTSAdapter { + const apiKey = getGroqApiKeyFromEnv() + return createGroqSpeech(model, apiKey, config) +} diff --git a/packages/ai-groq/src/audio/audio-provider-options.ts b/packages/ai-groq/src/audio/audio-provider-options.ts new file mode 100644 index 000000000..d0a5d3ffe --- /dev/null +++ b/packages/ai-groq/src/audio/audio-provider-options.ts @@ -0,0 +1,25 @@ +/** + * Common audio provider options for Groq audio endpoints. + */ +export interface AudioProviderOptions { + /** + * The text to generate audio for. + * Maximum length is 200 characters. + * Use [directions] for vocal control (English voices only). + */ + input: string + /** + * The audio model to use for generation. + */ + model: string +} + +/** + * Validates that the audio input text does not exceed the maximum length. + * @throws Error if input text exceeds 200 characters + */ +export const validateAudioInput = (options: AudioProviderOptions) => { + if (options.input.length > 200) { + throw new Error('Input text exceeds maximum length of 200 characters.') + } +} diff --git a/packages/ai-groq/src/audio/tts-provider-options.ts b/packages/ai-groq/src/audio/tts-provider-options.ts new file mode 100644 index 000000000..de7172af2 --- /dev/null +++ b/packages/ai-groq/src/audio/tts-provider-options.ts @@ -0,0 +1,48 @@ +/** + * Groq TTS voice options for English models + */ +export type GroqTTSEnglishVoice = + | 'autumn' + | 'diana' + | 'hannah' + | 'austin' + | 'daniel' + | 'troy' + +/** + * Groq TTS voice options for Arabic models + */ +export type GroqTTSArabicVoice = 'fahad' | 'sultan' | 'lulwa' | 'noura' + +/** + * Union of all Groq TTS voice options + */ +export type GroqTTSVoice = GroqTTSEnglishVoice | GroqTTSArabicVoice + +/** + * Groq TTS output format options. + */ +export type GroqTTSFormat = 'flac' | 'mp3' | 'mulaw' | 'ogg' | 'wav' + +/** + * Groq TTS sample rate options + */ +export type GroqTTSSampleRate = + | 8000 + | 16000 + | 22050 + | 24000 + | 32000 + | 44100 + | 48000 + +/** + * Provider-specific options for Groq TTS. + * These options are passed via `modelOptions` when calling `generateSpeech`. + */ +export interface GroqTTSProviderOptions { + /** + * The sample rate of the generated audio in Hz. + */ + sample_rate?: GroqTTSSampleRate +} diff --git a/packages/ai-groq/src/index.ts b/packages/ai-groq/src/index.ts index 034ff38d0..e8a0b103b 100644 --- a/packages/ai-groq/src/index.ts +++ b/packages/ai-groq/src/index.ts @@ -2,7 +2,7 @@ * @module @tanstack/ai-groq * * Groq provider adapter for TanStack AI. - * Provides tree-shakeable adapters for Groq's Chat Completions API. + * Provides tree-shakeable adapters for Groq's Chat Completions API and TTS API. */ // Text (Chat) adapter @@ -14,16 +14,34 @@ export { type GroqTextProviderOptions, } from './adapters/text' +// TTS adapter - for text-to-speech +export { + GroqTTSAdapter, + createGroqSpeech, + groqSpeech, + type GroqTTSConfig, +} from './adapters/tts' +export type { + GroqTTSProviderOptions, + GroqTTSVoice, + GroqTTSEnglishVoice, + GroqTTSArabicVoice, + GroqTTSFormat, + GroqTTSSampleRate, +} from './audio/tts-provider-options' + // Types export type { GroqChatModelProviderOptionsByName, + GroqTTSModelProviderOptionsByName, GroqChatModelToolCapabilitiesByName, GroqModelInputModalitiesByName, ResolveProviderOptions, ResolveInputModalities, GroqChatModels, + GroqTTSModel, } from './model-meta' -export { GROQ_CHAT_MODELS } from './model-meta' +export { GROQ_CHAT_MODELS, GROQ_TTS_MODELS } from './model-meta' export type { GroqTextMetadata, GroqImageMetadata, diff --git a/packages/ai-groq/src/model-meta.ts b/packages/ai-groq/src/model-meta.ts index 70eae1cde..cbc44d91f 100644 --- a/packages/ai-groq/src/model-meta.ts +++ b/packages/ai-groq/src/model-meta.ts @@ -1,4 +1,5 @@ import type { GroqTextProviderOptions } from './text/text-provider-options' +import type { GroqTTSProviderOptions } from './audio/tts-provider-options' /** * Internal metadata structure describing a Groq model's capabilities and pricing. @@ -385,14 +386,23 @@ export type GroqChatModelToolCapabilitiesByName = { [QWEN3_32B.name]: typeof QWEN3_32B.supports.tools } +/** + * Type-only map from Groq TTS model name to its provider options type. + */ +export type GroqTTSModelProviderOptionsByName = { + [K in GroqTTSModel]: GroqTTSProviderOptions +} + /** * Resolves the provider options type for a specific Groq model. - * Falls back to generic GroqTextProviderOptions for unknown models. + * Checks TTS models first, then chat models, then falls back to generic options. */ export type ResolveProviderOptions = - TModel extends keyof GroqChatModelProviderOptionsByName - ? GroqChatModelProviderOptionsByName[TModel] - : GroqTextProviderOptions + TModel extends GroqTTSModel + ? GroqTTSProviderOptions + : TModel extends keyof GroqChatModelProviderOptionsByName + ? GroqChatModelProviderOptionsByName[TModel] + : GroqTextProviderOptions /** * Resolve input modalities for a specific model. @@ -402,3 +412,50 @@ export type ResolveInputModalities = TModel extends keyof GroqModelInputModalitiesByName ? GroqModelInputModalitiesByName[TModel] : readonly ['text'] + +// ============================================================================ +// TTS Models +// ============================================================================ + +const ORPHEUS_V1_ENGLISH = { + name: 'canopylabs/orpheus-v1-english', + pricing: { + input: { + normal: 22, + }, + }, + supports: { + input: ['text'], + output: ['audio'], + endpoints: ['tts'], + features: [], + }, +} as const satisfies ModelMeta + +const ORPHEUS_ARABIC_SAUDI = { + name: 'canopylabs/orpheus-arabic-saudi', + pricing: { + input: { + normal: 40, + }, + }, + supports: { + input: ['text'], + output: ['audio'], + endpoints: ['tts'], + features: [], + }, +} as const satisfies ModelMeta + +/** + * All supported Groq TTS model identifiers. + */ +export const GROQ_TTS_MODELS = [ + ORPHEUS_V1_ENGLISH.name, + ORPHEUS_ARABIC_SAUDI.name, +] as const + +/** + * Union type of all supported Groq TTS model names. + */ +export type GroqTTSModel = (typeof GROQ_TTS_MODELS)[number] diff --git a/packages/ai-groq/tests/groq-tts.test.ts b/packages/ai-groq/tests/groq-tts.test.ts new file mode 100644 index 000000000..2b4fb5538 --- /dev/null +++ b/packages/ai-groq/tests/groq-tts.test.ts @@ -0,0 +1,224 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { generateSpeech } from '@tanstack/ai' +import { + GroqTTSAdapter, + createGroqSpeech, + groqSpeech, +} from '../src/adapters/tts' + +const mockSpeechCreate = vi.fn() + +// Groq drives the OpenAI-compatible /audio/speech endpoint via the OpenAI SDK. +vi.mock('openai', () => { + class OpenAI { + audio = { + speech: { + create: (...args: Array) => mockSpeechCreate(...args), + }, + } + } + return { default: OpenAI } +}) + +// Helper to create a mock audio response (mirrors the SDK's Response shape). +function createMockAudioResponse(audioContent = 'mock-audio-data') { + const buffer = new TextEncoder().encode(audioContent) + return { + arrayBuffer: () => Promise.resolve(buffer.buffer), + } +} + +describe('Groq TTS adapter', () => { + beforeEach(() => { + mockSpeechCreate.mockReset() + }) + + afterEach(() => { + delete process.env['GROQ_API_KEY'] + }) + + describe('Adapter creation', () => { + it('creates a TTS adapter with explicit API key', () => { + const adapter = createGroqSpeech( + 'canopylabs/orpheus-v1-english', + 'test-api-key', + ) + + expect(adapter).toBeInstanceOf(GroqTTSAdapter) + expect(adapter.kind).toBe('tts') + expect(adapter.name).toBe('groq') + expect(adapter.model).toBe('canopylabs/orpheus-v1-english') + }) + + it('creates a TTS adapter from environment variable', () => { + process.env['GROQ_API_KEY'] = 'env-api-key' + + const adapter = groqSpeech('canopylabs/orpheus-arabic-saudi') + + expect(adapter.kind).toBe('tts') + expect(adapter.model).toBe('canopylabs/orpheus-arabic-saudi') + }) + + it('throws if GROQ_API_KEY is not set when using groqSpeech', () => { + delete process.env['GROQ_API_KEY'] + + expect(() => groqSpeech('canopylabs/orpheus-v1-english')).toThrow( + 'GROQ_API_KEY is required', + ) + }) + + it('allows custom baseURL override', () => { + const adapter = createGroqSpeech( + 'canopylabs/orpheus-v1-english', + 'test-api-key', + { baseURL: 'https://custom.api.example.com/v1' }, + ) + + expect(adapter).toBeInstanceOf(GroqTTSAdapter) + }) + }) + + describe('generateSpeech', () => { + it('generates speech and returns base64 audio', async () => { + mockSpeechCreate.mockResolvedValueOnce( + createMockAudioResponse('test-audio-bytes'), + ) + + const adapter = createGroqSpeech( + 'canopylabs/orpheus-v1-english', + 'test-api-key', + ) + + const result = await generateSpeech({ + adapter, + text: 'Hello, world!', + voice: 'autumn', + format: 'wav', + speed: 1, + }) + + expect(result.model).toBe('canopylabs/orpheus-v1-english') + expect(result.format).toBe('wav') + expect(result.contentType).toBe('audio/wav') + expect(result.audio).toBeDefined() + expect(result.id).toMatch(/^groq-/) + }) + + it('passes correct parameters to the SDK', async () => { + mockSpeechCreate.mockResolvedValueOnce(createMockAudioResponse()) + + const adapter = createGroqSpeech( + 'canopylabs/orpheus-v1-english', + 'test-api-key', + ) + + await generateSpeech({ + adapter, + text: 'Test speech', + voice: 'daniel', + format: 'wav', + speed: 1.5, + modelOptions: { sample_rate: 24000 }, + }) + + expect(mockSpeechCreate).toHaveBeenCalledTimes(1) + const [params] = mockSpeechCreate.mock.calls[0] as [ + Record, + ] + + expect(params).toMatchObject({ + model: 'canopylabs/orpheus-v1-english', + input: 'Test speech', + voice: 'daniel', + response_format: 'wav', + speed: 1.5, + sample_rate: 24000, + }) + }) + + it('defaults to wav format when no format is specified', async () => { + mockSpeechCreate.mockResolvedValueOnce(createMockAudioResponse()) + + const adapter = createGroqSpeech( + 'canopylabs/orpheus-v1-english', + 'test-api-key', + ) + + const result = await generateSpeech({ adapter, text: 'Hello!' }) + + expect(result.format).toBe('wav') + expect(result.contentType).toBe('audio/wav') + }) + + it('defaults to autumn voice when no voice is specified', async () => { + mockSpeechCreate.mockResolvedValueOnce(createMockAudioResponse()) + + const adapter = createGroqSpeech( + 'canopylabs/orpheus-v1-english', + 'test-api-key', + ) + + await generateSpeech({ adapter, text: 'Hello!' }) + + const [params] = mockSpeechCreate.mock.calls[0] as [ + Record, + ] + expect(params.voice).toBe('autumn') + }) + + it('throws error when input exceeds 200 characters', async () => { + const adapter = createGroqSpeech( + 'canopylabs/orpheus-v1-english', + 'test-api-key', + ) + + await expect( + generateSpeech({ adapter, text: 'a'.repeat(201) }), + ).rejects.toThrow('Input text exceeds maximum length of 200 characters.') + }) + + it('returns correct content type for different formats', async () => { + const formatContentTypes: Array<['mp3' | 'flac' | 'wav', string]> = [ + ['mp3', 'audio/mpeg'], + ['flac', 'audio/flac'], + ['wav', 'audio/wav'], + ] + + for (const [format, expectedContentType] of formatContentTypes) { + mockSpeechCreate.mockResolvedValueOnce(createMockAudioResponse()) + + const adapter = createGroqSpeech( + 'canopylabs/orpheus-v1-english', + 'test-api-key', + ) + + const result = await generateSpeech({ adapter, text: 'Test', format }) + + expect(result.contentType).toBe(expectedContentType) + } + }) + + it('works with Arabic model and voices', async () => { + mockSpeechCreate.mockResolvedValueOnce(createMockAudioResponse()) + + const adapter = createGroqSpeech( + 'canopylabs/orpheus-arabic-saudi', + 'test-api-key', + ) + + const result = await generateSpeech({ + adapter, + text: 'مرحبا', + voice: 'fahad', + format: 'wav', + }) + + expect(result.model).toBe('canopylabs/orpheus-arabic-saudi') + + const [params] = mockSpeechCreate.mock.calls[0] as [ + Record, + ] + expect(params.voice).toBe('fahad') + }) + }) +}) From ada8301bf77f23b367818d06d010935a5e300da2 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:47:34 +0000 Subject: [PATCH 2/2] ci: apply automated fixes --- packages/ai-groq/src/adapters/tts.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ai-groq/src/adapters/tts.ts b/packages/ai-groq/src/adapters/tts.ts index 3b8b95f7c..2c3f2df56 100644 --- a/packages/ai-groq/src/adapters/tts.ts +++ b/packages/ai-groq/src/adapters/tts.ts @@ -33,9 +33,10 @@ export interface GroqTTSConfig extends GroqClientConfig {} * - Speed control * - Configurable sample rate via `modelOptions` */ -export class GroqTTSAdapter< - TModel extends GroqTTSModel, -> extends BaseTTSAdapter { +export class GroqTTSAdapter extends BaseTTSAdapter< + TModel, + GroqTTSProviderOptions +> { readonly name = 'groq' as const protected client: OpenAI