6.4 KiB
ADR-008: Provider Plugin Architecture
Status
Accepted
Context
The discovery phase identified complex provider management needs:
- Multiple AI providers (OpenAI, Anthropic, Google, Azure, etc.)
- 8 model management workflows
- Fallback chains and retry logic
- Custom model support
- Provider-specific capabilities (chat, embeddings, vision, tools)
- Dynamic model catalog updates
Decision
We will implement a plugin-based provider architecture with a common interface and provider-specific adapters.
Provider Interface
# cleveragents.domain.providers
from abc import ABC, abstractmethod
from typing import AsyncIterator, Optional
class ModelProvider(ABC):
"""Base interface for all model providers"""
@abstractmethod
async def chat_completion(
self,
messages: List[Message],
model: str,
temperature: float = 0.7,
max_tokens: Optional[int] = None,
stream: bool = False
) -> Union[ChatResponse, AsyncIterator[ChatChunk]]:
pass
@abstractmethod
async def embeddings(
self,
texts: List[str],
model: str
) -> EmbeddingsResponse:
pass
@abstractmethod
def supports_feature(self, feature: str) -> bool:
pass
@abstractmethod
async def list_models(self) -> List[ModelInfo]:
pass
Provider Registry
# cleveragents.providers.registry
class ProviderRegistry:
"""Central registry for all providers"""
def __init__(self):
self._providers: Dict[str, ModelProvider] = {}
self._fallback_chains: Dict[str, List[str]] = {}
def register(self, name: str, provider: ModelProvider):
self._providers[name] = provider
def get_provider(self, name: str) -> ModelProvider:
if name not in self._providers:
raise ProviderNotFoundError(f"Provider {name} not registered")
return self._providers[name]
def set_fallback_chain(self, primary: str, fallbacks: List[str]):
self._fallback_chains[primary] = fallbacks
2025-12-10 update — implemented runtime defaults
The concrete registry (src/cleveragents/providers/registry.py:1-357) and settings layer (src/cleveragents/config/settings.py:20-343) now realize this ADR with:
-
Deterministic precedence (actor-first): CLI requires
--actor(or a default actor). Actor configs carry provider/model; built-in actors are seeded fromCLEVERAGENTS_DEFAULT_PROVIDER/CLEVERAGENTS_DEFAULT_MODELand Settings defaults, then follow the fallback orderopenai → anthropic → google → azure → openrouter → groq → together → cohere → gemini. -
Diagnostics + CLI surfacing:
PlanServiceand Typer commands enforce actor selection, log resolved provider/model from the actor config, and bubble actionable errors when actors are missing or misconfigured. -
Mock isolation:
CLEVERAGENTS_TESTING_USE_MOCK_AIremains the only bypass insideapplication/container.py, keeping mocks out of production code per the implementation plan. -
Actor registry boundaries: The registry persists canonical config blobs/hashes/graph descriptors, seeds immutable built-in actors named
<provider>/<model>from settings defaults, enforceslocal/<id>for custom actors, blocks removal of built-ins or the default actor, and requires--unsafeconfirmation when an actor config is marked unsafe; CLI help/docs mirror these guards.
These details are mirrored in docs/reference/providers.md and docs/reference/env_variables.yaml so the ADR now points at the actual runtime behavior.
Provider Implementations
# cleveragents.providers.openai
class OpenAIProvider(ModelProvider):
def __init__(self, api_key: str, base_url: Optional[str] = None):
self.client = AsyncOpenAI(api_key=api_key, base_url=base_url)
async def chat_completion(self, messages, model, **kwargs):
try:
if kwargs.get('stream'):
return self._stream_chat(messages, model, **kwargs)
response = await self.client.chat.completions.create(
messages=messages,
model=model,
**kwargs
)
return ChatResponse.from_openai(response)
except RateLimitError as e:
raise ProviderRateLimitError(retry_after=e.retry_after)
Fallback Strategy
# cleveragents.providers.fallback
class FallbackProvider:
"""Handles fallback chains and retry logic"""
async def execute_with_fallback(
self,
operation: Callable,
providers: List[str],
max_retries: int = 3
):
for provider_name in providers:
provider = self.registry.get_provider(provider_name)
for attempt in range(max_retries):
try:
return await operation(provider)
except ProviderRateLimitError as e:
if attempt < max_retries - 1:
await asyncio.sleep(e.retry_after or 2 ** attempt)
continue
except ProviderError:
break # Try next provider
raise AllProvidersFailed("All providers in chain failed")
Model Catalog
# cleveragents.providers.catalog
class ModelCatalog:
"""Manages model metadata and capabilities"""
def __init__(self, catalog_path: Path):
self.catalog = self._load_catalog(catalog_path)
def get_model_info(self, provider: str, model: str) -> ModelInfo:
return self.catalog[provider][model]
def find_models_with_capability(self, capability: str) -> List[ModelInfo]:
return [
model for models in self.catalog.values()
for model in models.values()
if capability in model.capabilities
]
Consequences
Positive
- Easy to add new providers
- Consistent interface across providers
- Centralized fallback and retry logic
- Testable with mock providers
- Dynamic provider loading possible
Negative
- Abstraction may hide provider-specific features
- Need to maintain provider compatibility
- Catalog synchronization complexity
Implementation Priority
- OpenAI provider (most common)
- Anthropic provider
- Local/Ollama provider
- Google/Azure providers
References
- Strategy Pattern
- Plugin Architecture
- OpenAI API Documentation