From 13e058f22c791218614133fe9bb49946c3abf8c9 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Mon, 7 Sep 2026 19:44:40 +0200 Subject: [PATCH] feat: add openai-compatible provider for custom AI endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: opencode/test-engineer anthropic.claude-sonnet-4-5 Co-authored-by: opencode/tech-writer anthropic.claude-sonnet-4-5 --- .env.example | 14 ++ .secrets.baseline | 4 +- GUIDE_DEV_PYTHON.md | 66 +++++++++- pronote_sync/config/settings.py | 6 +- pronote_sync/synthesis/__init__.py | 84 +++++++++++- pyproject.toml | 5 + tests/unit/test_synthesis.py | 205 +++++++++++++++++++++++++++++ 7 files changed, 378 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index fa52d58..91226d2 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,20 @@ AI_BASE_URL=https://api.openai.com/v1 # AI_API_KEY= # AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut +# Exemple : OpenRouter (HTTPS) +# AI_PROVIDER=openai-compatible +# AI_BASE_URL=https://openrouter.ai/api/v1 +# AI_MODEL=fournisseur/modele +# AI_API_KEY=your-openrouter-key +# AI_ALLOW_INSECURE_HTTP=false + +# Exemple : Ollama local (HTTP, sans authentification réelle) +# AI_PROVIDER=openai-compatible +# AI_BASE_URL=http://127.0.0.1:11434/v1 +# AI_MODEL=modele-local +# AI_API_KEY=local-not-required +# AI_ALLOW_INSECURE_HTTP=true + # --- Blog --- BLOG_ENABLED=false BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2 diff --git a/.secrets.baseline b/.secrets.baseline index 67fc948..bf7f7eb 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -140,7 +140,7 @@ "filename": "GUIDE_DEV_PYTHON.md", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "is_verified": true, - "line_number": 4852, + "line_number": 4916, "is_secret": false } ], @@ -177,5 +177,5 @@ } ] }, - "generated_at": "2026-09-07T17:01:01Z" + "generated_at": "2026-09-07T17:24:40Z" } diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index f7db8c1..17f146b 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -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) ``` diff --git a/pronote_sync/config/settings.py b/pronote_sync/config/settings.py index 6dea995..f81402d 100644 --- a/pronote_sync/config/settings.py +++ b/pronote_sync/config/settings.py @@ -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 diff --git a/pronote_sync/synthesis/__init__.py b/pronote_sync/synthesis/__init__.py index d4362ed..b95eb7a 100644 --- a/pronote_sync/synthesis/__init__.py +++ b/pronote_sync/synthesis/__init__.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 9440163..94759ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,3 +120,8 @@ strict = true [[tool.mypy.overrides]] module = "litellm" ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "openai.*" +follow_imports = "skip" +ignore_missing_imports = true diff --git a/tests/unit/test_synthesis.py b/tests/unit/test_synthesis.py index c4e899b..41b0f1c 100644 --- a/tests/unit/test_synthesis.py +++ b/tests/unit/test_synthesis.py @@ -786,3 +786,208 @@ def test_validate_output_truncated_to_800(mocker: MockerFixture, target_date: da assert result is not None assert result.text == "A" * 800 + + +# --- Tests pour openai-compatible (FEAT_M9 §6) --- + + +def test_openai_provider_without_base_url_preserves_existing_behavior() -> None: + """Vérifie que 'openai' sans AI_BASE_URL conserve le comportement existant.""" + settings = AISettings(enabled=True, api_key=SecretStr("test"), provider="openai") + result = get_synthesis_provider(settings) + assert isinstance(result, OpenAISynthesisProvider) + + +def test_openai_compatible_passes_base_url_and_model() -> None: + """Vérifie que 'openai-compatible' transmet base_url et model au provider.""" + settings = AISettings( + enabled=True, + api_key=SecretStr("test"), + provider="openai-compatible", + base_url="https://api.example.com/v1", + model="test-model", + ) + result = get_synthesis_provider(settings) + assert isinstance(result, OpenAISynthesisProvider) + assert result._model == "test-model" + + +def test_openai_compatible_litellm_proxy_without_importing_litellm() -> None: + """Vérifie que LiteLLM en tant que proxy est traité comme un endpoint compatible.""" + settings = AISettings( + enabled=True, + api_key=SecretStr("test"), + provider="openai-compatible", + base_url="https://proxy.litellm.local/v1", + model="test", + ) + result = get_synthesis_provider(settings) + assert isinstance(result, OpenAISynthesisProvider) + # Vérifier que le provider n'est pas LiteLLMSynthesisProvider + assert result.__class__.__name__ == "OpenAISynthesisProvider" + + +def test_openai_compatible_missing_base_url_returns_none_with_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """Vérifie que base_url absente retourne None + warning.""" + settings = AISettings( + enabled=True, + api_key=SecretStr("test"), + provider="openai-compatible", + base_url=None, + model="test", + ) + result = get_synthesis_provider(settings) + assert result is None + assert "URL de base requise pour le provider openai-compatible" in caplog.text + + +def test_openai_compatible_missing_model_returns_none_with_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """Vérifie que model absent retourne None + warning.""" + settings = AISettings( + enabled=True, + api_key=SecretStr("test"), + provider="openai-compatible", + base_url="https://api.example.com/v1", + model=None, + ) + result = get_synthesis_provider(settings) + assert result is None + assert "Modèle requis pour le provider openai-compatible" in caplog.text + + +def test_openai_compatible_valid_https_url_accepted() -> None: + """Vérifie qu'une URL HTTPS valide est acceptée.""" + settings = AISettings( + enabled=True, + api_key=SecretStr("test"), + provider="openai-compatible", + base_url="https://api.openrouter.ai/api/v1", + model="test-model", + ) + result = get_synthesis_provider(settings) + assert isinstance(result, OpenAISynthesisProvider) + + +def test_openai_compatible_http_refused_by_default( + caplog: pytest.LogCaptureFixture, +) -> None: + """Vérifie que HTTP est refusé par défaut.""" + settings = AISettings( + enabled=True, + api_key=SecretStr("test"), + provider="openai-compatible", + base_url="http://127.0.0.1:11434/v1", + model="test-model", + allow_insecure_http=False, + ) + result = get_synthesis_provider(settings) + assert result is None + assert "URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true" in caplog.text + + +def test_openai_compatible_http_accepted_with_allow_insecure_http() -> None: + """Vérifie que HTTP est accepté avec allow_insecure_http=True.""" + settings = AISettings( + enabled=True, + api_key=SecretStr("test"), + provider="openai-compatible", + base_url="http://127.0.0.1:11434/v1", + model="test-model", + allow_insecure_http=True, + ) + result = get_synthesis_provider(settings) + assert isinstance(result, OpenAISynthesisProvider) + + +def test_openai_compatible_credentials_in_url_refused( + caplog: pytest.LogCaptureFixture, +) -> None: + """Vérifie que les credentials dans l'URL sont refusés.""" + settings = AISettings( + enabled=True, + api_key=SecretStr("test"), + provider="openai-compatible", + base_url="https://user:pass@host/v1", # pragma: allowlist secret + model="test-model", + ) + result = get_synthesis_provider(settings) + assert result is None + assert "Credentials dans l'URL refusés" in caplog.text + + +def test_openai_compatible_sensitive_query_params_refused( + caplog: pytest.LogCaptureFixture, +) -> None: + """Vérifie que les query params sensibles sont refusés.""" + settings = AISettings( + enabled=True, + api_key=SecretStr("test"), + provider="openai-compatible", + base_url="https://host/v1?token=secret", + model="test-model", + ) + result = get_synthesis_provider(settings) + assert result is None + assert "Paramètres sensibles dans l'URL refusés" in caplog.text + + +def test_openai_compatible_connection_error_returns_none( + mocker: MockerFixture, + target_date: date, +) -> None: + """Vérifie qu'une erreur de connexion retourne None.""" + mock_client = MagicMock() + mock_client.chat.completions.create.side_effect = Exception("connection error") + + provider = OpenAISynthesisProvider( + api_key=SecretStr("test-key"), + base_url="https://api.example.com/v1", + model="test-model", + client=mock_client, + ) + input_data = SynthesisInput(target_date=target_date, agenda_diff=None) + result = provider.generate(input_data) + + assert result is None + + +def test_openai_compatible_sentinel_key_not_in_logs( + caplog: pytest.LogCaptureFixture, +) -> None: + """Vérifie qu'une clé sentinelle est absente des logs.""" + sentinel = "sk-SENTINEL-CUSTOM-12345" + settings = AISettings( + enabled=True, + api_key=SecretStr(sentinel), + provider="openai-compatible", + base_url=None, + model="test", + ) + result = get_synthesis_provider(settings) + assert result is None + assert sentinel not in caplog.text + + +def test_openai_compatible_factory_no_network_calls( + mocker: MockerFixture, +) -> None: + """Vérifie que la factory ne fait aucun appel réseau.""" + # Mock des appels réseau pour s'assurer qu'ils ne sont pas appelés + mock_get = mocker.patch("requests.get") + mock_post = mocker.patch("requests.post") + + settings = AISettings( + enabled=True, + api_key=SecretStr("test"), + provider="openai-compatible", + base_url="https://api.example.com/v1", + model="test-model", + ) + result = get_synthesis_provider(settings) + assert isinstance(result, OpenAISynthesisProvider) + mock_get.assert_not_called() + mock_post.assert_not_called()