Skip to content

Commit 2e3d058

Browse files
committed
Add unittest for GrokClient
1 parent 83f46bf commit 2e3d058

File tree

27 files changed

+3992
-22
lines changed

27 files changed

+3992
-22
lines changed

python/mirascope/llm/clients/xai/grok.py

Lines changed: 259 additions & 13 deletions
Large diffs are not rendered by default.

python/tests/e2e/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,43 @@ Note that when you update the implementation or tests in a way that changes what
1919
You can regenerate snapshots en masse by deleting the `cassettes/` subdirectory, but this
2020
should be done sparingly because it takes time and API token usage.
2121

22+
### xAI/Grok Special Case
23+
24+
**xAI uses gRPC instead of HTTP**, so VCR.py cannot intercept its traffic. Therefore, xAI tests are handled differently:
25+
26+
- **In CI**: xAI tests are **automatically skipped** (no API key required)
27+
- **Locally**: xAI tests run with **real API calls** when the `--use-real-grok` flag is provided
28+
29+
#### Running xAI tests locally
30+
31+
```bash
32+
# Set your API key
33+
export XAI_API_KEY=your-api-key
34+
35+
# Run all E2E tests including xAI
36+
cd python
37+
uv run pytest tests/e2e/ --use-real-grok --inline-snapshot=fix
38+
39+
# Run only xAI tests
40+
uv run pytest tests/e2e/ -k xai --use-real-grok
41+
```
42+
43+
#### When to run xAI tests
44+
45+
Run xAI tests manually when:
46+
47+
1. **Making changes to xAI client**: `mirascope/llm/clients/xai/grok.py`
48+
2. **Changing core abstractions**: Changes to base classes or protocols that affect all providers
49+
3. **Before releases**: Verify xAI compatibility before publishing a new version
50+
51+
#### Why this approach?
52+
53+
Since gRPC traffic cannot be recorded with VCR.py, we have two options:
54+
1. Build a custom cassette system (complex, fragile, hard to maintain)
55+
2. Run tests with real API calls when needed (simple, accurate, pragmatic)
56+
57+
We chose option 2 for simplicity and maintainability.
58+
2259
## Snapshots
2360

2461
Tests use [inline-snapshot](https://15r10nk.github.io/inline-snapshot/) to validate test outputs. Snapshots are stored in `snapshots/` subdirectories.

python/tests/e2e/conftest.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,34 @@
2525
from mirascope import llm
2626
from mirascope.llm.clients.anthropic_vertex import clients as anthropic_vertex_clients
2727

28+
29+
def pytest_addoption(parser: pytest.Parser) -> None:
30+
"""Add custom command line options."""
31+
parser.addoption(
32+
"--use-real-grok",
33+
action="store_true",
34+
default=False,
35+
help="Run xAI/Grok tests with real API (requires XAI_API_KEY). "
36+
"Without this flag, xAI tests are skipped since gRPC cannot be recorded with VCR.",
37+
)
38+
39+
40+
def pytest_collection_modifyitems(
41+
config: pytest.Config, items: list[pytest.Item]
42+
) -> None:
43+
"""Modify test collection to skip xAI tests unless --use-real-grok is set."""
44+
if config.getoption("--use-real-grok"):
45+
return # Run all tests including xAI
46+
47+
skip_grok = pytest.mark.skip(
48+
reason="xAI tests require --use-real-grok flag (gRPC cannot be recorded with VCR)"
49+
)
50+
for item in items:
51+
# Skip tests that have "xai" in their test ID (parametrized tests)
52+
if "xai" in item.nodeid.lower():
53+
item.add_marker(skip_grok)
54+
55+
2856
if TYPE_CHECKING:
2957
from typing import Any
3058

@@ -36,6 +64,7 @@
3664
("google", "gemini-2.5-flash"),
3765
("openai:completions", "gpt-4o"),
3866
("openai:responses", "gpt-4o"),
67+
("xai", "grok-3"),
3968
]
4069

4170

python/tests/e2e/input/conftest.py

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,31 @@
33
from __future__ import annotations
44

55
import importlib
6+
from collections.abc import Generator
7+
from contextlib import nullcontext
68
from pathlib import Path
9+
from typing import Any
710

811
import pytest
12+
import vcr
913
from pytest import FixtureRequest
1014

1115
from mirascope import llm
12-
from tests.e2e.conftest import SNAPSHOT_IMPORT_SYMBOLS
16+
from tests.e2e.conftest import SNAPSHOT_IMPORT_SYMBOLS, VCRConfig
1317
from tests.utils import Snapshot
1418

1519

20+
@pytest.fixture(scope="session")
21+
def vcr_config(vcr_config: VCRConfig) -> VCRConfig:
22+
"""Override VCR config to set cassette directory for input tests.
23+
24+
Inherits the base VCR configuration from tests/e2e/conftest.py and adds
25+
the cassette_library_dir to point to the input/cassettes directory.
26+
"""
27+
vcr_config["cassette_library_dir"] = str(Path(__file__).parent / "cassettes")
28+
return vcr_config
29+
30+
1631
def _extract_scenario_from_test_name(test_name: str) -> str:
1732
"""Extract scenario name from test name.
1833
@@ -43,25 +58,31 @@ def vcr_cassette_name(
4358
provider: llm.Provider,
4459
model_id: llm.ModelId,
4560
formatting_mode: llm.FormattingMode | None,
46-
) -> str:
61+
) -> str | None:
4762
"""Generate VCR cassette name based on test name, provider, model, and formatting_mode.
4863
4964
Input tests use a single cassette per test (no call type variants).
5065
5166
Structure:
5267
- Without formatting_mode: {scenario}/{provider}_{model_id}
5368
- With formatting_mode: {scenario}/{formatting_mode}/{provider}_{model_id}
69+
70+
Returns None for xAI provider to skip VCR (xAI tests run with --use-real-grok flag).
5471
"""
72+
# xAI uses gRPC, so skip VCR cassettes (requires --use-real-grok to run)
73+
if provider == "xai":
74+
return None
75+
5576
test_name = request.node.name
5677
scenario = _extract_scenario_from_test_name(test_name)
5778

5879
provider_str = provider.replace(":", "_")
5980
model_id_str = model_id.replace("-", "_").replace(".", "_")
6081

6182
if formatting_mode is None:
62-
return f"{scenario}/{provider_str}_{model_id_str}"
83+
return f"{scenario}/{provider_str}_{model_id_str}.yaml"
6384
else:
64-
return f"{scenario}/{formatting_mode}/{provider_str}_{model_id_str}"
85+
return f"{scenario}/{formatting_mode}/{provider_str}_{model_id_str}.yaml"
6586

6687

6788
@pytest.fixture
@@ -114,3 +135,30 @@ def snapshot(
114135

115136
module = importlib.import_module(module_path)
116137
return module.test_snapshot
138+
139+
140+
@pytest.fixture
141+
def vcr_cassette(
142+
request: FixtureRequest, vcr_cassette_name: str | None, vcr_config: dict[str, Any]
143+
) -> Generator[Any, None, None]:
144+
"""Override pytest-vcr's vcr_cassette to handle None cassette_name for xAI.
145+
146+
When vcr_cassette_name is None (e.g., for xAI provider which uses gRPC and
147+
cannot be recorded with VCR), this fixture returns a no-op context manager.
148+
149+
Args:
150+
request: Pytest fixture request.
151+
vcr_cassette_name: Cassette name or None to skip VCR.
152+
vcr_config: VCR configuration dict.
153+
154+
Yields:
155+
VCR cassette or None if skipped.
156+
"""
157+
if vcr_cassette_name is None:
158+
# xAI uses gRPC and cannot be recorded with VCR, skip VCR
159+
with nullcontext() as cassette:
160+
yield cassette
161+
else:
162+
# Use normal VCR for HTTP-based providers
163+
with vcr.VCR(**vcr_config).use_cassette(vcr_cassette_name) as cassette:
164+
yield cassette
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from inline_snapshot import snapshot
2+
3+
from mirascope.llm import AssistantMessage, Text, UserMessage
4+
5+
test_snapshot = snapshot(
6+
{
7+
"response": (
8+
{
9+
"provider": "xai",
10+
"model_id": "grok-3",
11+
"params": {
12+
"temperature": 0.7,
13+
"max_tokens": 500,
14+
"top_p": 0.3,
15+
"seed": 42,
16+
"stop_sequences": ["4242"],
17+
},
18+
"finish_reason": None,
19+
"messages": [
20+
UserMessage(content=[Text(text="What is 4200 + 42?")]),
21+
AssistantMessage(
22+
content=[Text(text="4200 + 42 = ")],
23+
provider="xai",
24+
model_id="grok-3",
25+
raw_message=None,
26+
),
27+
],
28+
"format": None,
29+
"tools": [],
30+
},
31+
)
32+
}
33+
)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from inline_snapshot import snapshot
2+
3+
test_snapshot = snapshot({"response": "1597"})
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from inline_snapshot import snapshot
2+
3+
from mirascope.llm import (
4+
AssistantMessage,
5+
Text,
6+
UserMessage,
7+
)
8+
9+
test_snapshot = snapshot(
10+
{
11+
"response": {
12+
"provider": "xai",
13+
"model_id": "grok-3",
14+
"params": {},
15+
"finish_reason": None,
16+
"messages": [
17+
UserMessage(content=[Text(text="Who created you?")]),
18+
AssistantMessage(
19+
content=[Text(text="I was created by Anthropic.")],
20+
provider="anthropic",
21+
model_id="claude-sonnet-4-0",
22+
raw_message={
23+
"role": "assistant",
24+
"content": [
25+
{
26+
"citations": None,
27+
"text": "I was created by Anthropic.",
28+
"type": "text",
29+
}
30+
],
31+
},
32+
),
33+
UserMessage(content=[Text(text="Can you double-check that?")]),
34+
AssistantMessage(
35+
content=[
36+
Text(
37+
text="My apologies for the confusion. I am Grok, created by xAI. I must have misspoken earlier. Thank you for asking me to double-check. I'm indeed a product of xAI, a company working on building artificial intelligence to accelerate human scientific discovery."
38+
)
39+
],
40+
provider="xai",
41+
model_id="grok-3",
42+
raw_message=None,
43+
),
44+
],
45+
"format": None,
46+
"tools": [],
47+
}
48+
}
49+
)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from inline_snapshot import snapshot
2+
3+
from mirascope.llm import (
4+
AssistantMessage,
5+
Text,
6+
UserMessage,
7+
)
8+
9+
test_snapshot = snapshot(
10+
{
11+
"response": {
12+
"provider": "xai",
13+
"model_id": "grok-3",
14+
"params": {},
15+
"finish_reason": None,
16+
"messages": [
17+
UserMessage(content=[Text(text="Who created you?")]),
18+
AssistantMessage(
19+
content=[Text(text="I was created by Anthropic.")],
20+
provider="anthropic",
21+
model_id="claude-sonnet-4-0",
22+
raw_message={
23+
"role": "assistant",
24+
"content": [
25+
{
26+
"citations": None,
27+
"text": "I was created by Anthropic.",
28+
"type": "text",
29+
}
30+
],
31+
},
32+
),
33+
UserMessage(content=[Text(text="Can you double-check that?")]),
34+
AssistantMessage(
35+
content=[
36+
Text(
37+
text="My apologies for any confusion. I am Grok, created by xAI. I must have misspoken earlier. Thank you for asking for clarification."
38+
)
39+
],
40+
provider="xai",
41+
model_id="grok-3",
42+
raw_message=None,
43+
),
44+
],
45+
"format": None,
46+
"tools": [],
47+
}
48+
}
49+
)

0 commit comments

Comments
 (0)