| from __future__ import annotations |
|
|
| import importlib |
|
|
| import pytest |
|
|
| from gcmd_classifier.config import ModelSettings |
| from gcmd_classifier.errors import NonRetryableModelError, RetryableModelError |
| from gcmd_classifier.llm import FakeModelClient, ModelRequest, ModelStage |
| from gcmd_classifier.llm.schemas import TopicResponse |
|
|
|
|
| def test_openai_provider_module_import_does_not_require_openai() -> None: |
| module = importlib.import_module("gcmd_classifier.llm.openai_provider") |
|
|
| assert hasattr(module, "OpenAIModelClient") |
|
|
|
|
| def test_openai_provider_is_isolated_from_fake_model_tests() -> None: |
| client = FakeModelClient([{"selected": [], "ambiguous_alternatives": []}]) |
| request = ModelRequest.from_settings( |
| stage=ModelStage.TOPIC, |
| prompt="prompt", |
| response_schema=TopicResponse, |
| settings=ModelSettings(), |
| ) |
|
|
| response = client.generate_structured(request) |
|
|
| assert response.provider == "fake" |
| assert response.parsed.selected == [] |
|
|
|
|
| def test_openai_provider_does_not_make_live_call_without_explicit_key( |
| monkeypatch: pytest.MonkeyPatch, |
| ) -> None: |
| monkeypatch.delenv("OPENAI_API_KEY", raising=False) |
| module = importlib.import_module("gcmd_classifier.llm.openai_provider") |
| client = module.OpenAIModelClient(ModelSettings(provider="openai", model_name="configured")) |
| request = ModelRequest.from_settings( |
| stage=ModelStage.TOPIC, |
| prompt="prompt", |
| response_schema=TopicResponse, |
| settings=ModelSettings(provider="openai", model_name="configured"), |
| ) |
|
|
| with pytest.raises(NonRetryableModelError): |
| client.generate_structured(request) |
|
|
|
|
| class _FakeCompletions: |
| def __init__(self, exc: Exception) -> None: |
| self._exc = exc |
|
|
| def parse(self, **kwargs): |
| raise self._exc |
|
|
|
|
| class _FakeOpenAIClient: |
| def __init__(self, exc: Exception) -> None: |
| self.beta = type( |
| "Beta", |
| (), |
| {"chat": type("Chat", (), {"completions": _FakeCompletions(exc)})()}, |
| )() |
|
|
|
|
| class _FakeOpenAIError(Exception): |
| status_code = 404 |
| body = { |
| "error": { |
| "message": "The model `gpt-5.5` does not exist. api_key=sk-secret123", |
| "type": "invalid_request_error", |
| "code": "model_not_found", |
| } |
| } |
|
|
| def __init__(self) -> None: |
| super().__init__("raw fallback with authorization Bearer secret-token") |
|
|
|
|
| class _FakeTimeout(TimeoutError): |
| status_code = 504 |
| code = "timeout" |
|
|
|
|
| def test_openai_provider_non_retryable_error_includes_sanitized_diagnostics() -> None: |
| module = importlib.import_module("gcmd_classifier.llm.openai_provider") |
| settings = ModelSettings(provider="openai", model_name="gpt-5.5") |
| client = module.OpenAIModelClient(settings, client=_FakeOpenAIClient(_FakeOpenAIError())) |
| request = ModelRequest.from_settings( |
| stage=ModelStage.TOPIC, |
| prompt="SECRET ABSTRACT SHOULD NOT APPEAR", |
| response_schema=TopicResponse, |
| settings=settings, |
| ) |
|
|
| with pytest.raises(NonRetryableModelError) as exc_info: |
| client.generate_structured(request) |
|
|
| message = str(exc_info.value) |
| assert "OpenAI provider call failed." in message |
| assert "exception_class=_FakeOpenAIError" in message |
| assert "http_status=404" in message |
| assert "openai_error_type=invalid_request_error" in message |
| assert "openai_error_code=model_not_found" in message |
| assert "gpt-5.5" in message |
| assert "sk-secret123" not in message |
| assert "secret-token" not in message |
| assert "SECRET ABSTRACT SHOULD NOT APPEAR" not in message |
| assert "authorization" not in message.lower() |
|
|
|
|
| def test_openai_provider_retryable_error_includes_sanitized_diagnostics() -> None: |
| module = importlib.import_module("gcmd_classifier.llm.openai_provider") |
| settings = ModelSettings(provider="openai", model_name="gpt-4o") |
| client = module.OpenAIModelClient(settings, client=_FakeOpenAIClient(_FakeTimeout("timeout"))) |
| request = ModelRequest.from_settings( |
| stage=ModelStage.TOPIC, |
| prompt="prompt should not appear", |
| response_schema=TopicResponse, |
| settings=settings, |
| ) |
|
|
| with pytest.raises(RetryableModelError) as exc_info: |
| client.generate_structured(request) |
|
|
| message = str(exc_info.value) |
| assert "Temporary OpenAI provider failure." in message |
| assert "exception_class=_FakeTimeout" in message |
| assert "http_status=504" in message |
| assert "openai_error_code=timeout" in message |
| assert "prompt should not appear" not in message |
|
|
|
|
| class _CaptureCompletions: |
| def __init__(self) -> None: |
| self.kwargs = None |
|
|
| def parse(self, **kwargs): |
| self.kwargs = kwargs |
| parsed = TopicResponse(selected=[]) |
| message = type("Message", (), {"parsed": parsed})() |
| choice = type("Choice", (), {"message": message})() |
| return type("Completion", (), {"choices": [choice], "usage": None})() |
|
|
|
|
| class _CaptureOpenAIClient: |
| def __init__(self) -> None: |
| self.completions = _CaptureCompletions() |
| self.beta = type( |
| "Beta", |
| (), |
| {"chat": type("Chat", (), {"completions": self.completions})()}, |
| )() |
|
|
|
|
| def test_gpt5_family_request_omits_temperature_when_configured_zero() -> None: |
| module = importlib.import_module("gcmd_classifier.llm.openai_provider") |
| settings = ModelSettings(provider="openai", model_name="gpt-5.5", temperature=0.0) |
| fake_openai = _CaptureOpenAIClient() |
| client = module.OpenAIModelClient(settings, client=fake_openai) |
| request = ModelRequest.from_settings( |
| stage=ModelStage.TOPIC, |
| prompt="prompt", |
| response_schema=TopicResponse, |
| settings=settings, |
| ) |
|
|
| response = client.generate_structured(request) |
|
|
| assert response.model_name == "gpt-5.5" |
| assert fake_openai.completions.kwargs is not None |
| assert fake_openai.completions.kwargs["model"] == "gpt-5.5" |
| assert "temperature" not in fake_openai.completions.kwargs |
|
|
|
|
| def test_gpt4o_request_preserves_temperature_behavior() -> None: |
| module = importlib.import_module("gcmd_classifier.llm.openai_provider") |
| settings = ModelSettings(provider="openai", model_name="gpt-4o", temperature=0.0) |
| fake_openai = _CaptureOpenAIClient() |
| client = module.OpenAIModelClient(settings, client=fake_openai) |
| request = ModelRequest.from_settings( |
| stage=ModelStage.TOPIC, |
| prompt="prompt", |
| response_schema=TopicResponse, |
| settings=settings, |
| ) |
|
|
| response = client.generate_structured(request) |
|
|
| assert response.model_name == "gpt-4o" |
| assert fake_openai.completions.kwargs is not None |
| assert fake_openai.completions.kwargs["model"] == "gpt-4o" |
| assert fake_openai.completions.kwargs["temperature"] == 0.0 |
|
|