mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(vision): reject oversized images before API call, handle file:// URIs, improve 400 errors
Three fixes for vision_analyze returning cryptic 400 "Invalid request data": 1. Pre-flight base64 size check — base64 inflates data ~33%, so a 3.8 MB file exceeds the 5 MB API limit. Reject early with a clear message instead of letting the provider return a generic 400. 2. Handle file:// URIs — strip the scheme and resolve as a local path. Previously file:///path/to/image.png fell through to the "invalid image source" error since it matched neither is_file() nor http(s). 3. Separate invalid_request errors from "does not support vision" errors so the user gets actionable guidance (resize/compress/retry) instead of a misleading "model does not support vision" message. Closes #6677
This commit is contained in:
parent
1909877e6e
commit
4e56eacdce
2 changed files with 154 additions and 4 deletions
|
|
@ -533,6 +533,133 @@ class TestTildeExpansion:
|
|||
assert data["success"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# file:// URI support
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFileUriSupport:
|
||||
"""Verify that file:// URIs resolve as local file paths."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_uri_resolved_as_local_path(self, tmp_path):
|
||||
"""file:///absolute/path should be treated as a local file."""
|
||||
img = tmp_path / "photo.png"
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message.content = "A test image"
|
||||
mock_response.choices = [mock_choice]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tools.vision_tools._image_to_base64_data_url",
|
||||
return_value="data:image/png;base64,abc",
|
||||
),
|
||||
patch(
|
||||
"tools.vision_tools.async_call_llm",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
),
|
||||
):
|
||||
result = await vision_analyze_tool(
|
||||
f"file://{img}", "describe this", "test/model"
|
||||
)
|
||||
data = json.loads(result)
|
||||
assert data["success"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_uri_nonexistent_gives_error(self, tmp_path):
|
||||
"""file:// pointing to a missing file should fail gracefully."""
|
||||
result = await vision_analyze_tool(
|
||||
f"file://{tmp_path}/nonexistent.png", "describe this", "test/model"
|
||||
)
|
||||
data = json.loads(result)
|
||||
assert data["success"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base64 size pre-flight check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBase64SizeLimit:
|
||||
"""Verify that oversized images are rejected before hitting the API."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oversized_image_rejected_before_api_call(self, tmp_path):
|
||||
"""Images exceeding 5 MB base64 should fail with a clear size error."""
|
||||
img = tmp_path / "huge.png"
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * (4 * 1024 * 1024))
|
||||
|
||||
with patch("tools.vision_tools.async_call_llm", new_callable=AsyncMock) as mock_llm:
|
||||
result = json.loads(await vision_analyze_tool(str(img), "describe this"))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "too large" in result["error"].lower()
|
||||
mock_llm.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_small_image_not_rejected(self, tmp_path):
|
||||
"""Images well under the limit should pass the size check."""
|
||||
img = tmp_path / "small.png"
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 64)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message.content = "Small image"
|
||||
mock_response.choices = [mock_choice]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tools.vision_tools.async_call_llm",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
),
|
||||
):
|
||||
result = json.loads(await vision_analyze_tool(str(img), "describe this", "test/model"))
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error classification for 400 responses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestErrorClassification:
|
||||
"""Verify that API 400 errors produce actionable guidance."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_request_error_gives_image_guidance(self, tmp_path):
|
||||
"""An invalid_request_error from the API should mention image size/format."""
|
||||
img = tmp_path / "test.png"
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8)
|
||||
|
||||
api_error = Exception(
|
||||
"Error code: 400 - {'type': 'error', 'error': "
|
||||
"{'type': 'invalid_request_error', 'message': 'Invalid request data'}}"
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tools.vision_tools._image_to_base64_data_url",
|
||||
return_value="data:image/png;base64,abc",
|
||||
),
|
||||
patch(
|
||||
"tools.vision_tools.async_call_llm",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=api_error,
|
||||
),
|
||||
):
|
||||
result = json.loads(await vision_analyze_tool(str(img), "describe", "test/model"))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "rejected the image" in result["analysis"].lower()
|
||||
assert "smaller" in result["analysis"].lower()
|
||||
|
||||
|
||||
class TestVisionRegistration:
|
||||
def test_vision_analyze_registered(self):
|
||||
from tools.registry import registry
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue