diff --git a/CHANGELOG.md b/CHANGELOG.md index d6655ed9d..43f402306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added `OpenRouter` for OpenRouter integration with an OpenAI-compatible + interface. Added foundational example `14m-function-calling-openrouter.py`. + - Added a new `WebsocketService` based class for TTS services, containing base functions and retry logic. diff --git a/dot-env.template b/dot-env.template index da76ebcc9..1e902be60 100644 --- a/dot-env.template +++ b/dot-env.template @@ -80,4 +80,7 @@ CEREBRAS_API_KEY=... FISH_API_KEY=... # Assembly AI -ASSEMBLYAI_API_KEY=... \ No newline at end of file +ASSEMBLYAI_API_KEY=... + +# OpenRouter +OPENROUTER_API_KEY=... diff --git a/examples/foundational/14m-function-calling-openrouter.py b/examples/foundational/14m-function-calling-openrouter.py new file mode 100644 index 000000000..2535160e9 --- /dev/null +++ b/examples/foundational/14m-function-calling-openrouter.py @@ -0,0 +1,142 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import os +import sys + +import aiohttp +from dotenv import load_dotenv +from loguru import logger +from openai.types.chat import ChatCompletionToolParam +from runner import configure + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext +from pipecat.services.azure import AzureTTSService +from pipecat.services.openrouter import OpenRouterLLMService +from pipecat.transports.services.daily import DailyParams, DailyTransport + +load_dotenv(override=True) + +logger.remove(0) +logger.add(sys.stderr, level="DEBUG") + + +async def start_fetch_weather(function_name, llm, context): + # note: we can't push a frame to the LLM here. the bot + # can interrupt itself and/or cause audio overlapping glitches. + # possible question for Aleix and Chad about what the right way + # to trigger speech is, now, with the new queues/async/sync refactors. + # await llm.push_frame(TextFrame("Let me check on that.")) + logger.debug(f"Starting fetch_weather_from_api with function_name: {function_name}") + + +async def fetch_weather_from_api(function_name, tool_call_id, args, llm, context, result_callback): + await result_callback({"conditions": "nice", "temperature": "75"}) + + +async def main(): + async with aiohttp.ClientSession() as session: + (room_url, token) = await configure(session) + + transport = DailyTransport( + room_url, + token, + "Respond bot", + DailyParams( + audio_out_enabled=True, + transcription_enabled=True, + vad_enabled=True, + vad_analyzer=SileroVADAnalyzer(), + ), + ) + + tts = AzureTTSService( + api_key=os.getenv("AZURE_API_KEY"), + region="eastus", + voice="en-US-JennyNeural", + params=AzureTTSService.InputParams(language="en-US", rate="1.1", style="cheerful"), + ) + + llm = OpenRouterLLMService( + api_key=os.getenv("OPENROUTER_API_KEY"), model="openai/gpt-4o-2024-11-20" + ) + # Register a function_name of None to get all functions + # sent to the same callback with an additional function_name parameter. + llm.register_function(None, fetch_weather_from_api, start_callback=start_fetch_weather) + + tools = [ + ChatCompletionToolParam( + type="function", + function={ + "name": "get_current_weather", + "description": "Get the current weather", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + "format": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "description": "The temperature unit to use. Infer this from the users location.", + }, + }, + "required": ["location", "format"], + }, + }, + ) + ] + messages = [ + { + "role": "system", + "content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Your output will be converted to audio so don't include special characters in your answers. Respond to what the user said in a creative and helpful way.", + }, + ] + + context = OpenAILLMContext(messages, tools) + context_aggregator = llm.create_context_aggregator(context) + + pipeline = Pipeline( + [ + transport.input(), + context_aggregator.user(), + llm, + tts, + transport.output(), + context_aggregator.assistant(), + ] + ) + + task = PipelineTask( + pipeline, + PipelineParams( + allow_interruptions=True, + enable_metrics=True, + enable_usage_metrics=True, + report_only_initial_ttfb=True, + ), + ) + + @transport.event_handler("on_first_participant_joined") + async def on_first_participant_joined(transport, participant): + await transport.capture_participant_transcription(participant["id"]) + # Kick off the conversation. + await task.queue_frames([context_aggregator.user().get_context_frame()]) + + runner = PipelineRunner() + + await runner.run(task) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 680c3c129..25d32d53c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,7 @@ soundfile = [ "soundfile~=0.12.1" ] together = [ "openai~=1.59.0" ] websocket = [ "websockets~=13.1", "fastapi~=0.115.0" ] whisper = [ "faster-whisper~=1.1.0" ] +openrouter = [ "openai~=1.59.0" ] [tool.setuptools.packages.find] # All the following settings are optional: diff --git a/src/pipecat/services/openrouter.py b/src/pipecat/services/openrouter.py new file mode 100644 index 000000000..4c99fb767 --- /dev/null +++ b/src/pipecat/services/openrouter.py @@ -0,0 +1,55 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +from typing import Dict, List + +from loguru import logger + +from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext +from pipecat.services.openai import OpenAILLMService + +try: + from openai import AsyncStream, OpenAI + from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam +except ModuleNotFoundError as e: + logger.error(f"Exception: {e}") + logger.error( + "In order to use OpenRouter, you need to `pip install pipecat-ai[openrouter]`. Also, set `OPENROUTER_API_KEY` environment variable." + ) + raise Exception(f"Missing module: {e}") + + +class OpenRouterLLMService(OpenAILLMService): + """A service for interacting with OpenRouter's API using the OpenAI-compatible interface. + + This service extends OpenAILLMService to connect to OpenRouter's API endpoint while + maintaining full compatibility with OpenAI's interface and functionality. + + Args: + api_key (str): The API key for accessing OpenRouter's API + base_url (str, optional): The base URL for OpenRouter API. Defaults to "https://openrouter.ai/api/v1" + model (str, optional): The model identifier to use. Defaults to "openai/gpt-4o-2024-11-20" + **kwargs: Additional keyword arguments passed to OpenAILLMService + """ + + def __init__( + self, + *, + api_key: str | None = None, + model: str = "openai/gpt-4o-2024-11-20", + base_url: str = "https://openrouter.ai/api/v1", + **kwargs, + ): + super().__init__( + api_key=api_key, + base_url=base_url, + model=model, + **kwargs, + ) + + def create_client(self, api_key=None, base_url=None, **kwargs): + logger.debug(f"Creating OpenRouter client with api {base_url}") + return super().create_client(api_key, base_url, **kwargs)