Skip to content

Commit a6a3fd5

Browse files
committed
test(llm): add recorded fixtures and live integration tests
Lands end-to-end coverage for the new client/model stack. record_fixtures.py captures real OpenAI and NIM responses (generate/stream x text/tool_call/ reasoning) once, producing the JSON fixtures under fixtures/. The live test file runs those fixtures through a mocked httpx transport for deterministic CI coverage, and has an opt-in suite that hits the real OpenAI and NIM endpoints when API keys are present. Separated from the protocol-layer commit so reviewers can skim the model code without scrolling through ~1200 lines of recorded JSON.
1 parent 7d8c724 commit a6a3fd5

21 files changed

Lines changed: 4317 additions & 0 deletions

tests/llm/clients/_helpers.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,25 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515

16+
import json
17+
import os
1618
from contextlib import asynccontextmanager
19+
from pathlib import Path
1720

1821
import httpx
1922

23+
from nemoguardrails.llm.clients.openai_chat_model import OpenAIChatModel
2024
from nemoguardrails.llm.clients.openai_compatible import OpenAICompatibleClient
2125

26+
_FIXTURES_DIR = Path(__file__).parent / "fixtures"
27+
28+
LIVE_TEST_MODE = bool(os.environ.get("LIVE_TEST_MODE") or os.environ.get("TEST_LIVE_MODE"))
29+
30+
OPENAI_BASE_URL = "https://api.openai.com/v1"
31+
OPENAI_DEFAULT_MODEL = "gpt-4o-mini"
32+
NIM_BASE_URL = "https://integrate.api.nvidia.com/v1"
33+
NIM_DEFAULT_MODEL = "nvidia/nemotron-3-nano-30b-a3b"
34+
2235

2336
def make_client(**kwargs):
2437
return OpenAICompatibleClient(base_url="https://api.openai.com/v1", api_key="sk-test", **kwargs)
@@ -117,3 +130,151 @@ async def aiter_lines(self):
117130
yield FakeResponse()
118131

119132
return mock, aread_calls
133+
134+
135+
def load_fixture(name):
136+
with open(_FIXTURES_DIR / name) as f:
137+
return json.load(f)
138+
139+
140+
def fixture_exists(name):
141+
return (_FIXTURES_DIR / name).exists()
142+
143+
144+
def _fixture_to_response(data, request):
145+
if isinstance(data, list):
146+
body = "".join(f"data: {json.dumps(c)}\n\n" for c in data) + "data: [DONE]\n\n"
147+
return httpx.Response(
148+
200,
149+
content=body.encode("utf-8"),
150+
headers={"content-type": "text/event-stream"},
151+
request=request,
152+
)
153+
if isinstance(data, dict) and "status_code" in data and "body" in data:
154+
body = data.get("body")
155+
content = json.dumps(body).encode("utf-8") if body is not None else b""
156+
return httpx.Response(
157+
data["status_code"],
158+
content=content,
159+
headers=data.get("response_headers") or {},
160+
request=request,
161+
)
162+
if isinstance(data, dict):
163+
return httpx.Response(200, json=data, request=request)
164+
raise ValueError(f"Unknown fixture shape: {type(data).__name__}")
165+
166+
167+
def _resolve_fixture(fixture_or_name):
168+
return load_fixture(fixture_or_name) if isinstance(fixture_or_name, str) else fixture_or_name
169+
170+
171+
def fixture_transport(fixture_or_name, on_request=None):
172+
"""Build an httpx.MockTransport that serves a fixture on each request."""
173+
data = _resolve_fixture(fixture_or_name)
174+
175+
def handler(request):
176+
if on_request is not None:
177+
on_request(request)
178+
return _fixture_to_response(data, request)
179+
180+
return httpx.MockTransport(handler)
181+
182+
183+
def sequenced_fixture_transport(fixtures_or_names, on_request=None):
184+
"""Serve a sequence of fixtures, one per request. For multi-turn tests."""
185+
data_list = [_resolve_fixture(f) for f in fixtures_or_names]
186+
idx = [0]
187+
188+
def handler(request):
189+
if on_request is not None:
190+
on_request(request)
191+
if idx[0] >= len(data_list):
192+
raise AssertionError(
193+
f"sequenced_fixture_transport: request {idx[0] + 1} exceeds fixture count ({len(data_list)})"
194+
)
195+
response = _fixture_to_response(data_list[idx[0]], request)
196+
idx[0] += 1
197+
return response
198+
199+
return httpx.MockTransport(handler)
200+
201+
202+
@asynccontextmanager
203+
async def simulated_model(
204+
fixture_or_name,
205+
*,
206+
model_name="gpt-4o-mini",
207+
base_url="https://api.openai.com/v1",
208+
api_key="sk-test-simulated",
209+
on_request=None,
210+
transport=None,
211+
**client_kwargs,
212+
):
213+
"""Context manager yielding an OpenAIChatModel whose HTTP transport is
214+
backed by a fixture. Exercises the full client stack (serialization,
215+
httpx, SSE parsing, response parsing) without a real API call.
216+
"""
217+
if transport is None:
218+
transport = fixture_transport(fixture_or_name, on_request=on_request)
219+
async with httpx.AsyncClient(transport=transport) as http:
220+
client = OpenAICompatibleClient(
221+
base_url=base_url,
222+
api_key=api_key,
223+
http_client=http,
224+
max_retries=0,
225+
**client_kwargs,
226+
)
227+
yield OpenAIChatModel(client=client, model=model_name)
228+
229+
230+
@asynccontextmanager
231+
async def simulated_model_sequenced(
232+
fixtures,
233+
*,
234+
model_name="gpt-4o-mini",
235+
base_url="https://api.openai.com/v1",
236+
on_request=None,
237+
**client_kwargs,
238+
):
239+
"""Like simulated_model but serves a sequence of fixtures (for multi-turn)."""
240+
transport = sequenced_fixture_transport(fixtures, on_request=on_request)
241+
async with simulated_model(
242+
None,
243+
model_name=model_name,
244+
base_url=base_url,
245+
on_request=on_request,
246+
transport=transport,
247+
**client_kwargs,
248+
) as model:
249+
yield model
250+
251+
252+
def live_mode_enabled(provider: str) -> bool:
253+
if not LIVE_TEST_MODE:
254+
return False
255+
env_var = {"openai": "OPENAI_API_KEY", "nim": "NVIDIA_API_KEY"}[provider]
256+
return bool(os.environ.get(env_var))
257+
258+
259+
@asynccontextmanager
260+
async def live_openai_model(*, model_name=OPENAI_DEFAULT_MODEL):
261+
"""Yield an OpenAIChatModel backed by the real OpenAI API."""
262+
async with httpx.AsyncClient() as http:
263+
client = OpenAICompatibleClient(
264+
base_url=OPENAI_BASE_URL,
265+
api_key=os.environ.get("OPENAI_API_KEY"),
266+
http_client=http,
267+
)
268+
yield OpenAIChatModel(client=client, model=model_name)
269+
270+
271+
@asynccontextmanager
272+
async def live_nim_model(*, model_name=NIM_DEFAULT_MODEL):
273+
"""Yield a NIM OpenAIChatModel backed by the real NIM API."""
274+
async with httpx.AsyncClient() as http:
275+
client = OpenAICompatibleClient(
276+
base_url=NIM_BASE_URL,
277+
api_key=os.environ.get("NVIDIA_API_KEY"),
278+
http_client=http,
279+
)
280+
yield OpenAIChatModel(client=client, model=model_name)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"id": "chatcmpl-bd57cedab69e3cdc",
3+
"object": "chat.completion",
4+
"created": 1776675713,
5+
"model": "nvidia/nemotron-3-nano-30b-a3b",
6+
"choices": [
7+
{
8+
"index": 0,
9+
"message": {
10+
"role": "assistant",
11+
"content": "4",
12+
"refusal": null,
13+
"annotations": null,
14+
"audio": null,
15+
"function_call": null,
16+
"tool_calls": [],
17+
"reasoning": "The user asks a simple math question. Answer: 4.\n",
18+
"reasoning_content": "The user asks a simple math question. Answer: 4.\n"
19+
},
20+
"logprobs": null,
21+
"finish_reason": "stop",
22+
"stop_reason": null,
23+
"token_ids": null
24+
}
25+
],
26+
"service_tier": null,
27+
"system_fingerprint": null,
28+
"usage": {
29+
"prompt_tokens": 23,
30+
"total_tokens": 40,
31+
"completion_tokens": 17,
32+
"prompt_tokens_details": null
33+
},
34+
"prompt_logprobs": null,
35+
"prompt_token_ids": null,
36+
"kv_transfer_params": null,
37+
"_response_headers": {
38+
"date": "Mon, 20 Apr 2026 09:01:54 GMT",
39+
"content-type": "application/json",
40+
"content-length": "707",
41+
"connection": "keep-alive",
42+
"access-control-expose-headers": "nvcf-reqid",
43+
"nvcf-reqid": "9db34295-fda8-49d1-85fc-911fe4356626",
44+
"nvcf-status": "fulfilled",
45+
"server": "uvicorn",
46+
"vary": "Origin"
47+
}
48+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"id": "chatcmpl-81761a3388cfdd35",
3+
"object": "chat.completion",
4+
"created": 1776675712,
5+
"model": "nvidia/nemotron-3-nano-30b-a3b",
6+
"choices": [
7+
{
8+
"index": 0,
9+
"message": {
10+
"role": "assistant",
11+
"content": "Hey",
12+
"refusal": null,
13+
"annotations": null,
14+
"audio": null,
15+
"function_call": null,
16+
"tool_calls": [],
17+
"reasoning": null,
18+
"reasoning_content": null
19+
},
20+
"logprobs": null,
21+
"finish_reason": "stop",
22+
"stop_reason": null,
23+
"token_ids": null
24+
}
25+
],
26+
"service_tier": null,
27+
"system_fingerprint": null,
28+
"usage": {
29+
"prompt_tokens": 21,
30+
"total_tokens": 23,
31+
"completion_tokens": 2,
32+
"prompt_tokens_details": null
33+
},
34+
"prompt_logprobs": null,
35+
"prompt_token_ids": null,
36+
"kv_transfer_params": null,
37+
"_response_headers": {
38+
"date": "Mon, 20 Apr 2026 09:01:52 GMT",
39+
"content-type": "application/json",
40+
"content-length": "612",
41+
"connection": "keep-alive",
42+
"access-control-expose-headers": "nvcf-reqid",
43+
"nvcf-reqid": "32b52980-1d04-4f20-8ae3-82dbd4f64fae",
44+
"nvcf-status": "fulfilled",
45+
"server": "uvicorn",
46+
"vary": "Origin"
47+
}
48+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"id": "chatcmpl-84573de96c0257e5",
3+
"object": "chat.completion",
4+
"created": 1776675712,
5+
"model": "nvidia/nemotron-3-nano-30b-a3b",
6+
"choices": [
7+
{
8+
"index": 0,
9+
"message": {
10+
"role": "assistant",
11+
"content": null,
12+
"refusal": null,
13+
"annotations": null,
14+
"audio": null,
15+
"function_call": null,
16+
"tool_calls": [
17+
{
18+
"id": "chatcmpl-tool-99fa29fc3387337b",
19+
"type": "function",
20+
"function": {
21+
"name": "get_weather",
22+
"arguments": "{\"city\": \"Paris\"}"
23+
}
24+
}
25+
],
26+
"reasoning": "Okay, the user is asking about the weather in Paris. Let me check the tools available. There's a function called get_weather that takes a city parameter. Since Paris is the city mentioned, I should call that function with \"Paris\" as the argument. I don't need any other tools here. Just need to make sure to structure the tool call correctly within the XML tags.\n",
27+
"reasoning_content": "Okay, the user is asking about the weather in Paris. Let me check the tools available. There's a function called get_weather that takes a city parameter. Since Paris is the city mentioned, I should call that function with \"Paris\" as the argument. I don't need any other tools here. Just need to make sure to structure the tool call correctly within the XML tags.\n"
28+
},
29+
"logprobs": null,
30+
"finish_reason": "tool_calls",
31+
"stop_reason": null,
32+
"token_ids": null
33+
}
34+
],
35+
"service_tier": null,
36+
"system_fingerprint": null,
37+
"usage": {
38+
"prompt_tokens": 276,
39+
"total_tokens": 380,
40+
"completion_tokens": 104,
41+
"prompt_tokens_details": null
42+
},
43+
"prompt_logprobs": null,
44+
"prompt_token_ids": null,
45+
"kv_transfer_params": null,
46+
"_response_headers": {
47+
"date": "Mon, 20 Apr 2026 09:01:53 GMT",
48+
"content-type": "application/json",
49+
"content-length": "1476",
50+
"connection": "keep-alive",
51+
"access-control-expose-headers": "nvcf-reqid",
52+
"nvcf-reqid": "1e6cd027-cfe3-46f1-868b-046f6449b3af",
53+
"nvcf-status": "fulfilled",
54+
"server": "uvicorn",
55+
"vary": "Origin"
56+
}
57+
}

0 commit comments

Comments
 (0)