File size: 6,819 Bytes
d840c10
 
 
 
 
 
 
68cf134
d840c10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68cf134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
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