Remove error-wrapping overrides and align class docstring

The OpenAI SDK already preserves server error details in typed exceptions
(APIStatusError subclasses), and BaseOpenAILLMService propagates errors
via push_error(). The RuntimeError wrapper was erasing exception types
that callers may want to handle explicitly.
This commit is contained in:
Mark Backman
2026-03-23 09:12:31 -04:00
parent eea7fa381e
commit c6c14d5887
3 changed files with 184 additions and 88 deletions

View File

@@ -0,0 +1,178 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import os
from dotenv import load_dotenv
from loguru import logger
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.sarvam.llm import SarvamLLMService
from pipecat.services.sarvam.stt import SarvamSTTService
from pipecat.services.sarvam.tts import SarvamTTSService
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
load_dotenv(override=True)
async def fetch_weather_from_api(params: FunctionCallParams):
await params.result_callback({"conditions": "nice", "temperature": "75"})
async def fetch_restaurant_recommendation(params: FunctionCallParams):
await params.result_callback({"name": "The Golden Dragon"})
# We use lambdas to defer transport parameter creation until the transport
# type is selected at runtime.
transport_params = {
"daily": lambda: DailyParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
"twilio": lambda: FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
"webrtc": lambda: TransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
}
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
logger.info(f"Starting bot")
stt = SarvamSTTService(
api_key=os.getenv("SARVAM_API_KEY"),
settings=SarvamSTTService.Settings(
model="saarika:v2.5",
),
)
tts = SarvamTTSService(
api_key=os.getenv("SARVAM_API_KEY"),
settings=SarvamTTSService.Settings(
model="bulbul:v2",
voice="manisha",
),
)
llm = SarvamLLMService(
api_key=os.getenv("SARVAM_API_KEY"),
settings=SarvamLLMService.Settings(
system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.",
),
)
# You can also register a function_name of None to get all functions
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
weather_function = FunctionSchema(
name="get_current_weather",
description="Get the current weather",
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 user's location.",
},
},
required=["location", "format"],
)
restaurant_function = FunctionSchema(
name="get_restaurant_recommendation",
description="Get a restaurant recommendation",
properties={
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
},
required=["location"],
)
tools = ToolsSchema(standard_tools=[weather_function, restaurant_function])
context = LLMContext(tools=tools)
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(
context,
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
)
pipeline = Pipeline(
[
transport.input(),
stt,
user_aggregator,
llm,
tts,
transport.output(),
assistant_aggregator,
]
)
task = PipelineTask(
pipeline,
params=PipelineParams(
enable_metrics=True,
enable_usage_metrics=True,
),
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
)
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info(f"Client connected")
# Kick off the conversation.
context.add_message({"role": "user", "content": "Please introduce yourself to the user."})
await task.queue_frames([LLMRunFrame()])
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info(f"Client disconnected")
await task.cancel()
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
await runner.run(task)
async def bot(runner_args: RunnerArguments):
"""Main bot entry point compatible with Pipecat Cloud."""
transport = await create_transport(runner_args, transport_params)
await run_bot(transport, runner_args)
if __name__ == "__main__":
from pipecat.runner.run import main
main()

View File

@@ -186,6 +186,7 @@ TESTS_14 = [
("14v-function-calling-openai.py", EVAL_WEATHER),
("14w-function-calling-mistral.py", EVAL_WEATHER),
("14x-function-calling-openpipe.py", EVAL_WEATHER),
("14y-function-calling-sarvam.py", EVAL_WEATHER),
("14-function-calling-openai-responses.py", EVAL_WEATHER),
("14-function-calling-openai-responses.py", EVAL_WEATHER_AND_RESTAURANT),
# Video

View File

@@ -6,27 +6,19 @@
"""Sarvam LLM service implementation using OpenAI-compatible interface."""
import asyncio
import json
from dataclasses import dataclass, field
from typing import Any, Awaitable, Literal, Mapping, Optional, TypeVar
from typing import Literal, Mapping, Optional
import httpx
from loguru import logger
from openai import NOT_GIVEN, APITimeoutError, AsyncStream
from openai.types.chat import ChatCompletionChunk
from openai import NOT_GIVEN
from pipecat.adapters.services.open_ai_adapter import OpenAILLMInvocationParams
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
from pipecat.services.openai.base_llm import OpenAILLMSettings
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.services.sarvam._sdk import sdk_headers
from pipecat.services.settings import NOT_GIVEN as _NOT_GIVEN
from pipecat.services.settings import _NotGiven, is_given
_T = TypeVar("_T")
@dataclass
class SarvamLLMSettings(OpenAILLMSettings):
@@ -44,15 +36,10 @@ class SarvamLLMSettings(OpenAILLMSettings):
class SarvamLLMService(OpenAILLMService):
"""Sarvam LLM service using Sarvam's OpenAI-compatible chat completions API.
"""A service for interacting with Sarvam's API using the OpenAI-compatible interface.
This service extends ``OpenAILLMService`` while adding Sarvam-specific behavior:
- model allow-list validation
- request shaping for Sarvam-compatible parameters
- Sarvam auth header wiring (``api-subscription-key``)
- SDK User-Agent propagation on every API call
- raw Sarvam server error passthrough
This service extends OpenAILLMService to connect to Sarvam's API endpoint while
maintaining full compatibility with OpenAI's interface and functionality.
"""
_SUPPORTED_MODELS = frozenset(
@@ -153,44 +140,6 @@ class SarvamLLMService(OpenAILLMService):
return params
async def _call_with_raw_sarvam_errors(self, awaitable: Awaitable[_T]) -> _T:
"""Await an OpenAI call while preserving Sarvam raw error payloads.
BaseOpenAILLMService handles pipeline-frame exceptions via push_error(),
but direct helper methods like ``get_chat_completions`` and
``run_inference`` are often consumed directly. We normalize those errors
here so applications consistently receive server-provided messages.
"""
try:
return await awaitable
except (APITimeoutError, asyncio.TimeoutError, httpx.TimeoutException):
raise
except Exception as e:
raise RuntimeError(self._format_raw_server_error(e)) from e
async def get_chat_completions(
self, params_from_context: OpenAILLMInvocationParams
) -> AsyncStream[ChatCompletionChunk]:
"""Get streaming chat completions with Sarvam raw error passthrough."""
return await self._call_with_raw_sarvam_errors(
super().get_chat_completions(params_from_context)
)
async def run_inference(
self,
context: LLMContext | OpenAILLMContext,
max_tokens: Optional[int] = None,
system_instruction: Optional[str] = None,
) -> Optional[str]:
"""Run one-shot inference and preserve Sarvam raw server errors."""
return await self._call_with_raw_sarvam_errors(
super().run_inference(
context,
max_tokens=max_tokens,
system_instruction=system_instruction,
)
)
def _validate_model(self, model: str):
if model not in self._SUPPORTED_MODELS:
allowed = ", ".join(sorted(self._SUPPORTED_MODELS))
@@ -209,35 +158,3 @@ class SarvamLLMService(OpenAILLMService):
if has_tool_choice and not has_tools:
raise ValueError("Sarvam requires non-empty `tools` when `tool_choice` is provided.")
def _format_raw_server_error(self, error: Exception) -> str:
raw_message = self._extract_raw_server_message(error)
return f"Sarvam server error: {raw_message}"
def _extract_raw_server_message(self, error: Exception) -> str:
body = getattr(error, "body", None)
if body is not None:
return self._payload_to_message(body)
response = getattr(error, "response", None)
if response is not None:
try:
return self._payload_to_message(response.json())
except Exception:
text = getattr(response, "text", None)
if text:
return str(text)
return str(error)
def _payload_to_message(self, payload: Any) -> str:
if isinstance(payload, dict):
error_obj = payload.get("error")
if isinstance(error_obj, dict) and isinstance(error_obj.get("message"), str):
return error_obj["message"]
if isinstance(payload.get("message"), str):
return payload["message"]
return json.dumps(payload, ensure_ascii=False)
if isinstance(payload, list):
return json.dumps(payload, ensure_ascii=False)
return str(payload)