diff --git a/api/server/services/Files/Audio/STTService.js b/api/server/services/Files/Audio/STTService.js index 94512930c7..c5124ea7cf 100644 --- a/api/server/services/Files/Audio/STTService.js +++ b/api/server/services/Files/Audio/STTService.js @@ -157,7 +157,7 @@ class STTService { } const providers = Object.entries(sttSchema).filter( - ([, value]) => Object.keys(value).length > 0, + ([key, value]) => key !== 'allowedAddresses' && Object.keys(value).length > 0, ); if (providers.length !== 1) { diff --git a/api/server/services/Files/Audio/STTService.spec.js b/api/server/services/Files/Audio/STTService.spec.js index b91c1c95bd..6886e4c641 100644 --- a/api/server/services/Files/Audio/STTService.spec.js +++ b/api/server/services/Files/Audio/STTService.spec.js @@ -10,7 +10,7 @@ jest.mock('librechat-data-provider', () => ({ })); jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); -const { getFileExtensionFromMime, MIME_TO_EXTENSION_MAP } = require('./STTService'); +const { STTService, getFileExtensionFromMime, MIME_TO_EXTENSION_MAP } = require('./STTService'); describe('getFileExtensionFromMime', () => { it('should normalize audio/x-m4a to m4a', () => { @@ -51,6 +51,41 @@ describe('getFileExtensionFromMime', () => { }); }); +describe('STTService.getProviderSchema provider detection', () => { + const service = new STTService(); + + const buildReq = (stt) => ({ config: { speech: { stt } } }); + + it('resolves exactly one provider when allowedAddresses is set alongside it', async () => { + const req = buildReq({ + allowedAddresses: ['127.0.0.1:8080'], + openai: { url: 'http://127.0.0.1:8080', apiKey: 'sk', model: 'whisper-1' }, + }); + const [provider, schema] = await service.getProviderSchema(req); + expect(provider).toBe('openai'); + expect(schema.url).toBe('http://127.0.0.1:8080'); + }); + + it('reports "No provider is set" when only allowedAddresses is present', async () => { + const req = buildReq({ allowedAddresses: ['127.0.0.1:8080'] }); + await expect(service.getProviderSchema(req)).rejects.toThrow('No provider is set'); + }); + + it('reports "Multiple providers" when two providers are set even with allowedAddresses', async () => { + const req = buildReq({ + allowedAddresses: ['127.0.0.1:8080'], + openai: { url: 'http://127.0.0.1:8080', apiKey: 'sk', model: 'whisper-1' }, + azureOpenAI: { + instanceName: 'inst', + apiKey: 'sk', + deploymentName: 'dep', + apiVersion: '2024', + }, + }); + await expect(service.getProviderSchema(req)).rejects.toThrow('Multiple providers are set'); + }); +}); + describe('STT audio format validation with MIME normalization', () => { const acceptedFormats = ['flac', 'mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'ogg', 'wav', 'webm']; diff --git a/api/server/services/Files/Audio/TTSService.js b/api/server/services/Files/Audio/TTSService.js index f8f47eda43..58ac157db5 100644 --- a/api/server/services/Files/Audio/TTSService.js +++ b/api/server/services/Files/Audio/TTSService.js @@ -52,7 +52,7 @@ class TTSService { ); } const providers = Object.entries(ttsSchema).filter( - ([, value]) => Object.keys(value).length > 0, + ([key, value]) => key !== 'allowedAddresses' && Object.keys(value).length > 0, ); if (providers.length !== 1) { diff --git a/api/server/services/Files/Audio/TTSService.spec.js b/api/server/services/Files/Audio/TTSService.spec.js index f3b506e3af..1f9fb79dc0 100644 --- a/api/server/services/Files/Audio/TTSService.spec.js +++ b/api/server/services/Files/Audio/TTSService.spec.js @@ -23,7 +23,7 @@ jest.mock('./streamAudio', () => ({ jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); const { resolveConfigSecret } = require('@librechat/api'); -const { TTSService } = require('./TTSService'); +const { TTSService, getProvider } = require('./TTSService'); describe('TTSService provider header construction with an undecryptable apiKey', () => { let service; @@ -70,3 +70,35 @@ describe('TTSService provider header construction with an undecryptable apiKey', expect(headers).not.toHaveProperty('Authorization'); }); }); + +describe('TTSService getProvider detection', () => { + const buildConfig = (tts) => ({ speech: { tts } }); + + it('resolves exactly one provider when allowedAddresses is set alongside it', async () => { + const provider = await getProvider( + buildConfig({ + allowedAddresses: ['localhost:11434'], + localai: { url: 'http://localhost:11434/tts', apiKey: 'sk' }, + }), + ); + expect(provider).toBe('localai'); + }); + + it('reports "No provider is set" when only allowedAddresses is present', async () => { + await expect( + getProvider(buildConfig({ allowedAddresses: ['localhost:11434'] })), + ).rejects.toThrow('No provider is set'); + }); + + it('reports "Multiple providers" when two providers are set even with allowedAddresses', async () => { + await expect( + getProvider( + buildConfig({ + allowedAddresses: ['localhost:11434'], + openai: { url: 'http://localhost:11434', apiKey: 'sk' }, + localai: { url: 'http://localhost:11434/tts', apiKey: 'sk' }, + }), + ), + ).rejects.toThrow('Multiple providers are set'); + }); +}); diff --git a/packages/api/src/files/ocr.ts b/packages/api/src/files/ocr.ts index 02060a858f..b60719d675 100644 --- a/packages/api/src/files/ocr.ts +++ b/packages/api/src/files/ocr.ts @@ -11,5 +11,6 @@ export function loadOCRConfig(config?: TCustomConfig['ocr']): TCustomConfig['ocr baseURL, mistralModel, strategy: config?.strategy ?? OCRStrategy.MISTRAL_OCR, + allowedAddresses: config?.allowedAddresses, }; } diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts index 3b2ae383cb..0b5c3c573e 100644 --- a/packages/data-provider/src/config.spec.ts +++ b/packages/data-provider/src/config.spec.ts @@ -492,6 +492,62 @@ describe('allowedAddressesSchema', () => { }); expect(result.success).toBe(false); }); + + it('accepts the field on speech.stt', () => { + const result = configSchema.safeParse({ + version: '1.0', + speech: { stt: { allowedAddresses: ['127.0.0.1:8080'] } }, + }); + expect(result.success).toBe(true); + }); + + it('accepts the field on speech.tts', () => { + const result = configSchema.safeParse({ + version: '1.0', + speech: { tts: { allowedAddresses: ['localhost:11434', 'ollama.internal:11434'] } }, + }); + expect(result.success).toBe(true); + }); + + it('accepts the field on ocr', () => { + const result = configSchema.safeParse({ + version: '1.0', + ocr: { allowedAddresses: ['10.0.0.5:443'] }, + }); + expect(result.success).toBe(true); + }); + + it('omitting the field on ocr leaves it undefined', () => { + const result = configSchema.safeParse({ version: '1.0', ocr: {} }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.ocr?.allowedAddresses).toBeUndefined(); + } + }); + + it('rejects a public IP at the speech.stt location', () => { + const result = configSchema.safeParse({ + version: '1.0', + speech: { stt: { allowedAddresses: ['8.8.8.8:53'] } }, + }); + expect(result.success).toBe(false); + }); + + it('rejects a bare host at the speech.tts location', () => { + const result = configSchema.safeParse({ + version: '1.0', + speech: { tts: { allowedAddresses: ['localhost'] } }, + }); + expect(result.success).toBe(false); + }); + + it('rejects a CIDR range at the ocr location', () => { + const result = configSchema.safeParse({ + version: '1.0', + ocr: { allowedAddresses: ['10.0.0.0/24'] }, + }); + expect(result.success).toBe(false); + }); }); }); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 38959f5cde..33328ab0f6 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1200,6 +1200,7 @@ const ttsLocalaiSchema = z.object({ }); const ttsSchema = z.object({ + allowedAddresses: allowedAddressesSchema, openai: ttsOpenaiSchema.optional(), azureOpenAI: ttsAzureOpenAISchema.optional(), elevenlabs: ttsElevenLabsSchema.optional(), @@ -1222,6 +1223,7 @@ const sttAzureOpenAISchema = z.object({ }); const sttSchema = z.object({ + allowedAddresses: allowedAddressesSchema, openai: sttOpenaiSchema.optional(), azureOpenAI: sttAzureOpenAISchema.optional(), }); @@ -1788,6 +1790,7 @@ export const webSearchSchema = z.object({ export type TWebSearchConfig = DeepPartial>; export const ocrSchema = z.object({ + allowedAddresses: allowedAddressesSchema, mistralModel: z.string().optional(), apiKey: z.string().optional().default('${OCR_API_KEY}'), apiKeyPreview: apiKeyPreviewSchema, diff --git a/packages/data-schemas/src/app/ocr.spec.ts b/packages/data-schemas/src/app/ocr.spec.ts new file mode 100644 index 0000000000..7c4cccaeb3 --- /dev/null +++ b/packages/data-schemas/src/app/ocr.spec.ts @@ -0,0 +1,27 @@ +import { OCRStrategy } from 'librechat-data-provider'; +import { loadOCRConfig } from './ocr'; + +describe('loadOCRConfig', () => { + it('returns undefined when no config is provided', () => { + expect(loadOCRConfig(undefined)).toBeUndefined(); + }); + + it('preserves allowedAddresses so the exemption survives config load', () => { + const loaded = loadOCRConfig({ + apiKey: '${OCR_API_KEY}', + baseURL: 'https://ocr.internal:8080', + strategy: OCRStrategy.MISTRAL_OCR, + allowedAddresses: ['ocr.internal:8080'], + }); + expect(loaded?.allowedAddresses).toEqual(['ocr.internal:8080']); + }); + + it('leaves allowedAddresses undefined when it is not configured', () => { + const loaded = loadOCRConfig({ + apiKey: 'key', + baseURL: 'https://api.mistral.ai', + strategy: OCRStrategy.MISTRAL_OCR, + }); + expect(loaded?.allowedAddresses).toBeUndefined(); + }); +}); diff --git a/packages/data-schemas/src/app/ocr.ts b/packages/data-schemas/src/app/ocr.ts index 02060a858f..b60719d675 100644 --- a/packages/data-schemas/src/app/ocr.ts +++ b/packages/data-schemas/src/app/ocr.ts @@ -11,5 +11,6 @@ export function loadOCRConfig(config?: TCustomConfig['ocr']): TCustomConfig['ocr baseURL, mistralModel, strategy: config?.strategy ?? OCRStrategy.MISTRAL_OCR, + allowedAddresses: config?.allowedAddresses, }; }