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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user