Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions api/server/services/Files/Audio/STTService.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,15 @@ class STTService {
}

const acceptedFormats = ['flac', 'mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'ogg', 'wav', 'webm'];
const fileFormat = audioFile.mimetype.split('/')[1];
if (!acceptedFormats.includes(fileFormat)) {
throw new Error(`The audio file format ${fileFormat} is not accepted`);
const [mimePrefix, rawFormat = ''] = audioFile.mimetype.split('/');
const isAudioMime = mimePrefix === 'audio' || mimePrefix === 'video';
const isKnownMime = audioFile.mimetype in MIME_TO_EXTENSION_MAP;
const normalizedFormat = isKnownMime ? MIME_TO_EXTENSION_MAP[audioFile.mimetype] : null;
if (
!acceptedFormats.includes(normalizedFormat) &&
!(isAudioMime && acceptedFormats.includes(rawFormat))
) {
throw new Error(`The audio file format ${rawFormat} is not accepted`);

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thrown error now always interpolates rawFormat. For some invalid inputs rawFormat can be empty or misleading (e.g., malformed MIME strings without a subtype), which makes debugging harder. Consider including the full audioFile.mimetype and/or the computed normalizedFormat in the error message so logs clearly show what was rejected.

Suggested change
throw new Error(`The audio file format ${rawFormat} is not accepted`);
throw new Error(
`The audio file format is not accepted (mimetype: "${audioFile.mimetype}", raw format: "${rawFormat}", normalized format: "${normalizedFormat}")`,
);

Copilot uses AI. Check for mistakes.
}

const formData = new FormData();
Expand Down Expand Up @@ -377,4 +383,4 @@ async function speechToText(req, res) {
await sttService.processSpeechToText(req, res);
}

module.exports = { STTService, speechToText };
module.exports = { STTService, speechToText, getFileExtensionFromMime, MIME_TO_EXTENSION_MAP };

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MIME_TO_EXTENSION_MAP is now exported, which makes it part of the module’s public surface and allows external code/tests to mutate it at runtime. To prevent hard-to-debug side effects, consider freezing the map (e.g., Object.freeze) or exporting a defensive copy so consumers can’t modify the internal lookup table.

Copilot uses AI. Check for mistakes.
113 changes: 113 additions & 0 deletions api/server/services/Files/Audio/STTService.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Mock all external dependencies so we can test getFileExtensionFromMime in isolation
jest.mock('axios');
jest.mock('form-data');
jest.mock('https-proxy-agent');
jest.mock('@librechat/data-schemas', () => ({ logger: { warn: jest.fn(), error: jest.fn() } }));
jest.mock('@librechat/api', () => ({ genAzureEndpoint: jest.fn(), logAxiosError: jest.fn() }));
jest.mock('librechat-data-provider', () => ({
extractEnvVariable: jest.fn(),
STTProviders: {},
}));
jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() }));

const { getFileExtensionFromMime, MIME_TO_EXTENSION_MAP } = require('./STTService');

describe('getFileExtensionFromMime', () => {
it('should normalize audio/x-m4a to m4a', () => {
expect(getFileExtensionFromMime('audio/x-m4a')).toBe('m4a');
});

it('should normalize audio/mp4 to m4a', () => {
expect(getFileExtensionFromMime('audio/mp4')).toBe('m4a');
});

it('should normalize audio/x-wav to wav', () => {
expect(getFileExtensionFromMime('audio/x-wav')).toBe('wav');
});

it('should normalize audio/x-flac to flac', () => {
expect(getFileExtensionFromMime('audio/x-flac')).toBe('flac');
});

it('should normalize audio/mpeg to mp3', () => {
expect(getFileExtensionFromMime('audio/mpeg')).toBe('mp3');
});

it('should return webm for audio/webm', () => {
expect(getFileExtensionFromMime('audio/webm')).toBe('webm');
});

it('should return ogg for audio/ogg', () => {
expect(getFileExtensionFromMime('audio/ogg')).toBe('ogg');
});

it('should fall back to webm for unknown MIME types', () => {
expect(getFileExtensionFromMime('audio/somethingelse')).toBe('webm');
});

it('should return webm for null/undefined input', () => {
expect(getFileExtensionFromMime(null)).toBe('webm');
expect(getFileExtensionFromMime(undefined)).toBe('webm');
});
});

describe('STT audio format validation with MIME normalization', () => {
const acceptedFormats = ['flac', 'mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'ogg', 'wav', 'webm'];

/**
* Mirrors the format validation logic in azureOpenAIProvider.
* Only uses MIME_TO_EXTENSION_MAP for normalization so unknown audio
* subtypes are not silently accepted via the webm default fallback.
* Raw subtype matching is gated on audio/video prefix to prevent
* non-audio types like text/webm from passing.
*/
function isFormatAccepted(mimetype) {
const [mimePrefix, rawFormat = ''] = mimetype.split('/');
const isAudioMime = mimePrefix === 'audio' || mimePrefix === 'video';
const isKnownMime = mimetype in MIME_TO_EXTENSION_MAP;
const normalizedFormat = isKnownMime ? MIME_TO_EXTENSION_MAP[mimetype] : null;
return (
acceptedFormats.includes(normalizedFormat) ||
(isAudioMime && acceptedFormats.includes(rawFormat))
);
}
Comment on lines +64 to +73

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new tests validate a local isFormatAccepted() helper that re-implements the provider’s MIME/format check, but they never call STTService.azureOpenAIProvider() (the code changed in this PR). This can let the test suite pass even if the real provider logic diverges (e.g., acceptedFormats list or normalization rules change). Consider exercising azureOpenAIProvider() directly with mocked sttSchema/genAzureEndpoint and asserting it throws/doesn’t throw for the same mimetypes instead of duplicating the logic in the test.

Copilot uses AI. Check for mistakes.

it('should accept audio/x-m4a (browser MIME for .m4a files)', () => {
expect(isFormatAccepted('audio/x-m4a')).toBe(true);
});

it('should accept audio/x-wav', () => {
expect(isFormatAccepted('audio/x-wav')).toBe(true);
});

it('should accept audio/x-flac', () => {
expect(isFormatAccepted('audio/x-flac')).toBe(true);
});

it('should accept standard formats directly', () => {
expect(isFormatAccepted('audio/mpeg')).toBe(true);
expect(isFormatAccepted('audio/wav')).toBe(true);
expect(isFormatAccepted('audio/ogg')).toBe(true);
expect(isFormatAccepted('audio/webm')).toBe(true);
expect(isFormatAccepted('audio/flac')).toBe(true);
expect(isFormatAccepted('audio/mp3')).toBe(true);
expect(isFormatAccepted('audio/mp4')).toBe(true);
expect(isFormatAccepted('audio/mpga')).toBe(true);
});

it('should reject unknown audio subtypes', () => {
expect(isFormatAccepted('audio/aac')).toBe(false);
expect(isFormatAccepted('audio/somethingelse')).toBe(false);
expect(isFormatAccepted('video/unknown')).toBe(false);
});

it('should accept application/ogg (valid Ogg container MIME type in the map)', () => {
expect(isFormatAccepted('application/ogg')).toBe(true);
});

it('should reject non-audio types even if subtype matches an accepted format', () => {
expect(isFormatAccepted('text/webm')).toBe(false);
expect(isFormatAccepted('text/plain')).toBe(false);
expect(isFormatAccepted('application/json')).toBe(false);
});
});
Loading