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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/director/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class LLMType(str, Enum):
ANTHROPIC = "anthropic"
GOOGLEAI = "googleai"
VIDEODB_PROXY = "videodb_proxy"
LITELLM = "litellm"


class EnvPrefix(str, Enum):
Expand All @@ -29,5 +30,6 @@ class EnvPrefix(str, Enum):
OPENAI_ = "OPENAI_"
ANTHROPIC_ = "ANTHROPIC_"
GOOGLEAI_ = "GOOGLEAI_"
LITELLM_ = "LITELLM_"

DOWNLOADS_PATH="director/downloads"
5 changes: 4 additions & 1 deletion backend/director/llm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from director.llm.openai import OpenAI
from director.llm.anthropic import AnthropicAI
from director.llm.googleai import GoogleAI
from director.llm.litellm import LiteLLM
from director.llm.videodb_proxy import VideoDBProxy


Expand All @@ -17,7 +18,9 @@ def get_default_llm():

default_llm = os.getenv("DEFAULT_LLM")

if openai or default_llm == LLMType.OPENAI:
if default_llm == LLMType.LITELLM:
return LiteLLM()
elif openai or default_llm == LLMType.OPENAI:
return OpenAI()
elif anthropic or default_llm == LLMType.ANTHROPIC:
return AnthropicAI()
Expand Down
159 changes: 159 additions & 0 deletions backend/director/llm/litellm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import json
import logging

from pydantic import Field
from pydantic_settings import SettingsConfigDict

from director.llm.base import BaseLLM, BaseLLMConfig, LLMResponse, LLMResponseStatus
from director.constants import LLMType, EnvPrefix

logger = logging.getLogger(__name__)


class LiteLLMConfig(BaseLLMConfig):
"""LiteLLM Config.

Reads from LITELLM_ prefixed environment variables.
Set LITELLM_CHAT_MODEL to any LiteLLM-supported model string
(e.g. anthropic/claude-3-haiku, openai/gpt-4o, bedrock/anthropic.claude-v2).

API keys are read from standard provider environment variables
automatically (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.).
Optionally set LITELLM_API_KEY to override.
"""

model_config = SettingsConfigDict(
env_prefix=EnvPrefix.LITELLM_,
extra="ignore",
)

llm_type: str = LLMType.LITELLM
api_key: str = ""
api_base: str = ""
chat_model: str = Field(default="openai/gpt-4o")
max_tokens: int = 4096


class LiteLLM(BaseLLM):
def __init__(self, config: LiteLLMConfig = None):
"""
:param config: LiteLLM Config
"""
if config is None:
config = LiteLLMConfig()
super().__init__(config=config)

def _format_messages(self, messages: list):
"""Format messages to OpenAI chat format.

LiteLLM accepts OpenAI-format messages and translates
them for each provider internally.
"""
formatted_messages = []
for message in messages:
if message["role"] == "assistant" and message.get("tool_calls"):
formatted_messages.append(
{
"role": message["role"],
"content": message["content"],
"tool_calls": [
{
"id": tool_call["id"],
"function": {
"name": tool_call["tool"]["name"],
"arguments": json.dumps(
tool_call["tool"]["arguments"]
),
},
"type": tool_call["type"],
}
for tool_call in message["tool_calls"]
],
}
)
else:
formatted_messages.append(message)
return formatted_messages

def _format_tools(self, tools: list):
"""Format tools to OpenAI function-calling format."""
formatted_tools = []
for tool in tools:
formatted_tools.append(
{
"type": "function",
"function": {
"name": tool["name"],
"description": tool["description"],
"parameters": tool["parameters"],
},
}
)
return formatted_tools

def chat_completions(
self, messages: list, tools: list | None = None, stop=None, response_format=None
):
"""Get chat completions via LiteLLM.

Routes to 100+ providers (OpenAI, Anthropic, Azure, Bedrock, etc.)
based on the model string in LITELLM_CHAT_MODEL.
"""
import litellm

params = {
"model": self.chat_model,
"messages": self._format_messages(messages),
"temperature": self.temperature,
"max_tokens": self.max_tokens,
"top_p": self.top_p,
"stop": stop,
"timeout": self.timeout,
"drop_params": True,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if self.api_key:
params["api_key"] = self.api_key
if self.api_base:
params["api_base"] = self.api_base
if tools:
params["tools"] = self._format_tools(tools)
params["tool_choice"] = "auto"
if response_format:
params["response_format"] = response_format

try:
response = litellm.completion(**params)

usage = getattr(response, "usage", None)
tool_calls = []
if response.choices[0].message.tool_calls:
for tool_call in response.choices[0].message.tool_calls:
args_raw = tool_call.function.arguments
try:
arguments = json.loads(args_raw) if args_raw else {}
except (json.JSONDecodeError, TypeError):
arguments = {}
tool_calls.append(
{
"id": tool_call.id,
"tool": {
"name": tool_call.function.name,
"arguments": arguments,
},
"type": tool_call.type,
}
)

return LLMResponse(
content=response.choices[0].message.content or "",
tool_calls=tool_calls,
finish_reason=response.choices[0].finish_reason,
send_tokens=getattr(usage, "prompt_tokens", 0) or 0,
recv_tokens=getattr(usage, "completion_tokens", 0) or 0,
total_tokens=getattr(usage, "total_tokens", 0) or 0,
status=LLMResponseStatus.SUCCESS,
)
except Exception as e:
logger.error("LiteLLM completion failed: %s", e)
return LLMResponse(content=f"Error: {e}")
Comment on lines +157 to +159
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not return raw upstream exception text to callers.

content=f"Error: {e}" exposes provider/network error details directly in the response path. Those exceptions can include internal endpoints, request metadata, or other sensitive diagnostics. Return a generic failure message and keep the full error in logs instead.

Suggested fix
-        except Exception as e:
-            logger.error("LiteLLM completion failed: %s", e)
-            return LLMResponse(content=f"Error: {e}")
+        except Exception:
+            logger.exception("LiteLLM completion failed")
+            return LLMResponse(
+                content="Error: LiteLLM completion failed",
+                status=LLMResponseStatus.ERROR,
+            )
🧰 Tools
🪛 Ruff (0.15.12)

[warning] 157-157: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/director/llm/litellm.py` around lines 157 - 159, The except block in
litellm.py currently returns the raw exception text to callers; change it to log
the full error (use logger.exception or include the exception object in
logger.error) and return a generic failure message instead of content=f"Error:
{e}". Specifically, update the except handling around the LiteLLM completion
call so logger.exception("LiteLLM completion failed") (or equivalent) records
the full stack/exception, and return LLMResponse(content="Error: failed to
generate completion") (or a similar generic message) so no upstream/internal
error details are leaked.

1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ composio_openai==0.5.50
elevenlabs==1.9.0
fal-client===0.5.8
Flask==3.0.3
litellm>=1.60.0,<2.0.0
Flask-SocketIO==5.3.6
Flask-Cors==4.0.1
openai==1.55.3
Expand Down
Empty file added backend/tests/__init__.py
Empty file.
Loading