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:
@@ -149,14 +149,18 @@ class AISettings(BaseSettings):
|
||||
"""Paramètres de la synthèse par IA (désactivée par défaut).
|
||||
|
||||
Les variables d'environnement correspondantes sont préfixées par ``AI_``.
|
||||
Le provider ``openai-compatible`` permet d'utiliser n'importe quelle API
|
||||
compatible OpenAI via ``AI_BASE_URL`` ; les URLs en HTTP ne sont alors
|
||||
acceptées que si ``AI_ALLOW_INSECURE_HTTP`` vaut ``true``.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="AI_")
|
||||
|
||||
enabled: bool = False
|
||||
provider: Literal["openai", "litellm"] = "openai"
|
||||
provider: Literal["openai", "litellm", "openai-compatible"] = "openai"
|
||||
base_url: str | None = None
|
||||
api_key: SecretStr | None = None
|
||||
allow_insecure_http: bool = False
|
||||
model: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -3,26 +3,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from urllib.parse import parse_qsl, urlparse
|
||||
|
||||
from pronote_sync.config.settings import AISettings
|
||||
from pronote_sync.synthesis.openai import OpenAISynthesisProvider
|
||||
from pronote_sync.synthesis.provider import SynthesisProvider
|
||||
from pronote_sync.utils.redaction import redact_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["get_synthesis_provider", "SynthesisProvider", "OpenAISynthesisProvider"]
|
||||
|
||||
|
||||
def _validate_openai_compatible_config(
|
||||
url: str | None, model: str | None, allow_insecure_http: bool
|
||||
) -> str | None:
|
||||
"""Valide la configuration du provider ``openai-compatible``.
|
||||
|
||||
Vérifie la présence de l'URL de base et du modèle, le schéma de l'URL
|
||||
(HTTPS obligatoire, HTTP accepté uniquement si ``allow_insecure_http``
|
||||
vaut ``True``), la présence d'un hostname non vide, l'absence
|
||||
d'identifiants dans le netloc et de paramètres sensibles dans la
|
||||
requête (y compris les paramètres sans valeur). Une URL malformée
|
||||
(``ValueError`` levé par ``urlparse``) est également rejetée. En cas
|
||||
d'échec, un avertissement est journalisé (l'URL est toujours masquée
|
||||
via :func:`redact_url`) et ``None`` est retourné : la synthèse IA se
|
||||
dégrade silencieusement, sans jamais lever d'exception.
|
||||
|
||||
:param url: URL de base de l'API compatible OpenAI.
|
||||
:param model: Identifiant du modèle à utiliser.
|
||||
:param allow_insecure_http: Autorise ou non les URLs en HTTP.
|
||||
:return: L'URL validée, inchangée (aucune manipulation du chemin ou du
|
||||
suffixe ``/v1``), ou ``None`` si la configuration est invalide.
|
||||
:rtype: str | None
|
||||
"""
|
||||
if not url:
|
||||
logger.warning("URL de base requise pour le provider openai-compatible")
|
||||
return None
|
||||
if not model:
|
||||
logger.warning("Modèle requis pour le provider openai-compatible")
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"URL invalide pour le provider openai-compatible : %s",
|
||||
redact_url(url),
|
||||
)
|
||||
return None
|
||||
if not parsed.hostname:
|
||||
logger.warning(
|
||||
"URL sans hostname pour le provider openai-compatible : %s",
|
||||
redact_url(url),
|
||||
)
|
||||
return None
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
logger.warning(
|
||||
"Schéma d'URL non supporté pour le provider openai-compatible : %s",
|
||||
redact_url(url),
|
||||
)
|
||||
return None
|
||||
if parsed.scheme == "http" and not allow_insecure_http:
|
||||
logger.warning(
|
||||
"URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true : %s",
|
||||
redact_url(url),
|
||||
)
|
||||
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:
|
||||
"""Sélectionne le fournisseur de synthèse IA selon la configuration.
|
||||
|
||||
Retourne ``None`` lorsque la synthèse IA est désactivée ou qu'aucune clé
|
||||
API n'est configurée. Pour le provider ``litellm``, le paquet ``litellm``
|
||||
(extra ``ai-litellm``) est requis : s'il est absent, un avertissement est
|
||||
journalisé et ``None`` est retourné.
|
||||
journalisé et ``None`` est retourné. Pour le provider
|
||||
``openai-compatible``, la configuration (URL de base et modèle) est
|
||||
validée par :func:`_validate_openai_compatible_config` ; en cas de
|
||||
rejet, ``None`` est retourné avec un avertissement.
|
||||
|
||||
:param settings: Paramètres IA.
|
||||
:return: Le fournisseur configuré, ou ``None`` si désactivé ou sans clé API.
|
||||
:return: Le fournisseur configuré, ou ``None`` si désactivé, sans clé API
|
||||
ou avec une configuration ``openai-compatible`` invalide.
|
||||
:rtype: SynthesisProvider | None
|
||||
"""
|
||||
if not settings.enabled:
|
||||
@@ -41,4 +113,12 @@ 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