Replace custom provider abstraction with litellm
Drops the src/providers/ package (base class, AnthropicProvider, OpenAIProvider, factory) in favour of a single src/llm.py that calls litellm.completion() directly. litellm handles provider routing, authentication, and SDK differences for 100+ providers without any code we need to maintain. Changes: - Delete src/providers/ entirely - Add src/llm.py — one complete() function wrapping litellm - src/agent/core.py: EmailAgent takes model: str instead of LLMProvider - src/config.py: ai_provider + api key fields → single ai_model string in litellm format (e.g. "anthropic/claude-opus-4-6") - main.py: remove provider factory wiring; pass config.ai_model to agent - .env.example: simplify AI section, show litellm model string examples - pyproject.toml: replace anthropic + openai deps with litellm>=1.0.0 - uv.lock: regenerated https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
This commit is contained in:
+9
-9
@@ -6,21 +6,21 @@
|
|||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# AI Provider
|
# AI Model (via litellm — supports any provider)
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Choose "anthropic" (Claude) or "openai" (GPT).
|
# Use litellm model strings: "<provider>/<model-name>"
|
||||||
AI_PROVIDER=anthropic
|
# Examples:
|
||||||
|
# anthropic/claude-opus-4-6 (default)
|
||||||
# Optional: override the default model for the chosen provider.
|
# openai/gpt-4o
|
||||||
# Anthropic default: claude-opus-4-6
|
# gemini/gemini-2.0-flash
|
||||||
# OpenAI default: gpt-4o
|
AI_MODEL=anthropic/claude-opus-4-6
|
||||||
# AI_MODEL=
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# API Keys — set the one matching your AI_PROVIDER
|
# API Keys — set the one matching your chosen model's provider
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
# OPENAI_API_KEY=sk-...
|
# OPENAI_API_KEY=sk-...
|
||||||
|
# GEMINI_API_KEY=...
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Email — IMAP (receiving parent messages)
|
# Email — IMAP (receiving parent messages)
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ The agent polls the configured IMAP inbox every POLL_INTERVAL seconds,
|
|||||||
processes new messages, and replies via SMTP.
|
processes new messages, and replies via SMTP.
|
||||||
|
|
||||||
Environment variables (see .env.example for full list):
|
Environment variables (see .env.example for full list):
|
||||||
AI_PROVIDER anthropic | openai (default: anthropic)
|
AI_MODEL litellm model string (default: anthropic/claude-opus-4-6)
|
||||||
AI_MODEL Model name override (default: provider default)
|
ANTHROPIC_API_KEY Required for Anthropic models
|
||||||
ANTHROPIC_API_KEY Required if AI_PROVIDER=anthropic
|
OPENAI_API_KEY Required for OpenAI models
|
||||||
OPENAI_API_KEY Required if AI_PROVIDER=openai
|
|
||||||
IMAP_HOST IMAP server hostname
|
IMAP_HOST IMAP server hostname
|
||||||
IMAP_PORT IMAP port (default: 993)
|
IMAP_PORT IMAP port (default: 993)
|
||||||
IMAP_USERNAME Email account username
|
IMAP_USERNAME Email account username
|
||||||
@@ -35,7 +34,6 @@ from src.channels.email_channel import EmailChannel
|
|||||||
from src.config import Config
|
from src.config import Config
|
||||||
from src.knowledge_base.loader import KnowledgeBase
|
from src.knowledge_base.loader import KnowledgeBase
|
||||||
from src.notifications.notifier import AdminNotifier
|
from src.notifications.notifier import AdminNotifier
|
||||||
from src.providers import create_provider
|
|
||||||
from src.storage.json_store import ConversationStore
|
from src.storage.json_store import ConversationStore
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -48,24 +46,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def build_components(config: Config):
|
def build_components(config: Config):
|
||||||
"""Instantiate and wire together all agent components."""
|
"""Instantiate and wire together all agent components."""
|
||||||
|
logger.info("AI model: %s", config.ai_model)
|
||||||
# Resolve API key for the chosen provider
|
|
||||||
if config.ai_provider == "anthropic":
|
|
||||||
if not config.anthropic_api_key:
|
|
||||||
logger.error("ANTHROPIC_API_KEY is required when AI_PROVIDER=anthropic")
|
|
||||||
sys.exit(1)
|
|
||||||
api_key = config.anthropic_api_key
|
|
||||||
elif config.ai_provider == "openai":
|
|
||||||
if not config.openai_api_key:
|
|
||||||
logger.error("OPENAI_API_KEY is required when AI_PROVIDER=openai")
|
|
||||||
sys.exit(1)
|
|
||||||
api_key = config.openai_api_key
|
|
||||||
else:
|
|
||||||
logger.error("Unknown AI_PROVIDER '%s'. Choose 'anthropic' or 'openai'.", config.ai_provider)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
provider = create_provider(config.ai_provider, api_key, config.ai_model)
|
|
||||||
logger.info("AI provider: %s / model: %s", config.ai_provider, provider.model_name)
|
|
||||||
|
|
||||||
kb = KnowledgeBase(config.knowledge_base_dir)
|
kb = KnowledgeBase(config.knowledge_base_dir)
|
||||||
store = ConversationStore(config.data_dir)
|
store = ConversationStore(config.data_dir)
|
||||||
@@ -79,7 +60,7 @@ def build_components(config: Config):
|
|||||||
from_email=config.registration_email,
|
from_email=config.registration_email,
|
||||||
)
|
)
|
||||||
|
|
||||||
agent = EmailAgent(provider=provider, kb=kb, store=store, notifier=notifier)
|
agent = EmailAgent(model=config.ai_model, kb=kb, store=store, notifier=notifier)
|
||||||
|
|
||||||
channel = EmailChannel(
|
channel = EmailChannel(
|
||||||
imap_host=config.imap_host,
|
imap_host=config.imap_host,
|
||||||
|
|||||||
+2
-4
@@ -4,10 +4,8 @@ version = "0.1.0"
|
|||||||
description = "AI-powered conversational registration agent for Spielgruppe Pumuckl"
|
description = "AI-powered conversational registration agent for Spielgruppe Pumuckl"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
# At least one AI provider SDK is required; both are included so the
|
# LLM access — supports any provider (Anthropic, OpenAI, Gemini, …)
|
||||||
# operator can choose at runtime via the AI_PROVIDER env variable.
|
"litellm>=1.0.0",
|
||||||
"anthropic>=0.40.0",
|
|
||||||
"openai>=1.50.0",
|
|
||||||
# Configuration
|
# Configuration
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
# Registration schema validation
|
# Registration schema validation
|
||||||
|
|||||||
+7
-9
@@ -7,7 +7,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from ..models.conversation import ConversationState, ChatMessage
|
from ..models.conversation import ConversationState, ChatMessage
|
||||||
from ..models.registration import BookingDay, RegistrationData
|
from ..models.registration import BookingDay, RegistrationData
|
||||||
from ..providers.base import LLMProvider, LLMMessage
|
from .. import llm
|
||||||
from ..knowledge_base.loader import KnowledgeBase
|
from ..knowledge_base.loader import KnowledgeBase
|
||||||
from ..storage.json_store import ConversationStore, normalize_email, _diff_registrations
|
from ..storage.json_store import ConversationStore, normalize_email, _diff_registrations
|
||||||
from ..notifications.notifier import AdminNotifier
|
from ..notifications.notifier import AdminNotifier
|
||||||
@@ -28,12 +28,12 @@ class EmailAgent:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
provider: LLMProvider,
|
model: str,
|
||||||
kb: KnowledgeBase,
|
kb: KnowledgeBase,
|
||||||
store: ConversationStore,
|
store: ConversationStore,
|
||||||
notifier: AdminNotifier,
|
notifier: AdminNotifier,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._provider = provider
|
self._model = model
|
||||||
self._kb = kb
|
self._kb = kb
|
||||||
self._store = store
|
self._store = store
|
||||||
self._notifier = notifier
|
self._notifier = notifier
|
||||||
@@ -97,11 +97,10 @@ class EmailAgent:
|
|||||||
def _handle_registration(self, state: ConversationState) -> str:
|
def _handle_registration(self, state: ConversationState) -> str:
|
||||||
"""Drive the in-progress registration conversation."""
|
"""Drive the in-progress registration conversation."""
|
||||||
system = build_system_prompt(self._kb, state)
|
system = build_system_prompt(self._kb, state)
|
||||||
llm_messages = [LLMMessage(role=m.role, content=m.content) for m in state.messages]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self._provider.complete(system=system, messages=llm_messages)
|
content = llm.complete(self._model, system, state.messages)
|
||||||
parsed = self._parse_llm_response(response.content)
|
parsed = self._parse_llm_response(content)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("LLM call failed for %s", state.conversation_id)
|
logger.exception("LLM call failed for %s", state.conversation_id)
|
||||||
return self._fallback_message(state)
|
return self._fallback_message(state)
|
||||||
@@ -140,11 +139,10 @@ class EmailAgent:
|
|||||||
def _handle_post_completion(self, state: ConversationState) -> str:
|
def _handle_post_completion(self, state: ConversationState) -> str:
|
||||||
"""Handle messages received after a registration is already complete."""
|
"""Handle messages received after a registration is already complete."""
|
||||||
system = build_system_prompt(self._kb, state)
|
system = build_system_prompt(self._kb, state)
|
||||||
llm_messages = [LLMMessage(role=m.role, content=m.content) for m in state.messages]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self._provider.complete(system=system, messages=llm_messages)
|
content = llm.complete(self._model, system, state.messages)
|
||||||
parsed = self._parse_llm_response(response.content)
|
parsed = self._parse_llm_response(content)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("LLM call failed (post-completion) for %s", state.conversation_id)
|
logger.exception("LLM call failed (post-completion) for %s", state.conversation_id)
|
||||||
return self._fallback_message(state)
|
return self._fallback_message(state)
|
||||||
|
|||||||
+4
-9
@@ -13,11 +13,9 @@ except ImportError:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
# AI Provider
|
# AI model — litellm format, e.g. "anthropic/claude-opus-4-6" or "openai/gpt-4o".
|
||||||
ai_provider: str = "anthropic" # "anthropic" or "openai"
|
# The matching API key must be set as an env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, …).
|
||||||
ai_model: str = ""
|
ai_model: str = "anthropic/claude-opus-4-6"
|
||||||
anthropic_api_key: str = ""
|
|
||||||
openai_api_key: str = ""
|
|
||||||
|
|
||||||
# Email — IMAP (receiving)
|
# Email — IMAP (receiving)
|
||||||
imap_host: str = ""
|
imap_host: str = ""
|
||||||
@@ -48,10 +46,7 @@ class Config:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> "Config":
|
def from_env(cls) -> "Config":
|
||||||
return cls(
|
return cls(
|
||||||
ai_provider=os.getenv("AI_PROVIDER", "anthropic"),
|
ai_model=os.getenv("AI_MODEL", "anthropic/claude-opus-4-6"),
|
||||||
ai_model=os.getenv("AI_MODEL", ""),
|
|
||||||
anthropic_api_key=os.getenv("ANTHROPIC_API_KEY", ""),
|
|
||||||
openai_api_key=os.getenv("OPENAI_API_KEY", ""),
|
|
||||||
imap_host=os.getenv("IMAP_HOST", ""),
|
imap_host=os.getenv("IMAP_HOST", ""),
|
||||||
imap_port=int(os.getenv("IMAP_PORT", "993")),
|
imap_port=int(os.getenv("IMAP_PORT", "993")),
|
||||||
imap_username=os.getenv("IMAP_USERNAME", ""),
|
imap_username=os.getenv("IMAP_USERNAME", ""),
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
"""LLM completion via litellm — supports any provider with a single call."""
|
||||||
|
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
|
||||||
|
def complete(model: str, system: str, messages: list) -> str:
|
||||||
|
"""Call any LLM and return the response text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: litellm model string, e.g. "anthropic/claude-opus-4-6" or
|
||||||
|
"openai/gpt-4o". The matching API key must be set as an
|
||||||
|
environment variable (ANTHROPIC_API_KEY, OPENAI_API_KEY, …).
|
||||||
|
system: System prompt text.
|
||||||
|
messages: List of objects with .role and .content attributes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The model's reply as a plain string.
|
||||||
|
"""
|
||||||
|
api_messages = [{"role": "system", "content": system}]
|
||||||
|
api_messages += [{"role": m.role, "content": m.content} for m in messages]
|
||||||
|
response = litellm.completion(model=model, messages=api_messages, max_tokens=2048)
|
||||||
|
return response.choices[0].message.content
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
"""LLM provider registry."""
|
|
||||||
|
|
||||||
from .base import LLMProvider, LLMMessage, LLMResponse
|
|
||||||
from .anthropic_provider import AnthropicProvider
|
|
||||||
from .openai_provider import OpenAIProvider
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"LLMProvider",
|
|
||||||
"LLMMessage",
|
|
||||||
"LLMResponse",
|
|
||||||
"AnthropicProvider",
|
|
||||||
"OpenAIProvider",
|
|
||||||
"create_provider",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def create_provider(provider: str, api_key: str, model: str = "") -> LLMProvider:
|
|
||||||
"""Instantiate the correct LLMProvider by name.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider: "anthropic" or "openai"
|
|
||||||
api_key: API key for the chosen provider.
|
|
||||||
model: Optional model name override.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Configured LLMProvider instance.
|
|
||||||
"""
|
|
||||||
if provider == "anthropic":
|
|
||||||
return AnthropicProvider(api_key=api_key, model=model)
|
|
||||||
if provider == "openai":
|
|
||||||
return OpenAIProvider(api_key=api_key, model=model)
|
|
||||||
raise ValueError(
|
|
||||||
f"Unknown AI provider: '{provider}'. Supported values: 'anthropic', 'openai'."
|
|
||||||
)
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"""Anthropic (Claude) LLM provider."""
|
|
||||||
|
|
||||||
from .base import LLMProvider, LLMMessage, LLMResponse
|
|
||||||
|
|
||||||
|
|
||||||
class AnthropicProvider(LLMProvider):
|
|
||||||
DEFAULT_MODEL = "claude-opus-4-6"
|
|
||||||
|
|
||||||
def __init__(self, api_key: str, model: str = "") -> None:
|
|
||||||
try:
|
|
||||||
import anthropic
|
|
||||||
except ImportError as exc:
|
|
||||||
raise ImportError(
|
|
||||||
"Install the 'anthropic' package to use the Anthropic provider: "
|
|
||||||
"pip install anthropic"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
self._client = anthropic.Anthropic(api_key=api_key)
|
|
||||||
self._model = model or self.DEFAULT_MODEL
|
|
||||||
|
|
||||||
def complete(self, system: str, messages: list) -> LLMResponse:
|
|
||||||
api_messages = [
|
|
||||||
{"role": m.role, "content": m.content}
|
|
||||||
for m in messages
|
|
||||||
if m.role in ("user", "assistant")
|
|
||||||
]
|
|
||||||
response = self._client.messages.create(
|
|
||||||
model=self._model,
|
|
||||||
max_tokens=2048,
|
|
||||||
system=system,
|
|
||||||
messages=api_messages,
|
|
||||||
)
|
|
||||||
return LLMResponse(content=response.content[0].text)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def model_name(self) -> str:
|
|
||||||
return self._model
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""Abstract base class for LLM providers."""
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LLMMessage:
|
|
||||||
role: str # "user" or "assistant"
|
|
||||||
content: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LLMResponse:
|
|
||||||
content: str
|
|
||||||
|
|
||||||
|
|
||||||
class LLMProvider(ABC):
|
|
||||||
"""Uniform interface for any LLM backend."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def complete(self, system: str, messages: list) -> LLMResponse:
|
|
||||||
"""Generate a completion.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
system: System prompt text.
|
|
||||||
messages: List of LLMMessage objects (user/assistant turns).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
LLMResponse with the model's text output.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def model_name(self) -> str:
|
|
||||||
"""Human-readable model identifier."""
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"""OpenAI (GPT) LLM provider."""
|
|
||||||
|
|
||||||
from .base import LLMProvider, LLMMessage, LLMResponse
|
|
||||||
|
|
||||||
|
|
||||||
class OpenAIProvider(LLMProvider):
|
|
||||||
DEFAULT_MODEL = "gpt-4o"
|
|
||||||
|
|
||||||
def __init__(self, api_key: str, model: str = "") -> None:
|
|
||||||
try:
|
|
||||||
from openai import OpenAI
|
|
||||||
except ImportError as exc:
|
|
||||||
raise ImportError(
|
|
||||||
"Install the 'openai' package to use the OpenAI provider: "
|
|
||||||
"pip install openai"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
self._client = OpenAI(api_key=api_key)
|
|
||||||
self._model = model or self.DEFAULT_MODEL
|
|
||||||
|
|
||||||
def complete(self, system: str, messages: list) -> LLMResponse:
|
|
||||||
api_messages = [{"role": "system", "content": system}]
|
|
||||||
api_messages.extend(
|
|
||||||
{"role": m.role, "content": m.content}
|
|
||||||
for m in messages
|
|
||||||
if m.role in ("user", "assistant")
|
|
||||||
)
|
|
||||||
response = self._client.chat.completions.create(
|
|
||||||
model=self._model,
|
|
||||||
messages=api_messages,
|
|
||||||
max_tokens=2048,
|
|
||||||
)
|
|
||||||
return LLMResponse(content=response.choices[0].message.content)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def model_name(self) -> str:
|
|
||||||
return self._model
|
|
||||||
Reference in New Issue
Block a user