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:
14
.env.example
14
.env.example
@@ -49,6 +49,20 @@ AI_BASE_URL=https://api.openai.com/v1
|
|||||||
# AI_API_KEY=
|
# AI_API_KEY=
|
||||||
# AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut
|
# 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 ---
|
||||||
BLOG_ENABLED=false
|
BLOG_ENABLED=false
|
||||||
BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
|
BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
|
||||||
|
|||||||
@@ -140,7 +140,7 @@
|
|||||||
"filename": "GUIDE_DEV_PYTHON.md",
|
"filename": "GUIDE_DEV_PYTHON.md",
|
||||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||||
"is_verified": true,
|
"is_verified": true,
|
||||||
"line_number": 4852,
|
"line_number": 4916,
|
||||||
"is_secret": false
|
"is_secret": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -177,5 +177,5 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"generated_at": "2026-09-07T17:01:01Z"
|
"generated_at": "2026-09-07T17:24:40Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`.
|
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")`.
|
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
|
```python
|
||||||
|
import logging
|
||||||
|
from urllib.parse import parse_qsl, urlparse
|
||||||
|
|
||||||
from ..config.settings import AISettings
|
from ..config.settings import AISettings
|
||||||
from .provider import SynthesisProvider
|
from .provider import SynthesisProvider
|
||||||
from .openai import OpenAISynthesisProvider
|
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:
|
def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
||||||
@@ -3867,6 +3923,14 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
|||||||
return None
|
return None
|
||||||
return LiteLLMSynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
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)
|
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -149,14 +149,18 @@ class AISettings(BaseSettings):
|
|||||||
"""Paramètres de la synthèse par IA (désactivée par défaut).
|
"""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_``.
|
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_")
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="AI_")
|
||||||
|
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
provider: Literal["openai", "litellm"] = "openai"
|
provider: Literal["openai", "litellm", "openai-compatible"] = "openai"
|
||||||
base_url: str | None = None
|
base_url: str | None = None
|
||||||
api_key: SecretStr | None = None
|
api_key: SecretStr | None = None
|
||||||
|
allow_insecure_http: bool = False
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,26 +3,98 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from urllib.parse import parse_qsl, urlparse
|
||||||
|
|
||||||
from pronote_sync.config.settings import AISettings
|
from pronote_sync.config.settings import AISettings
|
||||||
from pronote_sync.synthesis.openai import OpenAISynthesisProvider
|
from pronote_sync.synthesis.openai import OpenAISynthesisProvider
|
||||||
from pronote_sync.synthesis.provider import SynthesisProvider
|
from pronote_sync.synthesis.provider import SynthesisProvider
|
||||||
|
from pronote_sync.utils.redaction import redact_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = ["get_synthesis_provider", "SynthesisProvider", "OpenAISynthesisProvider"]
|
__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:
|
def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
||||||
"""Sélectionne le fournisseur de synthèse IA selon la configuration.
|
"""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é
|
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``
|
API n'est configurée. Pour le provider ``litellm``, le paquet ``litellm``
|
||||||
(extra ``ai-litellm``) est requis : s'il est absent, un avertissement est
|
(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.
|
: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
|
:rtype: SynthesisProvider | None
|
||||||
"""
|
"""
|
||||||
if not settings.enabled:
|
if not settings.enabled:
|
||||||
@@ -41,4 +113,12 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
|||||||
return None
|
return None
|
||||||
return LiteLLMSynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
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)
|
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
||||||
|
|||||||
@@ -120,3 +120,8 @@ strict = true
|
|||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = "litellm"
|
module = "litellm"
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "openai.*"
|
||||||
|
follow_imports = "skip"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|||||||
@@ -786,3 +786,208 @@ def test_validate_output_truncated_to_800(mocker: MockerFixture, target_date: da
|
|||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.text == "A" * 800
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user