From 13eef371bce4d4230f0974477e046ca6754fdcfa Mon Sep 17 00:00:00 2001 From: curo1305 Date: Thu, 4 Jun 2026 19:01:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(07-02):=20registry-based=20get=5Fprovider(?= =?UTF-8?q?config:=20ProviderConfig)=20=E2=80=94=20D-06?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrites ai/__init__.py with _REGISTRY dict (10 entries) replacing the if/elif chain - get_provider() accepts ProviderConfig (not raw dict), raises ValueError on unknown - Resolves effective values: api_key fallback to "not-needed", model/base_url/context_chars from PROVIDER_DEFAULTS when config fields are empty/zero - SUPPORTS_JSON_MODE lookup sets supports_json_mode on GenericOpenAIProvider instances - AnthropicProvider instantiated without base_url (Plan 03 will widen the ctor) - provider_config.py: context_chars default changed to 0 (sentinel for PROVIDER_DEFAULTS resolution); 8000 default was inconsistent with O(1) factory pattern (Rule 1 fix) --- backend/ai/__init__.py | 108 +++++++++++++++++++++++++--------- backend/ai/provider_config.py | 2 +- 2 files changed, 82 insertions(+), 28 deletions(-) diff --git a/backend/ai/__init__.py b/backend/ai/__init__.py index 528ce16..b96e8cb 100644 --- a/backend/ai/__init__.py +++ b/backend/ai/__init__.py @@ -1,35 +1,89 @@ -from ai.base import AIProvider, ClassificationResult +"""AI provider factory — registry-based O(1) lookup. + +Usage: + from ai import get_provider + from ai.provider_config import ProviderConfig + + config = ProviderConfig(provider_id="groq", api_key="sk-...") + provider = get_provider(config) + result = await provider.classify(text, topics, system_prompt) +""" +from __future__ import annotations + +from ai.base import AIProvider from ai.anthropic_provider import AnthropicProvider from ai.openai_provider import OpenAIProvider -from ai.ollama_provider import OllamaProvider -from ai.lmstudio_provider import LMStudioProvider +from ai.generic_openai_provider import GenericOpenAIProvider +from ai.provider_config import ProviderConfig, PROVIDER_DEFAULTS, SUPPORTS_JSON_MODE -def get_provider(settings: dict) -> AIProvider: - active = settings.get("active_provider", "lmstudio") - providers = settings.get("providers", {}) - cfg = providers.get(active, {}) +# Registry: maps provider_id → provider class +# "openai" uses OpenAIProvider (no response_format override needed — plain OpenAI) +# "anthropic" uses AnthropicProvider (native output_config, no base_url ctor arg until Plan 03) +# All 8 OpenAI-compat vendors use GenericOpenAIProvider (D-16/D-17/D-18) +_REGISTRY: dict[str, type[AIProvider]] = { + "openai": OpenAIProvider, + "anthropic": AnthropicProvider, + "gemini": GenericOpenAIProvider, + "groq": GenericOpenAIProvider, + "xai": GenericOpenAIProvider, + "deepseek": GenericOpenAIProvider, + "openrouter": GenericOpenAIProvider, + "mistral": GenericOpenAIProvider, + "ollama": GenericOpenAIProvider, + "lmstudio": GenericOpenAIProvider, +} - if active == "anthropic": - return AnthropicProvider( - api_key=cfg.get("api_key", ""), - model=cfg.get("model", "claude-sonnet-4-6"), + +def get_provider(config: ProviderConfig) -> AIProvider: + """Instantiate and return an AI provider for the given ProviderConfig. + + Resolves defaults from PROVIDER_DEFAULTS when config fields are absent, + normalises an empty api_key to "not-needed" (OpenAI SDK 2.34+ rejects ""), + and sets supports_json_mode from the SUPPORTS_JSON_MODE lookup for + GenericOpenAIProvider instances. + + Args: + config: A ProviderConfig with at minimum provider_id set. + + Returns: + A fully-constructed AIProvider instance. + + Raises: + ValueError: If config.provider_id is not in the registry. + """ + cls = _REGISTRY.get(config.provider_id) + if cls is None: + raise ValueError(f"Unknown AI provider: {config.provider_id!r}") + + defaults = PROVIDER_DEFAULTS[config.provider_id] + + # Resolve effective values — config fields take precedence over defaults + effective_api_key = config.api_key or "not-needed" + effective_model = config.model or defaults["model"] + effective_base_url = config.base_url if config.base_url is not None else defaults["base_url"] + effective_context_chars = config.context_chars or defaults["context_chars"] + + if config.provider_id == "anthropic": + # AnthropicProvider does not accept base_url until Plan 03 refactors it; + # pass only the args its current __init__ accepts. + return cls( + api_key=effective_api_key, + model=effective_model, ) - elif active == "openai": - return OpenAIProvider( - api_key=cfg.get("api_key", ""), - model=cfg.get("model", "gpt-4o"), - base_url=cfg.get("base_url") or None, - ) - elif active == "ollama": - return OllamaProvider( - base_url=cfg.get("base_url", "http://host.docker.internal:11434"), - model=cfg.get("model", "llama3.2"), - ) - elif active == "lmstudio": - return LMStudioProvider( - base_url=cfg.get("base_url", "http://host.docker.internal:1234"), - model=cfg.get("model", "gemma-4-e4b-it"), + elif cls is GenericOpenAIProvider: + return cls( + api_key=effective_api_key, + model=effective_model, + base_url=effective_base_url, + context_chars=effective_context_chars, + supports_json_mode=SUPPORTS_JSON_MODE[config.provider_id], ) else: - raise ValueError(f"Unknown AI provider: {active}") + # OpenAIProvider + return cls( + api_key=effective_api_key, + model=effective_model, + base_url=effective_base_url, + context_chars=effective_context_chars, + ) diff --git a/backend/ai/provider_config.py b/backend/ai/provider_config.py index 1ae20af..020e0f3 100644 --- a/backend/ai/provider_config.py +++ b/backend/ai/provider_config.py @@ -29,7 +29,7 @@ class ProviderConfig(BaseModel): api_key: str = "" base_url: Optional[str] = None model: str = "" - context_chars: int = 8000 + context_chars: int = 0 # 0 means "unset — use PROVIDER_DEFAULTS in get_provider()" # Named preset defaults for all 10 supported providers.