[Feature] UI - Litellm test key audio (#16251)

* Add audio/transcriptions and audio/speech to Test key UI

* Removed unused import in test

* Fixed voice parameter not being passed in
This commit is contained in:
yuneng-jiang 2025-11-04 14:21:26 -08:00 • committed by GitHub
parent 726452dfe8
commit 615f76de88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 676 additions and 34 deletions

View file

@ -0,0 +1,13 @@
import { render } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import AudioRenderer from "./AudioRenderer";
import { MessageType } from "./types";
describe("AudioRenderer", () => {
it("should render the audio renderer", () => {
const { container } = render(
<AudioRenderer message={{ content: "Hello, world!", role: "user", type: "audio" } as unknown as MessageType} />,
);
expect(container).toBeTruthy();
});
});

View file

@ -0,0 +1,23 @@
import React from "react";
import { MessageType } from "./types";
interface AudioRendererProps {
message: MessageType;
}
const AudioRenderer: React.FC<AudioRendererProps> = ({ message }) => {
// Check if this message contains audio
if (!message.isAudio || typeof message.content !== "string") {
return null;
}
return (
<div className="mb-2">
<audio controls src={message.content} className="max-w-full" style={{ maxWidth: "500px" }}>
Your browser does not support the audio element.
</audio>
</div>
);
};
export default AudioRenderer;

View file

@ -1,7 +1,12 @@
import { render } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import ChatUI from "./ChatUI";
// Mock scrollIntoView which is not available in jsdom
beforeEach(() => {
Element.prototype.scrollIntoView = () => {};
});
describe("ChatUI", () => {
it("should render the chat UI", () => {
const { getByText } = render(
@ -15,4 +20,54 @@ describe("ChatUI", () => {
);
expect(getByText("Test Key")).toBeInTheDocument();
});
it("should show the voice selector when the endpoint type is audio_speech", async () => {
const { getByText, container } = render(
<ChatUI
accessToken="1234567890"
token="1234567890"
userRole="user"
userID="1234567890"
disabledPersonalKeyCreation={false}
/>,
);
// Wait for the component to render
await waitFor(() => {
expect(getByText("Test Key")).toBeInTheDocument();
});
// Find the endpoint selector by looking for the "Endpoint Type:" text and its associated Select
const endpointTypeText = getByText("Endpoint Type:");
const selectContainer = endpointTypeText.parentElement;
const selectElement = selectContainer?.querySelector(".ant-select-selector");
expect(selectElement).toBeInTheDocument();
// Click on the select to open the dropdown
if (selectElement) {
fireEvent.mouseDown(selectElement);
}
// Wait for the dropdown to appear and find the audio_speech option
await waitFor(() => {
const audioSpeechOption = screen.getByText("/v1/audio/speech");
expect(audioSpeechOption).toBeInTheDocument();
});
// Click on the audio_speech option
const audioSpeechOption = screen.getByText("/v1/audio/speech");
fireEvent.click(audioSpeechOption);
// Verify the voice selector appears
await waitFor(() => {
expect(getByText("Voice")).toBeInTheDocument();
});
// Verify the voice select component is present
const voiceText = getByText("Voice");
const voiceSelectContainer = voiceText.parentElement;
const voiceSelectElement = voiceSelectContainer?.querySelector(".ant-select");
expect(voiceSelectElement).toBeInTheDocument();
});
});

View file

@ -7,6 +7,8 @@ import { Select, Spin, Typography, Tooltip, Input, Upload, Modal, Button } from
import { makeOpenAIChatCompletionRequest } from "./llm_calls/chat_completion";
import { makeOpenAIImageGenerationRequest } from "./llm_calls/image_generation";
import { makeOpenAIImageEditsRequest } from "./llm_calls/image_edits";
import { makeOpenAIAudioSpeechRequest } from "./llm_calls/audio_speech";
import { makeOpenAIAudioTranscriptionRequest } from "./llm_calls/audio_transcriptions";
import { makeOpenAIResponsesRequest } from "./llm_calls/responses_api";
import { makeAnthropicMessagesRequest } from "./llm_calls/anthropic_messages";
import { fetchAvailableModels, ModelGroup } from "./llm_calls/fetch_models";
@ -29,6 +31,7 @@ import { createMultimodalMessage, createDisplayMessage } from "./ResponsesImageU
import ChatImageUpload from "./ChatImageUpload";
import ChatImageRenderer from "./ChatImageRenderer";
import { createChatMultimodalMessage, createChatDisplayMessage } from "./ChatImageUtils";
import AudioRenderer from "./AudioRenderer";
import SessionManagement from "./SessionManagement";
import MCPEventsDisplay, { MCPEvent } from "./MCPEventsDisplay";
import { SearchResultsDisplay } from "./SearchResultsDisplay";
@ -46,6 +49,7 @@ import {
SafetyOutlined,
PictureOutlined,
CodeOutlined,
SoundOutlined,
ToolOutlined,
FilePdfOutlined,
ArrowUpOutlined,
@ -53,6 +57,7 @@ import {
import NotificationsManager from "../molecules/notifications_manager";
import { makeOpenAIEmbeddingsRequest } from "./llm_calls/embeddings_api";
import { truncateString } from "./chatUtils";
import { OPEN_AI_VOICE_SELECT_OPTIONS, OpenAIVoice } from "./chatConstants";
const { TextArea } = Input;
const { Dragger } = Upload;
@ -122,6 +127,16 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
return [];
}
});
const [selectedVoice, setSelectedVoice] = useState<OpenAIVoice>(() => {
const saved = sessionStorage.getItem("selectedVoice");
if (!saved) return "alloy";
try {
return JSON.parse(saved) as OpenAIVoice;
} catch {
// If stored value is not valid JSON, treat it as a plain string
return saved as OpenAIVoice;
}
});
const [selectedVectorStores, setSelectedVectorStores] = useState<string[]>(() => {
const saved = sessionStorage.getItem("selectedVectorStores");
try {
@ -156,6 +171,7 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
const [responsesImagePreviewUrl, setResponsesImagePreviewUrl] = useState<string | null>(null);
const [chatUploadedImage, setChatUploadedImage] = useState<File | null>(null);
const [chatImagePreviewUrl, setChatImagePreviewUrl] = useState<string | null>(null);
const [uploadedAudio, setUploadedAudio] = useState<File | null>(null);
const [isGetCodeModalVisible, setIsGetCodeModalVisible] = useState(false);
const [generatedCode, setGeneratedCode] = useState("");
const [selectedSdk, setSelectedSdk] = useState<"openai" | "azure">("openai");
@ -200,6 +216,7 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
endpointType,
selectedModel,
selectedSdk,
selectedVoice,
});
setGeneratedCode(code);
}
@ -237,6 +254,7 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
sessionStorage.setItem("selectedVectorStores", JSON.stringify(selectedVectorStores));
sessionStorage.setItem("selectedGuardrails", JSON.stringify(selectedGuardrails));
sessionStorage.setItem("selectedMCPTools", JSON.stringify(selectedMCPTools));
sessionStorage.setItem("selectedVoice", selectedVoice);
if (selectedModel) {
sessionStorage.setItem("selectedModel", selectedModel);
@ -322,7 +340,7 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
setChatHistory((prev) => {
const last = prev[prev.length - 1];
// if the last message is already from this same role, append
if (last && last.role === role && !last.isImage) {
if (last && last.role === role && !last.isImage && !last.isAudio) {
// build a new object, but only set `model` if it wasn't there already
const updated: MessageType = {
...last,
@ -348,7 +366,7 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
setChatHistory((prevHistory) => {
const lastMessage = prevHistory[prevHistory.length - 1];
if (lastMessage && lastMessage.role === "assistant" && !lastMessage.isImage) {
if (lastMessage && lastMessage.role === "assistant" && !lastMessage.isImage && !lastMessage.isAudio) {
return [
...prevHistory.slice(0, prevHistory.length - 1),
{
@ -500,11 +518,15 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
]);
};
const updateAudioUI = (audioUrl: string, model: string) => {
setChatHistory((prevHistory) => [...prevHistory, { role: "assistant", content: audioUrl, model, isAudio: true }]);
};
const updateChatImageUI = (imageUrl: string, model?: string) => {
setChatHistory((prev) => {
const last = prev[prev.length - 1];
// If the last message is from assistant and has content, add image to it
if (last && last.role === "assistant" && !last.isImage) {
if (last && last.role === "assistant" && !last.isImage && !last.isAudio) {
const updated = {
...last,
image: {
@ -602,8 +624,17 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
setChatImagePreviewUrl(null);
};
const handleAudioUpload = (file: File): false => {
setUploadedAudio(file);
return false; // Prevent default upload behavior
};
const handleRemoveAudio = () => {
setUploadedAudio(null);
};
const handleSendMessage = async () => {
if (inputMessage.trim() === "") return;
if (inputMessage.trim() === "" && endpointType !== EndpointType.TRANSCRIPTION) return;
// For image edits, require both image and prompt
if (endpointType === EndpointType.IMAGE_EDITS && uploadedImages.length === 0) {
@ -611,6 +642,12 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
return;
}
// For audio transcriptions, require audio file
if (endpointType === EndpointType.TRANSCRIPTION && !uploadedAudio) {
NotificationsManager.fromBackend("Please upload an audio file for transcription");
return;
}
if (!token || !userRole || !userID) {
return;
}
@ -672,6 +709,12 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
chatImagePreviewUrl || undefined,
chatUploadedImage.name,
);
} else if (endpointType === EndpointType.TRANSCRIPTION && uploadedAudio) {
// For audio transcription, show the audio file name and optional prompt
const audioMessage = inputMessage
? `🎵 Audio file: ${uploadedAudio.name}\nPrompt: ${inputMessage}`
: `🎵 Audio file: ${uploadedAudio.name}`;
displayMessage = createDisplayMessage(audioMessage, false);
} else {
displayMessage = createDisplayMessage(inputMessage, false);
}
@ -687,7 +730,7 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
// For chat completions, we preserve the multimodal content structure
const apiChatHistory = [
...chatHistory
.filter((msg) => !msg.isImage)
.filter((msg) => !msg.isImage && !msg.isAudio)
.map(({ role, content }) => ({
role,
content: typeof content === "string" ? content : "",
@ -722,6 +765,17 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
selectedTags,
signal,
);
} else if (endpointType === EndpointType.SPEECH) {
// For audio speech
await makeOpenAIAudioSpeechRequest(
inputMessage,
selectedVoice,
(audioUrl, model) => updateAudioUI(audioUrl, model),
selectedModel || "",
effectiveApiKey,
selectedTags,
signal,
);
} else if (endpointType === EndpointType.IMAGE_EDITS) {
// For image edits
if (uploadedImages.length > 0) {
@ -745,7 +799,9 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
} else {
// When using UI session management or starting new API session, send full history
apiChatHistory = [
...chatHistory.filter((msg) => !msg.isImage).map(({ role, content }) => ({ role, content })),
...chatHistory
.filter((msg) => !msg.isImage && !msg.isAudio)
.map(({ role, content }) => ({ role, content })),
newUserMessage,
];
}
@ -770,7 +826,9 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
);
} else if (endpointType === EndpointType.ANTHROPIC_MESSAGES) {
const apiChatHistory = [
...chatHistory.filter((msg) => !msg.isImage).map(({ role, content }) => ({ role, content })),
...chatHistory
.filter((msg) => !msg.isImage && !msg.isAudio)
.map(({ role, content }) => ({ role, content })),
newUserMessage,
];
@ -797,6 +855,18 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
effectiveApiKey,
selectedTags,
);
} else if (endpointType === EndpointType.TRANSCRIPTION) {
// For audio transcriptions
if (uploadedAudio) {
await makeOpenAIAudioTranscriptionRequest(
uploadedAudio,
(transcription, model) => updateTextUI("assistant", transcription, model),
selectedModel,
effectiveApiKey,
selectedTags,
signal,
);
}
}
}
} catch (error) {
@ -821,12 +891,23 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
if (endpointType === EndpointType.CHAT && chatUploadedImage) {
handleRemoveChatImage();
}
// Clear audio after successful request for transcription
if (endpointType === EndpointType.TRANSCRIPTION && uploadedAudio) {
handleRemoveAudio();
}
}
setInputMessage("");
};
const clearChatHistory = () => {
// Clean up audio object URLs before clearing history
chatHistory.forEach((message) => {
if (message.isAudio && typeof message.content === "string") {
URL.revokeObjectURL(message.content);
}
});
setChatHistory([]);
setMessageTraceId(null);
setResponsesSessionId(null); // Clear responses session ID
@ -834,6 +915,7 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
handleRemoveAllImages(); // Clear any uploaded images for image edits
handleRemoveResponsesImage(); // Clear any uploaded images for responses
handleRemoveChatImage(); // Clear any uploaded images for chat completions
handleRemoveAudio(); // Clear any uploaded audio for transcription
sessionStorage.removeItem("chatHistory");
sessionStorage.removeItem("messageTraceId");
sessionStorage.removeItem("responsesSessionId");
@ -946,6 +1028,26 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
className="mb-4"
/>
{/* Voice Selector for Speech Endpoint */}
{endpointType === EndpointType.SPEECH && (
<div className="mb-4">
<Text className="font-medium block mb-2 text-gray-700 flex items-center">
<SoundOutlined className="mr-2" />
Voice
</Text>
<Select
value={selectedVoice}
onChange={(value) => {
setSelectedVoice(value);
sessionStorage.setItem("selectedVoice", value);
}}
style={{ width: "100%" }}
className="rounded-md"
options={OPEN_AI_VOICE_SELECT_OPTIONS}
/>
</div>
)}
{/* Session Management Component */}
<SessionManagement
endpointType={endpointType}
@ -1086,7 +1188,7 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
{chatHistory.length === 0 && (
<div className="h-full flex flex-col items-center justify-center text-gray-400">
<RobotOutlined style={{ fontSize: "48px", marginBottom: "16px" }} />
<Text>Start a conversation or generate an image</Text>
<Text>Start a conversation, generate an image, or handle audio</Text>
</div>
)}
@ -1154,6 +1256,8 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
className="max-w-full rounded-md border border-gray-200 shadow-sm"
style={{ maxHeight: "500px" }}
/>
) : message.isAudio ? (
<AudioRenderer message={message} />
) : (
<>
{/* Show attached image for user messages based on current endpoint */}
@ -1274,12 +1378,7 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
{endpointType === EndpointType.IMAGE_EDITS && (
<div className="mb-4">
{uploadedImages.length === 0 ? (
<Dragger
beforeUpload={handleImageUpload}
accept="image/*"
showUploadList={false}
className="border-dashed border-2 border-gray-300 rounded-lg p-4"
>
<Dragger beforeUpload={handleImageUpload} accept="image/*" showUploadList={false}>
<p className="ant-upload-drag-icon">
<PictureOutlined style={{ fontSize: "24px", color: "#666" }} />
</p>
@ -1330,6 +1429,44 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
)}
</div>
)}
{/* Audio Upload Section for Transcriptions */}
{endpointType === EndpointType.TRANSCRIPTION && (
<div className="mb-4">
{!uploadedAudio ? (
<Dragger
beforeUpload={handleAudioUpload}
accept="audio/*,.mp3,.mp4,.mpeg,.mpga,.m4a,.wav,.webm"
showUploadList={false}
>
<p className="ant-upload-drag-icon">
<SoundOutlined style={{ fontSize: "24px", color: "#666" }} />
</p>
<p className="ant-upload-text text-sm">Click or drag audio file to upload</p>
<p className="ant-upload-hint text-xs text-gray-500">
Support for MP3, MP4, MPEG, MPGA, M4A, WAV, WEBM formats. Max file size: 25 MB.
</p>
</Dragger>
) : (
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex items-center gap-2 flex-1">
<SoundOutlined style={{ fontSize: "20px", color: "#666" }} />
<span className="text-sm font-medium">{uploadedAudio.name}</span>
<span className="text-xs text-gray-500">
({(uploadedAudio.size / 1024 / 1024).toFixed(2)} MB)
</span>
</div>
<button
className="bg-white shadow-sm border border-gray-200 rounded px-2 py-1 text-red-500 hover:bg-red-50 text-xs"
onClick={handleRemoveAudio}
>
<DeleteOutlined /> Remove
</button>
</div>
)}
</div>
)}
{/* Show file previews above input when files are uploaded */}
{endpointType === EndpointType.RESPONSES && responsesUploadedImage && (
<div className="mb-2">
@ -1430,7 +1567,11 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
? "Type your message... (Shift+Enter for new line)"
: endpointType === EndpointType.IMAGE_EDITS
? "Describe how you want to edit the image..."
: "Describe the image you want to generate..."
: endpointType === EndpointType.SPEECH
? "Enter text to convert to speech..."
: endpointType === EndpointType.TRANSCRIPTION
? "Optional: Add context or prompt for transcription..."
: "Describe the image you want to generate..."
}
disabled={isLoading}
className="flex-1"
@ -1449,7 +1590,9 @@ const ChatUI: React.FC<ChatUIProps> = ({ accessToken, token, userRole, userID, d
{/* Right: send button - matching blue theme */}
<TremorButton
onClick={handleSendMessage}
disabled={isLoading || !inputMessage.trim()}
disabled={
isLoading || (endpointType === EndpointType.TRANSCRIPTION ? !uploadedAudio : !inputMessage.trim())
}
className="flex-shrink-0 ml-2 !w-8 !h-8 !min-w-8 !p-0 !rounded-full !bg-blue-600 hover:!bg-blue-700 disabled:!bg-gray-300 !border-none !text-white disabled:!text-gray-500 !flex !items-center !justify-center"
>
<ArrowUpOutlined style={{ fontSize: "14px" }} />

View file

@ -17,6 +17,7 @@ describe("CodeSnippets", () => {
selectedGuardrails: [],
selectedMCPTools: [],
selectedSdk: "openai",
selectedVoice: "alloy",
});
expect(code).toContain("text-embedding-3-small");
expect(code).toContain("Hello, world!");

View file

@ -17,6 +17,7 @@ interface GenerateCodeParams {
selectedVectorStores: string[];
selectedGuardrails: string[];
selectedMCPTools: string[];
selectedVoice: string;
endpointType: string;
selectedModel: string | undefined;
selectedSdk: "openai" | "azure";
@ -33,6 +34,7 @@ export const generateCodeSnippet = (params: GenerateCodeParams): string => {
selectedVectorStores,
selectedGuardrails,
selectedMCPTools,
selectedVoice,
endpointType,
selectedModel,
selectedSdk,
@ -502,6 +504,45 @@ response = client.embeddings.create(
)
print(response.data[0].embedding)
`;
break;
case EndpointType.TRANSCRIPTION:
endpointSpecificCode = `
# Open the audio file
audio_file = open("path/to/your/audio/file.mp3", "rb")
# Make the transcription request
response = client.audio.transcriptions.create(
model="${modelNameForCode}",
file=audio_file${inputMessage ? `,\n prompt="${inputMessage.replace(/"/g, '\\"')}"` : ""}
)
print(response.text)
`;
break;
case EndpointType.SPEECH:
endpointSpecificCode = `
# Make the text-to-speech request
response = client.audio.speech.create(
model="${modelNameForCode}",
input="${inputMessage || "Your text to convert to speech here"}",
voice="${selectedVoice}" # Options: alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer
)
# Save the audio to a file
output_filename = "output_speech.mp3"
response.stream_to_file(output_filename)
print(f"Audio saved to {output_filename}")
# Optional: Customize response format and speed
# response = client.audio.speech.create(
# model="${modelNameForCode}",
# input="${inputMessage || "Your text to convert to speech here"}",
# voice="alloy",
# response_format="mp3", # Options: mp3, opus, aac, flac, wav, pcm
# speed=1.0 # Range: 0.25 to 4.0
# )
# response.stream_to_file("output_speech.mp3")
`;
break;
default:

View file

@ -1,9 +1,10 @@
import { render, waitFor } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import EndpointSelector, { endpointOptions } from "./EndpointSelector";
import EndpointSelector from "./EndpointSelector";
import { ENDPOINT_OPTIONS } from "./chatConstants";
describe("EndpointSelector", () => {
Object.values(endpointOptions).forEach((endpointType) => {
Object.values(ENDPOINT_OPTIONS).forEach((endpointType) => {
it(`should render the endpoint selector for ${endpointType.value}`, async () => {
const { getByText } = render(<EndpointSelector endpointType={endpointType.value} onEndpointChange={() => {}} />);
await waitFor(() => {

View file

@ -1,7 +1,7 @@
import React from "react";
import { Select } from "antd";
import { Text } from "@tremor/react";
import { EndpointType } from "./mode_endpoint_mapping";
import { ENDPOINT_OPTIONS } from "./chatConstants";
interface EndpointSelectorProps {
endpointType: string; // Accept string to avoid type conflicts
@ -9,18 +9,6 @@ interface EndpointSelectorProps {
className?: string;
}
/**
* A reusable component for selecting API endpoints
*/
export const endpointOptions = [
{ value: EndpointType.CHAT, label: "/v1/chat/completions" },
{ value: EndpointType.RESPONSES, label: "/v1/responses" },
{ value: EndpointType.ANTHROPIC_MESSAGES, label: "/v1/messages" },
{ value: EndpointType.IMAGE, label: "/v1/images/generations" },
{ value: EndpointType.IMAGE_EDITS, label: "/v1/images/edits" },
{ value: EndpointType.EMBEDDINGS, label: "/v1/embeddings" },
];
const EndpointSelector: React.FC<EndpointSelectorProps> = ({ endpointType, onEndpointChange, className }) => {
return (
<div className={className}>
@ -30,7 +18,7 @@ const EndpointSelector: React.FC<EndpointSelectorProps> = ({ endpointType, onEnd
value={endpointType}
style={{ width: "100%" }}
onChange={onEndpointChange}
options={endpointOptions}
options={ENDPOINT_OPTIONS}
className="rounded-md"
/>
</div>

View file

@ -0,0 +1,45 @@
import { EndpointType } from "./mode_endpoint_mapping";
export const OPEN_AI_VOICES = {
ALLOY: "alloy",
ASH: "ash",
BALAD: "ballad",
CORAL: "coral",
ECHO: "echo",
FABLE: "fable",
NOVA: "nova",
ONYX: "onyx",
SAGE: "sage",
SHIMMER: "shimmer",
} as const;
export type OpenAIVoice = (typeof OPEN_AI_VOICES)[keyof typeof OPEN_AI_VOICES];
export const OPEN_AI_VOICE_LABELS = {
ALLOY: "Alloy - Professional and confident",
ASH: "Ash - Casual and relaxed",
BALAD: "Ballad - Smooth and melodic",
CORAL: "Coral - Warm and engaging",
ECHO: "Echo - Friendly and conversational",
FABLE: "Fable - Wise and measured",
NOVA: "Nova - Friendly and conversational",
ONYX: "Onyx - Deep and authoritative",
SAGE: "Sage - Wise and measured",
SHIMMER: "Shimmer - Bright and cheerful",
};
export const OPEN_AI_VOICE_SELECT_OPTIONS = Object.entries(OPEN_AI_VOICES).map(([key, voice]) => ({
value: voice,
label: OPEN_AI_VOICE_LABELS[key as keyof typeof OPEN_AI_VOICE_LABELS],
}));
export const ENDPOINT_OPTIONS = [
{ value: EndpointType.CHAT, label: "/v1/chat/completions" },
{ value: EndpointType.RESPONSES, label: "/v1/responses" },
{ value: EndpointType.ANTHROPIC_MESSAGES, label: "/v1/messages" },
{ value: EndpointType.IMAGE, label: "/v1/images/generations" },
{ value: EndpointType.IMAGE_EDITS, label: "/v1/images/edits" },
{ value: EndpointType.EMBEDDINGS, label: "/v1/embeddings" },
{ value: EndpointType.SPEECH, label: "/v1/audio/speech" },
{ value: EndpointType.TRANSCRIPTION, label: "/v1/audio/transcriptions" },
];

View file

@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { makeOpenAIAudioSpeechRequest } from "./audio_speech";
import OpenAI from "openai";
vi.mock("openai");
// Mock URL.createObjectURL
global.URL.createObjectURL = vi.fn(() => "blob:mock-audio-url");
describe("audio_speech", () => {
const mockCreate = vi.fn();
const mockUpdateUI = vi.fn();
const mockBlob = new Blob(["mock audio data"], { type: "audio/mpeg" });
beforeEach(() => {
// Mock the response structure from OpenAI audio speech API
mockCreate.mockResolvedValue({
blob: vi.fn().mockResolvedValue(mockBlob),
});
// Mock the OpenAI constructor and its methods
(OpenAI as any).mockImplementation(() => ({
audio: {
speech: {
create: mockCreate,
},
},
}));
});
afterEach(() => {
vi.clearAllMocks();
});
it("should make a request to the audio speech API with basic parameters", async () => {
await makeOpenAIAudioSpeechRequest("Hello, world!", "alloy", mockUpdateUI, "tts-1", "sk-1234567890", []);
expect(mockCreate).toHaveBeenCalledWith(
{
model: "tts-1",
input: "Hello, world!",
voice: "alloy",
},
{ signal: undefined },
);
expect(mockUpdateUI).toHaveBeenCalledWith("blob:mock-audio-url", "tts-1");
});
it("should include optional parameters when provided", async () => {
const signal = new AbortController().signal;
await makeOpenAIAudioSpeechRequest(
"Test input",
"nova",
mockUpdateUI,
"tts-1-hd",
"sk-1234567890",
["tag1", "tag2"],
signal,
"mp3",
1.5,
);
expect(mockCreate).toHaveBeenCalledWith(
{
model: "tts-1-hd",
input: "Test input",
voice: "nova",
response_format: "mp3",
speed: 1.5,
},
{ signal },
);
expect(mockUpdateUI).toHaveBeenCalledWith("blob:mock-audio-url", "tts-1-hd");
});
it("should handle errors gracefully", async () => {
const mockError = new Error("API Error");
mockCreate.mockRejectedValue(mockError);
await expect(
makeOpenAIAudioSpeechRequest("Hello, world!", "alloy", mockUpdateUI, "tts-1", "sk-1234567890", []),
).rejects.toThrow("API Error");
expect(mockUpdateUI).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,57 @@
import openai from "openai";
import { getProxyBaseUrl } from "@/components/networking";
import NotificationManager from "@/components/molecules/notifications_manager";
import type { OpenAIVoice } from "../chatConstants";
export async function makeOpenAIAudioSpeechRequest(
input: string,
voice: OpenAIVoice,
updateUI: (audioUrl: string, model: string) => void,
selectedModel: string,
accessToken: string,
tags?: string[],
signal?: AbortSignal,
responseFormat?: string,
speed?: number,
) {
// base url should be the current base_url
const isLocal = process.env.NODE_ENV === "development";
if (isLocal !== true) {
console.log = function () {};
}
console.log("isLocal:", isLocal);
const proxyBaseUrl = getProxyBaseUrl();
const client = new openai.OpenAI({
apiKey: accessToken,
baseURL: proxyBaseUrl,
dangerouslyAllowBrowser: true,
defaultHeaders: tags && tags.length > 0 ? { "x-litellm-tags": tags.join(",") } : undefined,
});
try {
const response = await client.audio.speech.create(
{
model: selectedModel,
input: input,
voice,
...(responseFormat ? { response_format: responseFormat as any } : {}),
...(speed ? { speed: speed } : {}),
},
{ signal },
);
// Convert the response to a blob and create an object URL
// The response from OpenAI SDK in the browser is a Response object with a blob() method
const blob = await response.blob();
const audioUrl = URL.createObjectURL(blob);
updateUI(audioUrl, selectedModel);
} catch (error) {
if (signal?.aborted) {
console.log("Audio speech request was cancelled");
} else {
NotificationManager.fromBackend(`Error occurred while generating speech. Please try again. Error: ${error}`);
}
throw error; // Re-throw to allow the caller to handle the error
}
}

View file

@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { makeOpenAIAudioTranscriptionRequest } from "./audio_transcriptions";
import OpenAI from "openai";
vi.mock("openai");
describe("audio_transcription", () => {
const mockCreate = vi.fn();
const mockUpdateUI = vi.fn();
beforeEach(() => {
// Mock the response structure from OpenAI audio transcription API
mockCreate.mockResolvedValue({
text: "This is the transcribed text from the audio file.",
});
// Mock the OpenAI constructor and its methods
(OpenAI as any).mockImplementation(() => ({
audio: {
transcriptions: {
create: mockCreate,
},
},
}));
});
afterEach(() => {
vi.clearAllMocks();
});
it("should make a request to the audio transcription API with basic parameters", async () => {
const mockFile = new File(["audio data"], "test.wav", {
type: "audio/wav",
});
await makeOpenAIAudioTranscriptionRequest(mockFile, mockUpdateUI, "whisper-1", "sk-1234567890", []);
expect(mockCreate).toHaveBeenCalledWith(
{
model: "whisper-1",
file: mockFile,
},
{ signal: undefined },
);
expect(mockUpdateUI).toHaveBeenCalledWith("This is the transcribed text from the audio file.", "whisper-1");
});
it("should include optional parameters when provided", async () => {
const mockFile = new File(["audio data"], "test.mp3", {
type: "audio/mpeg",
});
const signal = new AbortController().signal;
await makeOpenAIAudioTranscriptionRequest(
mockFile,
mockUpdateUI,
"whisper-1",
"sk-1234567890",
["tag1", "tag2"],
signal,
"en",
"This is a prompt",
"json",
0.5,
);
expect(mockCreate).toHaveBeenCalledWith(
{
model: "whisper-1",
file: mockFile,
language: "en",
prompt: "This is a prompt",
response_format: "json",
temperature: 0.5,
},
{ signal },
);
expect(mockUpdateUI).toHaveBeenCalledWith("This is the transcribed text from the audio file.", "whisper-1");
});
it("should handle errors gracefully", async () => {
const mockError = new Error("API Error");
mockCreate.mockRejectedValue(mockError);
const mockFile = new File(["audio data"], "test.wav", {
type: "audio/wav",
});
await expect(
makeOpenAIAudioTranscriptionRequest(mockFile, mockUpdateUI, "whisper-1", "sk-1234567890", []),
).rejects.toThrow("API Error");
expect(mockUpdateUI).not.toHaveBeenCalled();
});
it("should handle missing text in response", async () => {
mockCreate.mockResolvedValue({});
const mockFile = new File(["audio data"], "test.wav", {
type: "audio/wav",
});
await expect(
makeOpenAIAudioTranscriptionRequest(mockFile, mockUpdateUI, "whisper-1", "sk-1234567890", []),
).rejects.toThrow("No transcription text in response");
expect(mockUpdateUI).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,74 @@
import openai from "openai";
import { getProxyBaseUrl } from "@/components/networking";
import NotificationManager from "@/components/molecules/notifications_manager";
export async function makeOpenAIAudioTranscriptionRequest(
audioFile: File,
updateUI: (transcription: string, model: string) => void,
selectedModel: string,
accessToken: string,
tags?: string[],
signal?: AbortSignal,
language?: string,
prompt?: string,
responseFormat?: string,
temperature?: number,
) {
// base url should be the current base_url
const isLocal = process.env.NODE_ENV === "development";
if (isLocal !== true) {
console.log = function () {};
}
console.log("isLocal:", isLocal);
const proxyBaseUrl = getProxyBaseUrl();
const client = new openai.OpenAI({
apiKey: accessToken,
baseURL: proxyBaseUrl,
dangerouslyAllowBrowser: true,
defaultHeaders: tags && tags.length > 0 ? { "x-litellm-tags": tags.join(",") } : undefined,
});
try {
console.log("Processing audio file for transcription:", audioFile.name);
const response = await client.audio.transcriptions.create(
{
model: selectedModel,
file: audioFile,
...(language ? { language: language } : {}),
...(prompt ? { prompt: prompt } : {}),
...(responseFormat ? { response_format: responseFormat as any } : {}),
...(temperature !== undefined ? { temperature: temperature } : {}),
},
{ signal },
);
console.log("Transcription response:", response);
// The response is a transcription object with a text field
if (response && response.text) {
updateUI(response.text, selectedModel);
NotificationManager.success(`Audio transcribed successfully`);
} else {
throw new Error("No transcription text in response");
}
} catch (error: any) {
console.error("Error making audio transcription request:", error);
if (signal?.aborted) {
console.log("Audio transcription request was cancelled");
} else {
let errorMessage = "Failed to transcribe audio";
if (error?.error?.message) {
errorMessage = error.error.message;
} else if (error?.message) {
errorMessage = error.message;
}
NotificationManager.fromBackend(`Audio transcription failed: ${errorMessage}`);
}
throw error; // Re-throw to allow the caller to handle the error
}
}

View file

@ -2,6 +2,8 @@
// Define an enum for the modes as returned in model_info
export enum ModelMode {
AUDIO_SPEECH = "audio_speech",
AUDIO_TRANSCRIPTION = "audio_transcription",
IMAGE_GENERATION = "image_generation",
CHAT = "chat",
RESPONSES = "responses",
@ -18,6 +20,8 @@ export enum EndpointType {
IMAGE_EDITS = "image_edits",
ANTHROPIC_MESSAGES = "anthropic_messages",
EMBEDDINGS = "embeddings",
SPEECH = "speech",
TRANSCRIPTION = "transcription",
// add additional endpoint types if required
}
@ -28,6 +32,8 @@ export const litellmModeMapping: Record<ModelMode, EndpointType> = {
[ModelMode.RESPONSES]: EndpointType.RESPONSES,
[ModelMode.IMAGE_EDITS]: EndpointType.IMAGE_EDITS,
[ModelMode.ANTHROPIC_MESSAGES]: EndpointType.ANTHROPIC_MESSAGES,
[ModelMode.AUDIO_SPEECH]: EndpointType.SPEECH,
[ModelMode.AUDIO_TRANSCRIPTION]: EndpointType.TRANSCRIPTION,
};
export const getEndpointType = (mode: string): EndpointType => {

View file

@ -75,6 +75,7 @@ export interface MessageType {
content: string | MultimodalContent[];
model?: string;
isImage?: boolean;
isAudio?: boolean;
reasoningContent?: string;
timeToFirstToken?: number;
usage?: {