merge: provider openai-compatible pour la synthèse IA (FEAT_M9)

This commit is contained in:
2026-09-07 20:00:02 +02:00
9 changed files with 426 additions and 13 deletions

View File

@@ -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

View File

@@ -140,7 +140,7 @@
"filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": true,
"line_number": 4852,
"line_number": 4935,
"is_secret": false
}
],
@@ -177,5 +177,5 @@
}
]
},
"generated_at": "2026-09-07T17:01:01Z"
"generated_at": "2026-09-07T17:59:08Z"
}

View File

@@ -146,6 +146,17 @@ pronote-sync --dry-run
- Réutiliser un téléchargement/parsing iCal pour l'agenda et les devoirs pendant un même run, sans
cache global ni persistant.
### Contrat du provider `openai-compatible`
- Le provider `openai-compatible` réutilise `OpenAISynthesisProvider` avec un `base_url` personnalisé ; aucun nouveau provider n'est créé.
- `AI_BASE_URL` et `AI_MODEL` sont requis ; `AI_API_KEY` est requis (MVP).
- L'URL doit utiliser `https` sauf si `AI_ALLOW_INSECURE_HTTP=true`.
- Les credentials dans l'URL (`user:pass@host`) sont refusés.
- Les paramètres sensibles dans la *query string* sont refusés, y compris ceux sans valeur (`?token`).
- Les URL malformées ou sans hostname sont rejetées (`ValueError` catché).
- Aucune manipulation automatique de `/v1` n'est effectuée.
- Configuration incomplète ou invalide → `None` avec avertissement (mode dégradé) ; la factory ne lève jamais d'exception.
- La factory ne fait aucun appel réseau ; les avertissements utilisent `redact_url()`.
### Documentation (docstrings)
- **Obligatoire** : **Toute** fonction, méthode et classe publique doit avoir une docstring.
- **Format** : Utiliser le format **Sphinx/reST** (pas Google ou NumPy) pour une compatibilité native avec Sphinx.

View File

@@ -6,6 +6,7 @@
> **⚠️ À noter** : Ce guide est **volontairement détaillé** pour préserver les connaissances acquises sur les spécificités des flux Pronote (iCal) et les décisions architecturales du projet TypeScript. Certaines sections (ex: parsing iCal) contiennent des **observations précises** issues de l'analyse du code existant.
> **Mises à jour récentes** :
> - Ajout du provider ``openai-compatible`` dans la section **[3. Configuration](#3-configuration-denvironnement)** (variables §3.1.2, modèle §3.2, exemple §3.1.3) et la factory **[§9.5](#95-factory-pour-les-fournisseurs-ia-synthesis__init__py)** pour supporter les endpoints compatibles OpenAI (OpenRouter, Ollama, proxy LiteLLM) avec validation stricte de l'URL et opt-in HTTP.
> - Ajout de la section **[5 bis. Sources externes : blog du collège (RSS)](#5-bis-sources-externes--blog-du-collège-rss)** pour le parsing du flux RSS du blog.
> - Mise à jour de la section **[10. Envoi XMPP](#10-envoi-xmpp)** avec la décision architecturale (compte bot dédié, messages directs, pas de PubSub).
> - Intégration des modèles `BlogArticle` et `ExternalInfo` dans la section **[6. Modèle de données Pydantic](#6-modèle-de-données-pydantic)**.
@@ -299,22 +300,23 @@ d'un besoin réel et testé.
| `PRONOTE_MESSAGES_SOURCE` | Source pour les messages (`pronotepy` uniquement). | `pronotepy` | `Literal` |
| `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV. | `7` | `int` |
| `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV. | `30` | `int` |
> ⚠️ **Décision d'implémentation** :
> Ces variables sont désormais dans `AppSettings` (et non `CalDAVSettings`) car `CalDAVSettings` utilise `env_prefix="CALDAV_"`, ce qui nécessiterait `CALDAV_SYNC_PAST_DAYS`.
> Leur placement dans `AppSettings` (sans préfixe) garantit un mappage correct avec `SYNC_PAST_DAYS` / `SYNC_FUTURE_DAYS`.
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier JSON de l'agenda théorique. | `None` | `str \| None`|
| `SCHOOL_HOLIDAYS_PATH` | Chemin vers le fichier JSON des vacances scolaires. | `None` | `str \| None`|
| `THEORETICAL_WEEK_ANCHOR_DATE` | Date de référence pour la parité des semaines (paire/impaire). | `None` | `date \| None`|
| `THEORETICAL_WEEK_ANCHOR_TYPE` | Parité de la semaine de référence (`even` ou `odd`). | `None` | `Literal["even", "odd"] \| None`|
| `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` |
| `AI_PROVIDER` | Fournisseur IA (`openai` ou `litellm`). | `openai` | `str` |
| `AI_PROVIDER` | Fournisseur IA (`openai`, `openai-compatible` ou `litellm`). | `openai` | `Literal["openai", "litellm", "openai-compatible"]` |
| `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`|
| `AI_API_KEY` | Clé API pour l'API IA. | `None` | `SecretStr` |
| `AI_MODEL` | Modèle IA à utiliser (exemple recommandé : `gpt-4o-mini`). | `None` | `str \| None`|
| `AI_ALLOW_INSECURE_HTTP` | Autoriser HTTP (non sécurisé) pour `openai-compatible` uniquement. | `False` | `bool` |
| `DRY_RUN` | Mode dry-run (pas de modifications CalDAV/XMPP). | `False` | `bool` |
| `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` |
> ⚠️ **Décision d'implémentation** :
> Ces variables sont désormais dans `AppSettings` (et non `CalDAVSettings`) car `CalDAVSettings` utilise `env_prefix="CALDAV_"`, ce qui nécessiterait `CALDAV_SYNC_PAST_DAYS`.
> Leur placement dans `AppSettings` (sans préfixe) garantit un mappage correct avec `SYNC_PAST_DAYS` / `SYNC_FUTURE_DAYS`.
#### 3.1.3 Exemple de fichier `.env.example`
@@ -360,6 +362,20 @@ AI_BASE_URL=https://api.openai.com/v1
AI_API_KEY=your_ai_api_key
# AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut
# Exemple : OpenRouter (HTTPS, provider openai-compatible)
# 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, provider openai-compatible)
# 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
# --- Divers ---
DRY_RUN=false
LOG_LEVEL=INFO
@@ -376,6 +392,8 @@ LOG_LEVEL=INFO
> `sync_past_days` et `sync_future_days` sont dans `AppSettings`, et non `CalDAVSettings`.
> `CalDAVSettings.calendar_path` a pour valeur par défaut `"/pronote-sync/"`.
> `XmppSettings.resource` a pour valeur par défaut `"pronote-sync"`.
> > ``AISettings.provider`` accepte également ``openai-compatible`` (réutilise ``OpenAISynthesisProvider`` avec un ``base_url`` personnalisé).
> > ``AISettings.allow_insecure_http`` (défaut ``False``) autorise les URLs HTTP pour le provider ``openai-compatible`` uniquement.
```python
from typing import Literal
@@ -409,9 +427,10 @@ class CalDAVSettings(BaseSettings):
class AISettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="AI_", env_file=".env", extra="ignore")
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
@@ -3829,14 +3848,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 +3942,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)
```

13
TODO.md
View File

@@ -176,7 +176,7 @@ Générer une synthèse optionnelle via un fournisseur IA, avec mode dégradé s
- [x] Créer `synthesis/provider.py` : protocole `SynthesisProvider.generate → Optional[SynthesisResult]` (ne lève jamais d'exception).
- [x] Créer `synthesis/openai.py` : `OpenAISynthesisProvider` (httpx, prompt système FR, max 800 car., timeout 30 s, temp 0.3).
- [x] Créer `synthesis/litellm.py` : `LiteLLMSynthesisProvider` (optionnel, extra `ai-litellm`).
- [x] Créer `synthesis/__init__.py` : factory `get_synthesis_provider(settings)` (OpenAI par défaut, litellm si `AI_PROVIDER=litellm`).
- [x] Créer `synthesis/__init__.py` : factory `get_synthesis_provider(settings)` (OpenAI par défaut, litellm si `AI_PROVIDER=litellm`, `openai-compatible` si `AI_PROVIDER=openai-compatible` avec validation d'URL).
- [x] Mode dégradé : clé absente / timeout / exception → retour `None` (le pipeline continue sans synthèse).
- [x] Respecter les contraintes (3-5 phrases, ton sobre, pas d'emoji dans le texte IA).
@@ -185,6 +185,17 @@ Générer une synthèse optionnelle via un fournisseur IA, avec mode dégradé s
- Clé absente ou erreur réseau → `None` (aucune exception propagée).
- La factory renvoie le bon provider ; litellm derrière l'extra optionnel.
> **Évolution FEAT_M9 — Provider `openai-compatible`** :
> Le provider `openai-compatible` a été ajouté à `get_synthesis_provider` (commit `13e058f` sur `feat/m9-custom-endpoint`).
> Il réutilise `OpenAISynthesisProvider` avec un `base_url` validé (HTTPS obligatoire, HTTP via `AI_ALLOW_INSECURE_HTTP=true`).
> Configuration incomplète → `None` + warning (mode dégradé). Aucun appel réseau à la factory.
> Couverture synthesis : 91,57 % (13 tests factory ajoutés).
>
> **Corrections FIXME_M9 — Audit synthèse IA** :
> Cinq points d'audit corrigés (commit `19cbf8f` sur `fix/m9-fixme`, mergé en `2a27225`) :
> `redact_secrets(extra_secrets=...)`, `SecretStr` préservé dans les providers, contenu du message dans `_build_prompt`,
> `_validate_output` (rejet emoji/titre/liste/HTML), `importorskip` pour les tests litellm.
---
## M10. Canal XMPP — Priorité : Haute

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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()