Connect your own LLM service

Updated

Integrate a custom LLM into Agora's Conversational AI Engine.

In Conversational AI Engine interaction scenarios, your use case may require a custom large language model (Custom LLM). This document explains how to integrate a custom LLM into Agora's Conversational AI Engine.

Understand the tech

Agora's Conversational AI Engine interacts with LLM services using the OpenAI API protocol. To integrate a custom LLM, you need to provide an HTTP service compatible with the OpenAI API, capable of handling requests and responses in the OpenAI API format.

This approach enables you to implement additional custom functionalities, including but not limited to:

  • Retrieval-Augmented Generation (RAG): Allows the model to retrieve information from a specific knowledge base.
  • Multimodal Capabilities: Enables the model to generate output in both text and audio formats.
  • Tool Invocation: Allows the model to call external tools.
  • Function Calling: Enables the model to return structured data in the form of function calls.

Prerequisites

  • Implemented the basic logic for interacting with a conversational AI agent by following the quickstart.
  • Set up access to a custom LLM service.
  • Prepared a vector database or retrieval system if using Retrieval-Augmented Generation (RAG).

Implementation

Take the following steps to integrate your custom LLM into Agora's Conversational AI Engine.

Create an OpenAI API-compatible service

To integrate successfully with Agora's Conversational AI Engine, your custom LLM service must provide an interface compatible with the OpenAI Chat Completions API. The key requirements include:

  • Endpoint: A request-handling endpoint, such as https://your-custom-llm-service/chat/completions.
  • Request format: Must accept request parameters adhering to the OpenAI API protocol.
  • Response format: Should return OpenAI API-compatible responses and support the Server-Sent Events (SSE) standard for streaming.

The following example demonstrates how to implement an OpenAI API-compliant interface:

class TextContent(BaseModel):
    type: str = "text"
    text: str

class ImageContent(BaseModel):
    type: str = "image"
    image_url: HttpUrl

class AudioContent(BaseModel):
    type: str = "input_audio"
    input_audio: Dict[str, str]

class ToolFunction(BaseModel):
    name: str
    description: Optional[str]
    parameters: Optional[Dict]
    strict: bool = False

class Tool(BaseModel):
    type: str = "function"
    function: ToolFunction

class ToolChoice(BaseModel):
    type: str = "function"
    function: Optional[Dict]

class ResponseFormat(BaseModel):
    type: str = "json_schema"
    json_schema: Optional[Dict[str, str]]

class SystemMessage(BaseModel):
    role: str = "system"
    content: Union[str, List[str]]

class UserMessage(BaseModel):
    role: str = "user"
    content: Union[str, List[Union[TextContent, ImageContent, AudioContent]]]

class AssistantMessage(BaseModel):
    role: str = "assistant"
    content: Union[str, List[TextContent]] = None
    audio: Optional[Dict[str, str]] = None
    tool_calls: Optional[List[Dict]] = None

class ToolMessage(BaseModel):
    role: str = "tool"
    content: Union[str, List[str]]
    tool_call_id: str

# Define the complete request format
class ChatCompletionRequest(BaseModel):
    context: Optional[Dict] = None  # Context information
    model: Optional[str] = None  # Model name being used
    messages: List[Union[SystemMessage, UserMessage, AssistantMessage, ToolMessage]]  # List of messages
    response_format: Optional[ResponseFormat] = None  # Response format
    modalities: List[str] = ["text"]  # Default modality is text
    audio: Optional[Dict[str, str]] = None  # Assistant's audio response
    tools: Optional[List[Tool]] = None  # List of tools
    tool_choice: Optional[Union[str, ToolChoice]] = "auto"  # Tool selection
    parallel_tool_calls: bool = True  # Whether to call tools in parallel
    stream: bool = True  # Default to streaming response
    stream_options: Optional[Dict] = None  # Streaming options

@app.post("/chat/completions")
async def create_chat_completion(request: ChatCompletionRequest):
    try:
        logger.info(f"Received request: {request.model_dump_json()}")
        client = AsyncOpenAI(api_key=os.getenv("YOUR_LLM_API_KEY"))
        response = await client.chat.completions.create(
            model=request.model,
            messages=request.messages,  # Directly use request messages
            tool_choice=(
                request.tool_choice if request.tools and request.tool_choice else None
            ),
            tools=request.tools if request.tools else None,
            modalities=request.modalities,
            audio=request.audio,
            response_format=request.response_format,
            stream=request.stream,
            stream_options=request.stream_options,
        )
        if not request.stream:
            raise HTTPException(
                status_code=400, detail="chat completions require streaming"
            )

        async def generate():
            try:
                async for chunk in response:
                    logger.debug(f"Received chunk: {chunk}")
                    yield f"data: {json.dumps(chunk.to_dict())}\n\n"
                yield "data: [DONE]\n\n"
            except asyncio.CancelledError:
                logger.info("Request was cancelled")
                raise

        return StreamingResponse(generate(), media_type="text/event-stream")
    except asyncio.CancelledError:
        logger.info("Request was cancelled")
        raise HTTPException(status_code=499, detail="Request was cancelled")
    except Exception as e:
        traceback_str = "".join(traceback.format_tb(e.__traceback__))
        error_message = f"{str(e)}\
{traceback_str}"
        logger.error(error_message)
        raise HTTPException(status_code=500, detail=error_message)
type (
	AudioContent struct {
		InputAudio map[string]string `json:"input_audio"`
		Type       string            `json:"type"`
	}

	// Complete request format
	ChatCompletionRequest struct {
		// Assistant's audio reply
		Audio map[string]string `json:"audio,omitempty"`
		// Context information
		Context map[string]any `json:"context,omitempty"`
		// Message list
		Messages []Message `json:"messages"`
		// Default uses text modality
		Modalities []string `json:"modalities"`
		// Model name to use
		Model string `json:"model,omitempty"`
		// Whether to call tools in parallel
		ParallelToolCalls bool `json:"parallel_tool_calls"`
		// Response format
		ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
		// Whether to use streaming response
		Stream bool `json:"stream"`
		// Streaming options
		StreamOptions map[string]any `json:"stream_options,omitempty"`
		// Tool selection strategy, default value is "auto"
		ToolChoice any `json:"tool_choice,omitempty"`
		// Tool list
		Tools []Tool `json:"tools,omitempty"`
	}

	ImageContent struct {
		ImageURL string `json:"image_url"`
		Type     string `json:"type"`
	}

	Message struct {
		Audio      map[string]string `json:"audio,omitempty"`
		Content    any               `json:"content"`
		Role       string            `json:"role"`
		ToolCallID string            `json:"tool_call_id,omitempty"`
		ToolCalls  []map[string]any  `json:"tool_calls,omitempty"`
	}

	ResponseFormat struct {
		JSONSchema map[string]string `json:"json_schema,omitempty"`
		Type       string            `json:"type"`
	}

	TextContent struct {
		Text string `json:"text"`
		Type string `json:"type"`
	}

	Tool struct {
		Function ToolFunction `json:"function"`
		Type     string       `json:"type"`
	}

	ToolChoice struct {
		Function map[string]any `json:"function,omitempty"`
		Type     string         `json:"type"`
	}

	ToolFunction struct {
		Description string         `json:"description,omitempty"`
		Name        string         `json:"name"`
		Parameters  map[string]any `json:"parameters,omitempty"`
		Strict      bool           `json:"strict"`
	}
)

var waitingMessages = []string{
	"Just a moment, I'm thinking...",
	"Let me think about that for a second...",
	"Good question, let me find out...",
}

// Chat Completion Server
type Server struct {
	client *openai.Client
	logger *slog.Logger
}

// Create a new server instance
func NewServer(apiKey string) *Server {
	return &Server{
		client: openai.NewClient(apiKey),
		logger: slog.New(slog.NewJSONHandler(os.Stdout, nil)),
	}
}

// Handle Chat Completion endpoint
func (s *Server) handleChatCompletion(c *gin.Context) {
	var request ChatCompletionRequest

	if err := c.ShouldBindJSON(&request); err != nil {
		s.sendError(c, http.StatusBadRequest, err)
		return
	}

	if !request.Stream {
		s.sendError(c, http.StatusBadRequest, fmt.Errorf("chat completions require streaming"))
		return
	}

	// Set SSE headers
	c.Header("Content-Type", "text/event-stream")

	responseChan := make(chan any, 100)
	errorChan := make(chan error, 1)

	go func() {
		messages := make([]openai.ChatCompletionMessage, len(request.Messages))
		for i, msg := range request.Messages {
			if strContent, ok := msg.Content.(string); ok {
				messages[i] = openai.ChatCompletionMessage{
					Role:    msg.Role,
					Content: strContent,
				}
			}
		}

		req := openai.ChatCompletionRequest{
			Model:    request.Model,
			Messages: messages,
			Stream:   true,
		}

		if len(request.Tools) > 0 {
			tools := make([]openai.Tool, len(request.Tools))

			for i, tool := range request.Tools {
				tools[i] = openai.Tool{
					Type: openai.ToolTypeFunction,
					Function: &openai.FunctionDefinition{
						Name:        tool.Function.Name,
						Description: tool.Function.Description,
						Parameters:  tool.Function.Parameters,
					},
				}
			}

			req.Tools = tools
		}

		stream, err := s.client.CreateChatCompletionStream(c.Request.Context(), req)
		if err != nil {
			errorChan <- err
			return
		}

		defer stream.Close()

		for {
			response, err := stream.Recv()
			if err == io.EOF {
				break
			}

			if err != nil {
				errorChan <- err
				return
			}

			responseChan <- response
		}

		close(responseChan)
	}()

	for {
		select {
		case chunk, ok := <-responseChan:
			if !ok {
				c.SSEvent("data", "[DONE]")
				return
			}

			data, _ := json.Marshal(chunk)
			c.SSEvent("data", string(data))
		case err := <-errorChan:
			s.logger.Error("Error in chat completion stream", "err", err)
			s.sendError(c, http.StatusInternalServerError, err)
			return
		}
	}
}

// Send error response to client
func (s *Server) sendError(c *gin.Context, status int, err error) {
	c.JSON(status, gin.H{"detail": err.Error()})
}

func main() {
	// Initialize server
	server := NewServer(os.Getenv("YOUR_LLM_API_KEY"))

	// Initialize Gin router
	r := gin.Default()

	// Set routes
	r.POST("/chat/completions", server.handleChatCompletion)

	// Start server
	r.Run(":8000")
}
const express = require('express');
const OpenAI = require('openai');

// Initialize OpenAI client
const openai = new OpenAI({
  apiKey: process.env.YOUR_LLM_API_KEY,
});

// Initialize Express app
const app = express();
app.use(express.json());

// Configure logging
const logger = {
  info: (message) => console.log('INFO: ' + message),
  debug: (message) => console.log('DEBUG: ' + message),
  error: (message, error) => console.error('ERROR: ' + message, error),
};

// Chat Completions endpoint
app.post('/chat/completions', async (req, res) => {
  try {
    logger.info('Received request: ' + JSON.stringify(req.body));

    const {
      model,
      messages,
      modalities = ['text'],
      tools,
      tool_choice,
      response_format,
      audio,
      stream = true,
      stream_options,
    } = req.body;

    if (!stream) {
      return res
        .status(400)
        .json({ detail: 'chat completions require streaming' });
    }

    // Set SSE headers
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');

    // Create OpenAI streaming completion
    const completion = await openai.chat.completions.create({
      model,
      messages,
      tools: tools ? tools : undefined,
      tool_choice: tools && tool_choice ? tool_choice : undefined,
      modalities,
      audio,
      response_format,
      stream: true,
      stream_options,
    });

    // Stream the response
    for await (const chunk of completion) {
      logger.debug('Received chunk: ' + JSON.stringify(chunk));
      res.write('data: ' + JSON.stringify(chunk) + '\n\n');
    }

    // End the stream
    res.write('data: [DONE]\n\n');
    res.end();
  } catch (error) {
    logger.error('Chat completion error:', error);

    if (!res.headersSent) {
      const errorDetail = error.message + '\n' + (error.stack || '');
      return res.status(500).json({ detail: errorDetail });
    }

    res.write('data: ' + JSON.stringify({ error: error.message }) + '\n\n');
    res.write('data: [DONE]\n\n');
    res.end();
  }
});

// Start server
const PORT = process.env.PORT || 8000;
app.listen(PORT, () => {
  logger.info('Server running on port ' + PORT);
});

Configure Conversational AI Engine

To configure the agent to use your custom LLM service, update the LLM configuration as follows:

from agora_agent import CustomLLM

# ... other agent configuration ...
.with_llm(CustomLLM(
    api_key='your-llm-api-key',
    base_url='https://your-custom-llm-service/chat/completions',
    model='your-model-name',
    system_messages=[{'role': 'system', 'content': 'You are a helpful assistant.'}],
))
# ... continue with .with_tts() and .create_session() ...
import { CustomLLM } from 'agora-agents';

// ... other agent configuration ...
.withLlm(new CustomLLM({
  apiKey: 'your-llm-api-key',
  url: 'https://your-custom-llm-service/chat/completions',
  model: 'your-model-name',
  systemMessages: [{ role: 'system', content: 'You are a helpful assistant.' }],
}))
// ... continue with .withTts() and .createSession() ...
// ... other agent configuration ...
.WithLlm(
    vendors.NewCustomLLM(vendors.CustomLLMOptions{
        APIKey:  "your-llm-api-key",
        BaseURL: "https://your-custom-llm-service/chat/completions",
        Model:   "your-model-name",
        SystemMessages: []map[string]interface{}{
            {"role": "system", "content": "You are a helpful assistant."},
        },
    }),
)
// ... continue with .WithTts() and .CreateSession() ...
{
  "llm": {
    "url": "https://your-custom-llm-service/chat/completions",
    "api_key": "your-llm-api-key",
    "system_messages": [
      {
        "role": "system",
        "content": "You are a helpful assistant."
      }
    ]
  }
}

Info

If accessing your custom LLM service requires identity verification, provide the authentication information in the api_key field.

Advanced features

To integrate advanced features such as Retrieval-Augmented Generation and generating outputs in multimodal forms, refer to the following sections.

Retrieval-Augmented Generation

To improve the accuracy and relevance of the agent's responses, use the Retrieval-Augmented Generation (RAG) feature. This feature allows your custom LLM to retrieve information from a specific knowledge base and use the retrieved results as context for generating responses.

The following example simulates the process of retrieving and returning content from a knowledge base and creates the /rag/chat/completions endpoint to incorporate RAG retrieval results when generating responses with the LLM.

async def perform_rag_retrieval(messages: Optional[Dict]) -> str:
    """
    Retrieve relevant content from the knowledge base using the RAG model.

    Args:
        messages: The original message list.

    Returns:
        str: The retrieved text content.
    """

    # TODO: Implement the actual RAG retrieval logic.
    # You can choose the first or last message from the message list as the query,
    # then send the query to the RAG model to retrieve relevant content.

    # Return the retrieval result.
    return "This is relevant content retrieved from the knowledge base."

def refact_messages(context: str, messages: Optional[Dict] = None) -> Optional[Dict]:
    """
    Modify the message list by adding the retrieved context to the original messages.

    Args:
        context: The retrieved context.
        messages: The original message list.

    Returns:
        List: The modified message list.
    """

    # TODO: Implement the actual message modification logic.
    # This should add the retrieved context to the original message list.

    return messages

# Random waiting messages.
waiting_messages = [
    "Just a moment, I'm thinking...",
    "Let me think about that for a second...",
    "Good question, let me find out...",
]

@app.post("/rag/chat/completions")
async def create_rag_chat_completion(request: ChatCompletionRequest):
    try:
        logger.info(f"Received RAG request: {request.model_dump_json()}")
        if not request.stream:
            raise HTTPException(
                status_code=400, detail="chat completions require streaming"
            )

        async def generate():
            # First, send a "please wait" prompt.
            waiting_message = {
                "id": "waiting_msg",
                "choices": [
                    {
                        "index": 0,
                        "delta": {
                            "role": "assistant",
                            "content": random.choice(waiting_messages),
                        },
                        "finish_reason": None,
                    }
                ],
            }
            yield f"data: {json.dumps(waiting_message)}\n\n"

            # Perform RAG retrieval.
            retrieved_context = await perform_rag_retrieval(request.messages)

            # Modify messages.
            refacted_messages = refact_messages(retrieved_context, request.messages)

            # Request LLM completion.
            client = AsyncOpenAI(api_key=os.getenv(""))
            response = await client.chat.completions.create(
                model=request.model,
                messages=refacted_messages,
                tool_choice=(
                    request.tool_choice
                    if request.tools and request.tool_choice
                    else None
                ),
                tools=request.tools if request.tools else None,
                modalities=request.modalities,
                audio=request.audio,
                response_format=request.response_format,
                stream=True,  # Force streaming.
                stream_options=request.stream_options,
            )

            try:
                async for chunk in response:
                    logger.debug(f"Received RAG chunk: {chunk}")
                    yield f"data: {json.dumps(chunk.to_dict())}\n\n"
                yield "data: [DONE]\n\n"
            except asyncio.CancelledError:
                logger.info("RAG stream was cancelled")
                raise

        return StreamingResponse(generate(), media_type="text/event-stream")

    except asyncio.CancelledError:
        logger.info("RAG request was cancelled")
        raise HTTPException(status_code=499, detail="Request was cancelled")
    except Exception as e:
        traceback_str = "".join(traceback.format_tb(e.__traceback__))
        error_message = f"{str(e)}\
{traceback_str}"
        logger.error(error_message)
        raise HTTPException(status_code=500, detail=error_message)
// Handle RAG Chat Completion endpoint
func (s *Server) handleRAGChatCompletion(c *gin.Context) {
	var request ChatCompletionRequest
	if err := c.ShouldBindJSON(&request); err != nil {
		s.sendError(c, http.StatusBadRequest, err)
		return
	}
	if !request.Stream {
		s.sendError(c, http.StatusBadRequest, fmt.Errorf("chat completions require streaming"))
		return
	}
	// Set SSE headers
	c.Header("Content-Type", "text/event-stream")
	// First send a "please wait" prompt
	waitingMsg := map[string]any{
		"id": "waiting_msg",
		"choices": []map[string]any{
			{
				"index": 0,
				"delta": map[string]any{
					"role":    "assistant",
					"content": waitingMessages[rand.Intn(len(waitingMessages))],
				},
				"finish_reason": nil,
			},
		},
	}
	data, _ := json.Marshal(waitingMsg)
	c.SSEvent("data", string(data))
	// Perform RAG retrieval
	retrievedContext, err := s.performRAGRetrieval(request.Messages)
	if err != nil {
		s.logger.Error("Failed to perform RAG retrieval", "err", err)
		s.sendError(c, http.StatusInternalServerError, err)
		return
	}
	// Adjust messages
	refactedMessages := s.refactMessages(retrievedContext, request.Messages)
	// Convert messages to OpenAI format
	messages := make([]openai.ChatCompletionMessage, len(refactedMessages))
	for i, msg := range refactedMessages {
		if strContent, ok := msg.Content.(string); ok {
			messages[i] = openai.ChatCompletionMessage{
				Role:    msg.Role,
				Content: strContent,
			}
		}
	}
	req := openai.ChatCompletionRequest{
		Model:    request.Model,
		Messages: messages,
		Stream:   true,
	}
	stream, err := s.client.CreateChatCompletionStream(c.Request.Context(), req)
	if err != nil {
		s.sendError(c, http.StatusInternalServerError, err)
		return
	}
	defer stream.Close()
	for {
		response, err := stream.Recv()
		if err == io.EOF {
			break
		}
		if err != nil {
			s.sendError(c, http.StatusInternalServerError, err)
			return
		}
		data, _ := json.Marshal(response)
		c.SSEvent("data", string(data))
	}
	c.SSEvent("data", "[DONE]")
}
// performRAGRetrieval uses the RAG model to retrieve relevant content from the knowledge base based on the message list.
//
// messages: Contains the original message list.
//
// Returns the retrieved text content and any errors that occurred during retrieval.
func (s *Server) performRAGRetrieval(messages []Message) (string, error) {
	// TODO: Implement actual RAG retrieval logic
	// You may need to select the first or last message from the message list as a query based on specific requirements, then send the query to the RAG model to retrieve relevant content
	// Return retrieval results
	return "This is relevant content retrieved from the knowledge base.", nil
}
// refactMessages adjusts the message list, adding the retrieved context to the original message list.
//
// context: Contains the retrieved context.
// messages: Contains the original message list.
//
// Returns the adjusted message list.
func (s *Server) refactMessages(context string, messages []Message) []Message {
	// TODO: Implement actual message adjustment logic
	// This should add the retrieved context to the original message list
	// Only return original messages
	return messages
}
/**
 * Retrieve relevant content from the knowledge base using the RAG model.
 *
 * @param {Array} messages - The original message list.
 * @returns {Promise<string>} The retrieved text content.
 */
async function performRagRetrieval(messages) {
  // TODO: Implement the actual RAG retrieval logic.
  // You can choose the first or last message from the message list as the query,
  // then send the query to the RAG model to retrieve relevant content.

  // Return the retrieval result.
  return 'This is relevant content retrieved from the knowledge base.';
}

/**
 * Modify the message list by adding the retrieved context to the original messages.
 *
 * @param {string} context - The retrieved context.
 * @param {Array} messages - The original message list.
 * @returns {Array} The modified message list.
 */
function refactMessages(context, messages) {
  // TODO: Implement the actual message modification logic.
  // This should add the retrieved context to the original message list.

  return messages;
}

// Random waiting messages
const waitingMessages = [
  "Just a moment, I'm thinking...",
  'Let me think about that for a second...',
  'Good question, let me find out...',
];

// RAG Chat Completions endpoint
app.post('/rag/chat/completions', async (req, res) => {
  try {
    logger.info('Received RAG request: ' + JSON.stringify(req.body));

    const {
      model,
      messages,
      modalities = ['text'],
      tools,
      tool_choice,
      response_format,
      audio,
      stream = true,
      stream_options,
    } = req.body;

    if (!stream) {
      return res
        .status(400)
        .json({ detail: 'chat completions require streaming' });
    }

    // Set SSE headers
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');

    // First, send a "please wait" prompt
    const waitingMessage = {
      id: 'waiting_msg',
      choices: [
        {
          index: 0,
          delta: {
            role: 'assistant',
            content:
              waitingMessages[
                Math.floor(Math.random() * waitingMessages.length)
              ],
          },
          finish_reason: null,
        },
      ],
    };

    res.write('data: ' + JSON.stringify(waitingMessage) + '\n\n');

    // Perform RAG retrieval
    const retrievedContext = await performRagRetrieval(messages);

    // Modify messages
    const refactedMessages = refactMessages(retrievedContext, messages);

    // Request LLM completion
    const completion = await openai.chat.completions.create({
      model,
      messages: refactedMessages,
      tools: tools ? tools : undefined,
      tool_choice: tools && tool_choice ? tool_choice : undefined,
      modalities,
      audio,
      response_format,
      stream: true,
      stream_options,
    });

    // Stream the response
    for await (const chunk of completion) {
      logger.debug('Received RAG chunk: ' + JSON.stringify(chunk));
      res.write('data: ' + JSON.stringify(chunk) + '\n\n');
    }

    // End the stream
    res.write('data: [DONE]\n\n');
    res.end();
  } catch (error) {
    logger.error('RAG chat completion error:', error);

    if (!res.headersSent) {
      const errorDetail = error.message + '\n' + (error.stack || '');
      return res.status(500).json({ detail: errorDetail });
    }

    res.write('data: ' + JSON.stringify({ error: error.message }) + '\n\n');
    res.write('data: [DONE]\n\n');
    res.end();
  }
});

When calling the POST method to Start a conversational AI agent, simply point the LLM URL to your RAG interface:

{
  "llm": {
    "url": "http://your-custom-llm-service/rag/chat/completions",
    "api_key": ""
    "system_messages": [
      {
        "role": "system",
        "content": "Please answer the user's question based on the following retrieved information: ..."
      }
    ]
  }
}

Info

If accessing your custom LLM service requires identity verification, provide the authentication information in the api_key field.

Multimodal capabilities

Conversational AI Engine supports LLMs in generating output in multimodal formats, including text and audio. You can create dedicated multimodal interfaces to meet personalized requirements. For more information about using audio output, see Audio output mode.

The following example demonstrates how to read text and audio files and send them to an LLM to generate an audio response.

async def read_text_file(file_path: str) -> str:
    """
    Read a text file and return its content
    Args:
        file_path: Path to the text file
    Returns:
        str: Content of the text file
    """
    async with aiofiles.open(file_path, "r") as file:
        content = await file.read()
    return content

async def read_pcm_file(
    file_path: str, sample_rate: int, duration_ms: int
) -> List[bytes]:
    """
    Read a PCM file and return a list of audio chunks
    Args:
        file_path: Path to the PCM file
        sample_rate: Sampling rate of the audio
        duration_ms: Duration of each audio chunk in milliseconds
    Returns:
        List: List of audio chunks
    """
    async with aiofiles.open(file_path, "rb") as file:
        content = await file.read()
    chunk_size = int(sample_rate * 2 * (duration_ms / 1000))
    return [content[i : i + chunk_size] for i in range(0, len(content), chunk_size)]

@app.post("/audio/chat/completions")
async def create_audio_chat_completion(request: ChatCompletionRequest):
    try:
        logger.info(f"Received audio request: {request.model_dump_json()}")
        if not request.stream:
            raise HTTPException(
                status_code=400, detail="chat completions require streaming"
            )

        # Example usage, reading text and audio files
        # Please replace with your actual logic
        text_file_path = "./file.txt"
        pcm_file_path = "./file.pcm"

        sample_rate = 16000  # Example sampling rate
        duration_ms = 40  # 40ms audio chunk

        text_content = await read_text_file(text_file_path)
        audio_chunks = await read_pcm_file(pcm_file_path, sample_rate, duration_ms)

        async def generate():
            try:
                # Send text content
                audio_id = uuid.uuid4().hex
                text_message = {
                    "id": uuid.uuid4().hex,
                    "choices": [
                        {
                            "index": 0,
                            "delta": {
                                "audio": {
                                    "id": audio_id,
                                    "transcript": text_content,
                                },
                            },
                            "finish_reason": None,
                        }
                    ],
                }
                yield f"data: {json.dumps(text_message)}\n\n"
                # Send audio chunks
                for chunk in audio_chunks:
                    audio_message = {
                        "id": uuid.uuid4().hex,
                        "choices": [
                            {
                                "index": 0,
                                "delta": {
                                    "audio": {
                                        "id": audio_id,
                                        "data": base64.b64encode(chunk).decode("utf-8"),
                                    },
                                },
                                "finish_reason": None,
                            }
                        ],
                    }
                    yield f"data: {json.dumps(audio_message)}\n\n"

                yield "data: [DONE]\n\n"

            except asyncio.CancelledError:
                logger.info("Audio stream was cancelled")
                raise

        return StreamingResponse(generate(), media_type="text/event-stream")
    except asyncio.CancelledError:
        logger.info("Audio request was cancelled")
        raise HTTPException(status_code=499, detail="Request was cancelled")
    except Exception as e:
        traceback_str = "".join(traceback.format_tb(e.__traceback__))
        error_message = f"{str(e)}\
{traceback_str}"
        logger.error(error_message)
        raise HTTPException(status_code=500, detail=error_message)
// Handle Audio Chat Completion endpoint
func (s *Server) handleAudioChatCompletion(c *gin.Context) {
   var request ChatCompletionRequest
   if err := c.ShouldBindJSON(&request); err != nil {
   	s.sendError(c, http.StatusBadRequest, err)
   	return
   }
   if !request.Stream {
   	s.sendError(c, http.StatusBadRequest, fmt.Errorf("chat completions require streaming"))
   	return
   }

   // Set SSE headers
   c.Header("Content-Type", "text/event-stream")

   // Read text and audio files
   textContent, err := s.readTextFile("./file.txt")
   if err != nil {
   	s.logger.Error("Failed to read text file", "err", err)
   	s.sendError(c, http.StatusInternalServerError, err)
   	return
   }

   sampleRate := 16000 // Example sample rate
   durationMs := 40    // 40ms chunks
   audioChunks, err := s.readPCMFile("./file.pcm", sampleRate, durationMs)
   if err != nil {
   	s.logger.Error("Failed to read PCM file", "err", err)
   	s.sendError(c, http.StatusInternalServerError, err)
   	return
   }

   // Send text content
   audioID := uuid.New().String()
   textMessage := map[string]any{
   	"id": uuid.New().String(),
   	"choices": []map[string]any{
   		{
   			"index": 0,
   			"delta": map[string]any{
   				"audio": map[string]any{
   					"id":         audioID,
   					"transcript": textContent,
   				},
   			},
   			"finish_reason": nil,
   		},
   	},
   }
   data, _ := json.Marshal(textMessage)
   c.SSEvent("data", string(data))

   // Send audio chunks
   for _, chunk := range audioChunks {
   	audioMessage := map[string]any{
   		"id": uuid.New().String(),
   		"choices": []map[string]any{
   			{
   				"index": 0,
   				"delta": map[string]any{
   					"audio": map[string]any{
   						"id":   audioID,
   						"data": base64.StdEncoding.EncodeToString(chunk),
   					},
   				},
   				"finish_reason": nil,
   			},
   		},
   	}
   	data, _ := json.Marshal(audioMessage)
   	c.SSEvent("data", string(data))
   }
   c.SSEvent("data", "[DONE]")
}

// readPCMFile reads a PCM file and returns audio chunks.
//
// filePath: Specifies the path to the PCM file.
// sampleRate: Specifies the sampling rate of the audio.
// durationMs: Specifies the duration of each audio chunk in milliseconds.
//
// Returns a list of audio chunks and any errors that occurred during reading.
func (s *Server) readPCMFile(filePath string, sampleRate int, durationMs int) ([][]byte, error) {
   data, err := os.ReadFile(filePath)
   if err != nil {
   	return nil, fmt.Errorf("failed to read PCM file: %w", err)
   }
   chunkSize := int(float64(sampleRate) * 2 * float64(durationMs) / 1000.0)
   if chunkSize == 0 {
   	return nil, fmt.Errorf("invalid chunk size: sample rate %d, duration %dms", sampleRate, durationMs)
   }
   chunks := make([][]byte, 0, len(data)/chunkSize+1)
   for i := 0; i < len(data); i += chunkSize {
   	end := i + chunkSize
   	if end > len(data) {
   		end = len(data)
   	}
   	chunks = append(chunks, data[i:end])
   }
   return chunks, nil
}

// readTextFile reads a text file and returns its content.
//
// filePath: Specifies the path to the text file.
//
// Returns the content of the text file and any errors that occurred during reading.
func (s *Server) readTextFile(filePath string) (string, error) {
   data, err := os.ReadFile(filePath)
   if err != nil {
   	return "", fmt.Errorf("failed to read text file: %w", err)
   }
   return string(data), nil
}
const fs = require('fs').promises;
const { randomUUID } = require('crypto');

/**
 * Read a text file and return its content
 * @param {string} filePath - Path to the text file
 * @returns {Promise<string>} Content of the text file
 */
async function readTextFile(filePath) {
  const content = await fs.readFile(filePath, 'utf8');
  return content;
}

/**
 * Read a PCM file and return a list of audio chunks
 * @param {string} filePath - Path to the PCM file
 * @param {number} sampleRate - Sampling rate of the audio
 * @param {number} durationMs - Duration of each audio chunk in milliseconds
 * @returns {Promise} List of audio chunks
 */
async function readPCMFile(filePath, sampleRate, durationMs) {
  const content = await fs.readFile(filePath);
  const chunkSize = Math.floor(sampleRate * 2 * (durationMs / 1000));
  const chunks = [];

  for (let i = 0; i < content.length; i += chunkSize) {
    chunks.push(content.slice(i, i + chunkSize));
  }

  return chunks;
}

// Audio Chat Completions endpoint
app.post('/audio/chat/completions', async (req, res) => {
  try {
    logger.info('Received audio request: ' + JSON.stringify(req.body));

    const {
      model,
      messages,
      modalities = ['text'],
      tools,
      tool_choice,
      response_format,
      audio,
      stream = true,
      stream_options,
    } = req.body;

    if (!stream) {
      return res
        .status(400)
        .json({ detail: 'chat completions require streaming' });
    }

    // Set SSE headers
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');

    // Example usage, reading text and audio files
    // Please replace with your actual logic
    const textFilePath = './file.txt';
    const pcmFilePath = './file.pcm';

    const sampleRate = 16000; // Example sampling rate
    const durationMs = 40; // 40ms audio chunk

    const textContent = await readTextFile(textFilePath);
    const audioChunks = await readPCMFile(pcmFilePath, sampleRate, durationMs);

    // Generate audio ID for this response
    const audioId = randomUUID();

    // Send text content
    const textMessage = {
      id: randomUUID(),
      choices: [
        {
          index: 0,
          delta: {
            audio: {
              id: audioId,
              transcript: textContent,
            },
          },
          finish_reason: null,
        },
      ],
    };

    res.write('data: ' + JSON.stringify(textMessage) + '\n\n');

    // Send audio chunks
    for (const chunk of audioChunks) {
      const audioMessage = {
        id: randomUUID(),
        choices: [
          {
            index: 0,
            delta: {
              audio: {
                id: audioId,
                data: chunk.toString('base64'),
              },
            },
            finish_reason: null,
          },
        ],
      };

      res.write('data: ' + JSON.stringify(audioMessage) + '\n\n');
    }

    res.write('data: [DONE]\n\n');
    res.end();
  } catch (error) {
    logger.error('Audio chat completion error:', error);

    if (!res.headersSent) {
      const errorDetail = error.message + '\n' + (error.stack || '');
      return res.status(500).json({ detail: errorDetail });
    }

    res.write('data: ' + JSON.stringify({ error: error.message }) + '\n\n');
    res.write('data: [DONE]\n\n');
    res.end();
  }
});

To configure the agent to use your audio endpoint, update the LLM configuration as follows:

from agora_agent import CustomLLM

# ... other agent configuration ...
.with_llm(CustomLLM(
    api_key='your-llm-api-key',
    base_url='https://your-custom-llm-service/audio/chat/completions',
    model='your-model-name',
    input_modalities=['text'],
    output_modalities=['text', 'audio'],
    system_messages=[{'role': 'system', 'content': 'You are a helpful assistant.'}],
))
# ... continue with .with_tts() and .create_session() ...
import { CustomLLM } from 'agora-agents';

// ... other agent configuration ...
.withLlm(new CustomLLM({
  apiKey: 'your-llm-api-key',
  url: 'https://your-custom-llm-service/audio/chat/completions',
  model: 'your-model-name',
  inputModalities: ['text'],
  outputModalities: ['text', 'audio'],
  systemMessages: [{ role: 'system', content: 'You are a helpful assistant.' }],
}))
// ... continue with .withTts() and .createSession() ...
// ... other agent configuration ...
.WithLlm(
    vendors.NewCustomLLM(vendors.CustomLLMOptions{
        APIKey:           "your-llm-api-key",
        BaseURL:          "https://your-custom-llm-service/audio/chat/completions",
        Model:            "your-model-name",
        InputModalities:  []string{"text"},
        OutputModalities: []string{"text", "audio"},
        SystemMessages: []map[string]interface{}{
            {"role": "system", "content": "You are a helpful assistant."},
        },
    }),
)
// ... continue with .WithTts() and .CreateSession() ...
{
  "llm": {
    "url": "https://your-custom-llm-service/audio/chat/completions",
    "api_key": "your-llm-api-key",
    "input_modalities": ["text"],
    "output_modalities": ["text", "audio"],
    "system_messages": [
      {
        "role": "system",
        "content": "You are a helpful assistant."
      }
    ]
  }
}

Control agent behavior using first-packet metadata

In streaming response (SSE) scenarios, a single LLM response is segmented into multiple chunks for transmission. Agora's Conversational AI Engine supports processing a special first-chunk response where object is chat.completion.custom_metadata, and uses the metadata field in that response to control the following agent behaviors:

To use these features, modify your custom LLM service to output a special first-chunk response that meets the Conversational AI Engine's requirements. The diagram below shows how the engine processes this metadata chunk before handling the rest of the response.

Configure LLM response interruption

The Conversational AI Engine supports letting the custom LLM determine whether a response can be interrupted by the user. This is particularly useful for long-running tasks, such as querying a database or calling an external API, where the agent must remain responsive while the task completes in the background.

When the custom LLM initiates a long-running task, it sends a custom_metadata packet to the Conversational AI Engine with interruptable set to false, followed by a chat.completion.chunk response containing a filler phrase, for example "Just a moment, I'm processing your request". The Conversational AI Engine passes the filler phrase to TTS and plays it to the user while the task completes in the background. Setting interruptable to false prevents user speech from interrupting the TTS broadcast of the filler phrase.

In your custom LLM service, output a first-chunk response with the following structure:

{
  "id": "response-id",
  "object": "chat.completion.custom_metadata",
  "choices": [],
  "metadata": {
    "interruptable": false
  }
}

The interruptable field configures whether the TTS broadcast of this LLM response can be interrupted by user speech. Set it to false to prevent interruption, true to allow it, or omit it to leave the current interruption mode unchanged.

Caution

The Conversational AI Engine only processes the first chunk response where object is chat.completion.custom_metadata and ignores the content in choices. Subsequent responses are treated as normal responses and do not support metadata configuration. Ensure that the content requiring TTS broadcast is generated in the second and subsequent responses.

ASR behavior during non-interruptible responses

Setting interruptable to false does not disable automatic speech recognition (ASR). The engine continues to capture and transcribe user speech during TTS playback, but holds the transcript and does not process it until TTS finishes. This means:

  • If a user speaks during a non-interruptible TTS broadcast, their speech is not lost. It is queued and processed after playback ends.
  • For long-running tasks, you are responsible for managing the agent's state outside the response lifecycle. Consider how your application should handle queued speech: for example, whether to process it normally, discard it, or use it to cancel the background task.

Update TTS parameters in real time

The Conversational AI Engine supports real-time TTS parameter updates during a conversation, enabling a more immersive interactive experience. This feature is useful when the agent needs to adjust its voice dynamically based on user input, for example when a user requests a different voice or when the LLM detects a change in the user's mood and adjusts volume, pitch, or speech rate accordingly.

When the LLM determines that TTS parameters need to be updated, modify your custom LLM service to output a first-chunk response with the following structure:

{
  "id": "response-id",
  "object": "chat.completion.custom_metadata",
  "choices": [],
  "metadata": {
    "tts_params": {
      "params": {
        "voice_type": "female_1",
        "rate": 1.1
      }
    }
  }
}

The tts_params.params field specifies the TTS parameters to update. For the available parameters, refer to your TTS vendor's official documentation.

Reference

This section contains content that completes the information on this page, or points you to documentation that explains other aspects to this product.

Sample project

Agora provides open source sample projects on GitHub for your reference. Download the project or view the source code for a more complete example.

Interface standards

Custom LLM services must be compatible with the OpenAI Chat Completions API interface standard:

  • Request format: Contains parameters such as model, message, and tool call configuration.
  • Response format: Contains the response generated by the model, metadata, and other information
  • Streaming response: Compliant with the SSE (Server-Sent Events) specification

For detailed interface standards, please refer to: