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:
Claude
2026-02-21 07:35:37 +00:00
parent 1ba42f9497
commit 431847a8b7
11 changed files with 972 additions and 226 deletions
+9 -9
View File
@@ -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)
+5 -24
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
-34
View File
@@ -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'."
)
-37
View File
@@ -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
-36
View File
@@ -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."""
-37
View File
@@ -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
Generated
+923 -27
View File
File diff suppressed because it is too large Load Diff