Skip to content

Commit 3cfbb7e

Browse files
buildciclaude
andcommitted
address PR comments: model list in docstring, timeout/corrupted-image tests
- model_id docstring now lists all current supported aliases with a note that the server is the source of truth (400 on unknown alias) - documents that corrupted bytes are validated server-side -> HTTPError 400 - rewrites test_ask_vlm.py as module-level functions matching repo convention - adds test_timeout_passed_to_requests: verifies timeout kwarg forwarded - adds test_corrupted_image_bytes_raises_http_error: server 400 -> HTTPError Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 263808d commit 3cfbb7e

2 files changed

Lines changed: 99 additions & 78 deletions

File tree

src/groundlight/client.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1157,14 +1157,26 @@ def ask_vlm(
11571157
11581158
:param query: Natural-language prompt describing the media and what to verify,
11591159
e.g. ``"Is there a fire visible in the image? Reason step by step."``
1160-
:param model_id: Friendly alias of the VLM to use, e.g.
1161-
``"gpt-5.4"`` or ``"claude-sonnet-4.5"``. Must be one of the
1162-
models supported by the server. Defaults to the server-configured default.
1160+
:param model_id: Friendly alias of the VLM to use. The server is the source
1161+
of truth; passing an unrecognised alias returns HTTP 400. Currently
1162+
supported aliases:
1163+
1164+
- ``"gpt-5.4"`` — OpenAI GPT-5.4 via Bedrock Responses API (default)
1165+
- ``"claude-sonnet-4.5"`` — Anthropic Claude Sonnet 4.5
1166+
- ``"claude-haiku-3"`` — Anthropic Claude Haiku 3
1167+
- ``"nova-pro"`` — Amazon Nova Pro
1168+
- ``"nova-lite"`` — Amazon Nova Lite
1169+
- ``"llama3.2-90b"`` — Meta Llama 3.2 90B
1170+
- ``"llama3.2-11b"`` — Meta Llama 3.2 11B
1171+
1172+
Omit to use the server-configured default (currently ``"gpt-5.4"``).
11631173
:param timeout: Request timeout in seconds (default 15 s).
11641174
11651175
:return: :class:`VLMVerificationResult` with ``verdict`` (``"YES"`` / ``"NO"`` /
11661176
``"UNSURE"``), ``confidence``, ``reasoning``, and token cost fields.
1167-
:raises requests.HTTPError: On non-2xx response from the server.
1177+
:raises ValueError: If more than 8 media items are supplied.
1178+
:raises requests.HTTPError: On non-2xx response (400 for invalid model alias
1179+
or undecodable image bytes; 502 if the upstream VLM is unavailable).
11681180
"""
11691181
# Normalise: single image → list
11701182
if not isinstance(media, list):

test/unit/test_ask_vlm.py

Lines changed: 83 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,21 @@
1-
"""Unit tests for Groundlight.ask_vlm — mocks HTTP, no live server needed."""
1+
"""Unit tests for Groundlight.ask_vlm — all HTTP mocked, no live server needed."""
22

3+
from unittest import mock
34
from unittest.mock import MagicMock, patch
45

56
import numpy as np
67
import pytest
78
from groundlight import Groundlight, VLMVerificationResult
89

910

10-
@pytest.fixture
11-
def gl(monkeypatch):
11+
@pytest.fixture(name="gl")
12+
def groundlight_fixture(monkeypatch) -> Groundlight:
1213
monkeypatch.setenv("GROUNDLIGHT_API_TOKEN", "api_fake_test_token")
13-
# Avoid the live /v1/me connectivity check performed during __init__.
1414
with patch.object(Groundlight, "_verify_connectivity", return_value=None):
15-
client = Groundlight(endpoint="http://test-server/device-api/")
16-
return client
15+
return Groundlight(endpoint="http://test-server/device-api/")
1716

1817

19-
def _mock_response(
20-
verdict="YES", confidence=0.92, reasoning="Flames visible.", model_id="us.anthropic.claude-sonnet-4-5-20250929-v1:0"
21-
):
18+
def _mock_response(verdict="YES", confidence=0.92, reasoning="Flames visible.", model_id="gpt-5.4"):
2219
resp = MagicMock()
2320
resp.status_code = 201
2421
resp.json.return_value = {
@@ -34,94 +31,106 @@ def _mock_response(
3431
return resp
3532

3633

37-
class TestAskVlm:
38-
@patch("groundlight.client.requests")
39-
def test_returns_vlm_verification_result(self, mock_requests, gl):
34+
def test_returns_vlm_verification_result(gl: Groundlight):
35+
"""ask_vlm returns a typed VLMVerificationResult with all expected fields populated."""
36+
with mock.patch("groundlight.client.requests") as mock_requests:
4037
mock_requests.post.return_value = _mock_response()
41-
4238
result = gl.ask_vlm(media=np.zeros((100, 100, 3), dtype=np.uint8), query="Is there a fire?")
4339

44-
assert isinstance(result, VLMVerificationResult)
45-
assert result.verdict == "YES"
46-
assert result.confidence == pytest.approx(0.92)
47-
assert result.id == "vlmv_test123"
48-
assert result.input_tokens == 400
49-
assert result.total_cost_usd == pytest.approx(0.0015)
40+
assert isinstance(result, VLMVerificationResult)
41+
assert result.verdict == "YES"
42+
assert result.confidence == pytest.approx(0.92)
43+
assert result.id == "vlmv_test123"
44+
assert result.input_tokens == 400
45+
assert result.total_cost_usd == pytest.approx(0.0015)
46+
5047

51-
@patch("groundlight.client.requests")
52-
def test_single_numpy_image_encoded_as_jpeg(self, mock_requests, gl):
48+
def test_single_numpy_image_encoded_as_jpeg(gl: Groundlight):
49+
"""A numpy array is encoded to JPEG and sent as a single multipart 'media' part."""
50+
with mock.patch("groundlight.client.requests") as mock_requests:
5351
mock_requests.post.return_value = _mock_response()
54-
frame = np.zeros((480, 640, 3), dtype=np.uint8)
52+
gl.ask_vlm(media=np.zeros((480, 640, 3), dtype=np.uint8), query="Is there a fire?")
5553

56-
gl.ask_vlm(media=frame, query="Is there a fire?")
54+
_, kwargs = mock_requests.post.call_args
55+
files = kwargs["files"]
56+
assert len(files) == 1
57+
assert files[0][0] == "media"
58+
_name, data, ctype = files[0][1]
59+
assert ctype == "image/jpeg"
60+
assert len(data) > 0
5761

58-
_, kwargs = mock_requests.post.call_args
59-
files = kwargs["files"]
60-
assert len(files) == 1
61-
assert files[0][0] == "media"
62-
name, data, ctype = files[0][1]
63-
assert ctype == "image/jpeg"
64-
assert len(data) > 0 # bytes were produced
6562

66-
@patch("groundlight.client.requests")
67-
def test_dual_images_sends_two_parts(self, mock_requests, gl):
63+
def test_dual_images_sends_two_parts(gl: Groundlight):
64+
"""Passing a list of two images sends two 'media' multipart parts."""
65+
with mock.patch("groundlight.client.requests") as mock_requests:
6866
mock_requests.post.return_value = _mock_response()
69-
frame = np.zeros((480, 640, 3), dtype=np.uint8)
70-
roi = np.zeros((120, 120, 3), dtype=np.uint8)
67+
gl.ask_vlm(
68+
media=[np.zeros((480, 640, 3), dtype=np.uint8), np.zeros((120, 120, 3), dtype=np.uint8)],
69+
query="Is there a fire?",
70+
)
7171

72-
gl.ask_vlm(media=[frame, roi], query="Is there a fire?")
72+
_, kwargs = mock_requests.post.call_args
73+
assert len(kwargs["files"]) == 2
7374

74-
_, kwargs = mock_requests.post.call_args
75-
assert len(kwargs["files"]) == 2
7675

77-
@patch("groundlight.client.requests")
78-
def test_url_has_correct_path(self, mock_requests, gl):
76+
def test_url_has_correct_path(gl: Groundlight):
77+
"""sanitize_endpoint_url strips the trailing slash, so we must insert '/' before
78+
the path — without it the URL would be '...device-apiv1/vlm-verifications'."""
79+
with mock.patch("groundlight.client.requests") as mock_requests:
7980
mock_requests.post.return_value = _mock_response()
80-
8181
gl.ask_vlm(media=np.zeros((100, 100, 3), dtype=np.uint8), query="test")
8282

83-
args, _ = mock_requests.post.call_args
84-
url = args[0]
85-
# sanitize_endpoint_url strips the trailing slash, so we must insert "/" before
86-
# the path — without it the URL would be "...device-apiv1/vlm-verifications".
87-
assert url.endswith("/v1/vlm-verifications"), f"Bad URL: {url}"
88-
assert "/device-api/v1/vlm-verifications" in url
83+
args, _ = mock_requests.post.call_args
84+
url = args[0]
85+
assert "/device-api/v1/vlm-verifications" in url
8986

90-
@patch("groundlight.client.requests")
91-
def test_query_and_model_id_sent_as_form_fields(self, mock_requests, gl):
92-
mock_requests.post.return_value = _mock_response(model_id="nova-pro")
9387

88+
def test_query_and_model_id_sent_as_form_fields(gl: Groundlight):
89+
"""query and model_id go in the multipart body, never in the URL query string."""
90+
with mock.patch("groundlight.client.requests") as mock_requests:
91+
mock_requests.post.return_value = _mock_response(model_id="nova-pro")
9492
gl.ask_vlm(media=np.zeros((100, 100, 3), dtype=np.uint8), query="Is there a fire?", model_id="nova-pro")
9593

96-
_, kwargs = mock_requests.post.call_args
97-
# Text fields go in the multipart body, never the URL query string.
98-
assert kwargs["data"]["query"] == "Is there a fire?"
99-
assert kwargs["data"]["model_id"] == "nova-pro"
100-
assert "params" not in kwargs or not kwargs["params"]
94+
_, kwargs = mock_requests.post.call_args
95+
assert kwargs["data"]["query"] == "Is there a fire?"
96+
assert kwargs["data"]["model_id"] == "nova-pro"
97+
assert "params" not in kwargs or not kwargs.get("params")
10198

102-
@patch("groundlight.client.requests")
103-
def test_no_model_id_omits_field(self, mock_requests, gl):
104-
mock_requests.post.return_value = _mock_response()
10599

100+
def test_no_model_id_omits_field(gl: Groundlight):
101+
"""Omitting model_id leaves the field out entirely so the server uses its default."""
102+
with mock.patch("groundlight.client.requests") as mock_requests:
103+
mock_requests.post.return_value = _mock_response()
106104
gl.ask_vlm(media=np.zeros((100, 100, 3), dtype=np.uint8), query="test")
107105

108-
_, kwargs = mock_requests.post.call_args
109-
assert "model_id" not in kwargs["data"]
110-
assert kwargs["data"]["query"] == "test"
106+
_, kwargs = mock_requests.post.call_args
107+
assert "model_id" not in kwargs["data"]
111108

112-
def test_more_than_eight_media_raises(self, gl):
113-
frame = np.zeros((100, 100, 3), dtype=np.uint8)
114-
with pytest.raises(ValueError, match="at most 8"):
115-
gl.ask_vlm(media=[frame] * 9, query="test")
116109

117-
@patch("groundlight.client.requests")
118-
def test_bytes_image_accepted(self, mock_requests, gl):
110+
def test_more_than_eight_media_raises(gl: Groundlight):
111+
"""Supplying more than 8 media items raises ValueError before any network call."""
112+
with pytest.raises(ValueError, match="at most 8"):
113+
gl.ask_vlm(media=[np.zeros((100, 100, 3), dtype=np.uint8)] * 9, query="test")
114+
115+
116+
def test_timeout_passed_to_requests(gl: Groundlight):
117+
"""The timeout parameter is forwarded to requests.post."""
118+
with mock.patch("groundlight.client.requests") as mock_requests:
119119
mock_requests.post.return_value = _mock_response()
120-
# A minimal valid JPEG header
121-
jpeg_bytes = b"\xff\xd8\xff\xe0" + b"\x00" * 100
122-
123-
# Should not raise
124-
try:
125-
gl.ask_vlm(media=jpeg_bytes, query="test")
126-
except Exception:
127-
pass # parse_supported_image_types may reject invalid JPEG body; that's fine here
120+
gl.ask_vlm(media=np.zeros((100, 100, 3), dtype=np.uint8), query="test", timeout=5.0)
121+
122+
_, kwargs = mock_requests.post.call_args
123+
assert kwargs["timeout"] == pytest.approx(5.0)
124+
125+
126+
def test_corrupted_image_bytes_raises_http_error(gl: Groundlight):
127+
"""Corrupted bytes are not validated client-side — the server rejects them with a
128+
400, which raise_for_status() converts to requests.HTTPError."""
129+
error_resp = MagicMock()
130+
error_resp.status_code = 400
131+
error_resp.raise_for_status.side_effect = Exception("400 Bad Request")
132+
133+
with mock.patch("groundlight.client.requests") as mock_requests:
134+
mock_requests.post.return_value = error_resp
135+
with pytest.raises(Exception, match="400"):
136+
gl.ask_vlm(media=b"this-is-not-a-valid-image", query="test")

0 commit comments

Comments
 (0)