feat: add openai-compatible provider for custom AI endpoints
Add AI_PROVIDER=openai-compatible mode that reuses OpenAISynthesisProvider with a validated custom base_url, allowing any OpenAI-compatible API (OpenRouter, Ollama, LiteLLM proxy, etc.) without new code. Configuration: - AISettings.provider now accepts openai-compatible - New AISettings.allow_insecure_http: bool = False (HTTP opt-in) - .env.example: commented examples for OpenRouter (HTTPS) and Ollama (HTTP) Factory validation (_validate_openai_compatible_config): - base_url and model required, api_key required (MVP) - HTTPS enforced unless allow_insecure_http=true - Credentials in URL rejected, sensitive query params rejected (including valueless params via keep_blank_values=True) - Malformed URLs and missing hostname rejected (ValueError caught) - No /v1 manipulation; degraded to None + warning on invalid config - redact_url() used for all URL warnings Tests: 13 new factory tests in test_synthesis.py covering routing, URL validation, HTTP policy, credentials, sentinel non-leak, no-network. Coverage: 91.57% (synthesis module). Docs: GUIDE_DEV_PYTHON.md §9.5 updated with 3-provider table, validation rules, and synchronized code example. mypy override for openai.* (follow_imports=skip) to work around mypy 2.3.1 internal error in pre-commit's isolated environment. Co-authored-by: opencode/coder anthropic.claude-sonnet-4-5 <anthropic.claude-sonnet-4-5@agents.invalid> Co-authored-by: opencode/test-engineer anthropic.claude-sonnet-4-5 <anthropic.claude-sonnet-4-5@agents.invalid> Co-authored-by: opencode/tech-writer anthropic.claude-sonnet-4-5 <anthropic.claude-sonnet-4-5@agents.invalid>
This commit is contained in:
@@ -3829,14 +3829,70 @@ class LiteLLMSynthesisProvider:
|
||||
|
||||
La factory utilise `get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None`. Elle retourne `None` si `not settings.enabled` ou `not settings.api_key`.
|
||||
|
||||
L'import de `litellm` est conditionnel avec `try/except ImportError` → `None`. Les providers `openai` et `openai-compatible` sont mappés vers `OpenAISynthesisProvider`, et `litellm` vers `LiteLLMSynthesisProvider`. La factory passe `settings.api_key` (SecretStr) directement aux providers, sans appel à `.get_secret_value()`.
|
||||
L'import de `litellm` est conditionnel avec `try/except ImportError` → `None`. Les valeurs possibles pour `AI_PROVIDER` sont les suivantes :
|
||||
|
||||
| Valeur | Usage | Adaptateur |
|
||||
|---|---|---|
|
||||
| ``openai`` | API OpenAI officielle | ``OpenAISynthesisProvider`` |
|
||||
| ``openai-compatible`` | Proxy ou serveur compatible OpenAI | ``OpenAISynthesisProvider`` |
|
||||
| ``litellm`` | Bibliothèque LiteLLM embarquée | ``LiteLLMSynthesisProvider`` |
|
||||
|
||||
Pour le provider ``openai-compatible``, la validation de la configuration est stricte :
|
||||
|
||||
- ``AI_BASE_URL`` est requis.
|
||||
- ``AI_MODEL`` est requis et ne doit pas être vide.
|
||||
- ``AI_API_KEY`` est requis (MVP).
|
||||
- L'URL doit utiliser le schéma ``https`` sauf si ``AI_ALLOW_INSECURE_HTTP=true``.
|
||||
- Les credentials dans l'URL sont refusés.
|
||||
- Les paramètres sensibles dans la *query string* sont refusés.
|
||||
- Aucune manipulation automatique de ``/v1`` n'est effectuée.
|
||||
- Si la configuration est incomplète, la factory retourne ``None`` avec un avertissement (mode dégradé).
|
||||
|
||||
La politique hors réseau de la table des modèles litellm est gérée par `LITELLM_LOCAL_MODEL_COST_MAP=true`. Les tests utilisent `pytest.importorskip("litellm")`.
|
||||
|
||||
```python
|
||||
import logging
|
||||
from urllib.parse import parse_qsl, urlparse
|
||||
|
||||
from ..config.settings import AISettings
|
||||
from .provider import SynthesisProvider
|
||||
from .openai import OpenAISynthesisProvider
|
||||
from ..utils.redaction import redact_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_openai_compatible_config(
|
||||
url: str | None, model: str | None, allow_insecure_http: bool
|
||||
) -> str | None:
|
||||
"""Valide la configuration du provider ``openai-compatible``."""
|
||||
if not url or not model:
|
||||
return None
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
logger.warning("URL invalide : %s", redact_url(url))
|
||||
return None
|
||||
if not parsed.hostname:
|
||||
logger.warning("URL sans hostname : %s", redact_url(url))
|
||||
return None
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return None
|
||||
if parsed.scheme == "http" and not allow_insecure_http:
|
||||
return None
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
logger.warning("Credentials dans l'URL refusés : %s", redact_url(url))
|
||||
return None
|
||||
sensitive_names = {"token", "key", "api_key", "secret", "password", "auth"}
|
||||
param_names = [
|
||||
name.lower() for name, _ in parse_qsl(parsed.query, keep_blank_values=True)
|
||||
]
|
||||
if any(name in sensitive_names for name in param_names):
|
||||
logger.warning(
|
||||
"Paramètres sensibles dans l'URL refusés : %s", redact_url(url)
|
||||
)
|
||||
return None
|
||||
return url
|
||||
|
||||
|
||||
def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
||||
@@ -3867,6 +3923,14 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
||||
return None
|
||||
return LiteLLMSynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
||||
|
||||
if settings.provider == "openai-compatible":
|
||||
url = _validate_openai_compatible_config(
|
||||
settings.base_url, settings.model, settings.allow_insecure_http
|
||||
)
|
||||
if url is None:
|
||||
return None
|
||||
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=url, model=model)
|
||||
|
||||
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user