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