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).
AI_PROVIDER=anthropic
# Optional: override the default model for the chosen provider.
# Anthropic default: claude-opus-4-6
# OpenAI default: gpt-4o
# AI_MODEL=
# Use litellm model strings: "<provider>/<model-name>"
# Examples:
# anthropic/claude-opus-4-6 (default)
# openai/gpt-4o
# gemini/gemini-2.0-flash
AI_MODEL=anthropic/claude-opus-4-6
# ---------------------------------------------------------------
# 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-...
# OPENAI_API_KEY=sk-...
# GEMINI_API_KEY=...
# ---------------------------------------------------------------
# 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.
Environment variables (see .env.example for full list):
AI_PROVIDER anthropic | openai (default: anthropic)
AI_MODEL Model name override (default: provider default)
ANTHROPIC_API_KEY Required if AI_PROVIDER=anthropic
OPENAI_API_KEY Required if AI_PROVIDER=openai
AI_MODEL litellm model string (default: anthropic/claude-opus-4-6)
ANTHROPIC_API_KEY Required for Anthropic models
OPENAI_API_KEY Required for OpenAI models
IMAP_HOST IMAP server hostname
IMAP_PORT IMAP port (default: 993)
IMAP_USERNAME Email account username
@@ -35,7 +34,6 @@ from src.channels.email_channel import EmailChannel
from src.config import Config
from src.knowledge_base.loader import KnowledgeBase
from src.notifications.notifier import AdminNotifier
from src.providers import create_provider
from src.storage.json_store import ConversationStore
logging.basicConfig(
@@ -48,24 +46,7 @@ logger = logging.getLogger(__name__)
def build_components(config: Config):
"""Instantiate and wire together all agent components."""
# 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)
logger.info("AI model: %s", config.ai_model)
kb = KnowledgeBase(config.knowledge_base_dir)
store = ConversationStore(config.data_dir)
@@ -79,7 +60,7 @@ def build_components(config: Config):
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(
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"
requires-python = ">=3.13"
dependencies = [
# At least one AI provider SDK is required; both are included so the
# operator can choose at runtime via the AI_PROVIDER env variable.
"anthropic>=0.40.0",
"openai>=1.50.0",
# LLM access — supports any provider (Anthropic, OpenAI, Gemini, …)
"litellm>=1.0.0",
# Configuration
"python-dotenv>=1.0.0",
# Registration schema validation
+7 -9
View File
@@ -7,7 +7,7 @@ from datetime import datetime, timezone
from ..models.conversation import ConversationState, ChatMessage
from ..models.registration import BookingDay, RegistrationData
from ..providers.base import LLMProvider, LLMMessage
from .. import llm
from ..knowledge_base.loader import KnowledgeBase
from ..storage.json_store import ConversationStore, normalize_email, _diff_registrations
from ..notifications.notifier import AdminNotifier
@@ -28,12 +28,12 @@ class EmailAgent:
def __init__(
self,
provider: LLMProvider,
model: str,
kb: KnowledgeBase,
store: ConversationStore,
notifier: AdminNotifier,
) -> None:
self._provider = provider
self._model = model
self._kb = kb
self._store = store
self._notifier = notifier
@@ -97,11 +97,10 @@ class EmailAgent:
def _handle_registration(self, state: ConversationState) -> str:
"""Drive the in-progress registration conversation."""
system = build_system_prompt(self._kb, state)
llm_messages = [LLMMessage(role=m.role, content=m.content) for m in state.messages]
try:
response = self._provider.complete(system=system, messages=llm_messages)
parsed = self._parse_llm_response(response.content)
content = llm.complete(self._model, system, state.messages)
parsed = self._parse_llm_response(content)
except Exception:
logger.exception("LLM call failed for %s", state.conversation_id)
return self._fallback_message(state)
@@ -140,11 +139,10 @@ class EmailAgent:
def _handle_post_completion(self, state: ConversationState) -> str:
"""Handle messages received after a registration is already complete."""
system = build_system_prompt(self._kb, state)
llm_messages = [LLMMessage(role=m.role, content=m.content) for m in state.messages]
try:
response = self._provider.complete(system=system, messages=llm_messages)
parsed = self._parse_llm_response(response.content)
content = llm.complete(self._model, system, state.messages)
parsed = self._parse_llm_response(content)
except Exception:
logger.exception("LLM call failed (post-completion) for %s", state.conversation_id)
return self._fallback_message(state)
+4 -9
View File
@@ -13,11 +13,9 @@ except ImportError:
@dataclass
class Config:
# AI Provider
ai_provider: str = "anthropic" # "anthropic" or "openai"
ai_model: str = ""
anthropic_api_key: str = ""
openai_api_key: str = ""
# AI model — litellm format, e.g. "anthropic/claude-opus-4-6" or "openai/gpt-4o".
# The matching API key must be set as an env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, …).
ai_model: str = "anthropic/claude-opus-4-6"
# Email — IMAP (receiving)
imap_host: str = ""
@@ -48,10 +46,7 @@ class Config:
@classmethod
def from_env(cls) -> "Config":
return cls(
ai_provider=os.getenv("AI_PROVIDER", "anthropic"),
ai_model=os.getenv("AI_MODEL", ""),
anthropic_api_key=os.getenv("ANTHROPIC_API_KEY", ""),
openai_api_key=os.getenv("OPENAI_API_KEY", ""),
ai_model=os.getenv("AI_MODEL", "anthropic/claude-opus-4-6"),
imap_host=os.getenv("IMAP_HOST", ""),
imap_port=int(os.getenv("IMAP_PORT", "993")),
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