mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
feat(xai): Imagine public-URL storage, chaining & video edit/extend
Add durable public-URL output and URL-based chaining to xAI Grok Imagine: - Store generated media on files-cdn with permanent public HTTPS URLs (public_url: true, no expiry by default). - Chain by URL: generate -> edit -> extend each take a prior result's public HTTPS URL (or a data URI / local file for inputs). - Add provider-specific xai_video_edit and xai_video_extend tools. - Image generation: public-URL/storage output, multi-reference edits, and ~/ local-path support for image edits. Credentials use xAI Grok device-code OAuth (separate PR).
This commit is contained in:
parent
184c10cf97
commit
9ce79cd642
15 changed files with 1694 additions and 294 deletions
|
|
@ -16,9 +16,17 @@ import pytest
|
|||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fake_api_key(monkeypatch):
|
||||
def _fake_api_key(monkeypatch, tmp_path):
|
||||
"""Ensure XAI_API_KEY is set for all tests."""
|
||||
monkeypatch.setenv("XAI_API_KEY", "test-key-12345")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
try:
|
||||
import hermes_cli.config as cfg_mod
|
||||
|
||||
if hasattr(cfg_mod, "_invalidate_load_config_cache"):
|
||||
cfg_mod._invalidate_load_config_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -80,6 +88,13 @@ class TestXAIImageGenProvider:
|
|||
assert schema["env_vars"] == []
|
||||
assert schema["post_setup"] == "xai_grok"
|
||||
|
||||
def test_capabilities_expose_total_source_image_limit(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
caps = XAIImageGenProvider().capabilities()
|
||||
assert caps["max_reference_images"] == 2
|
||||
assert caps["max_source_images"] == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config tests
|
||||
|
|
@ -318,6 +333,131 @@ class TestGenerate:
|
|||
f"resolution must be the literal '1k' or '2k', got {payload['resolution']!r}"
|
||||
)
|
||||
|
||||
def test_image_edit_rejects_bare_file_id_input(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/edited.png"}]}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
|
||||
patch("plugins.image_gen.xai.save_url_image", return_value="/tmp/edited.png"):
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(
|
||||
prompt="make the robot red",
|
||||
image_url="file_03eb65b1-aa97-482f-9ef0-b04f9172ea00",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "invalid_image_url"
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_image_edit_accepts_public_https_url(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/edited.png"}]}
|
||||
|
||||
public_url = "https://files-cdn.x.ai/token/file_abc.png"
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
|
||||
patch("plugins.image_gen.xai.save_url_image", return_value="/tmp/edited.png"):
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(
|
||||
prompt="make the robot red",
|
||||
image_url=public_url,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
|
||||
assert payload["image"] == {"url": public_url, "type": "image_url"}
|
||||
|
||||
def test_multi_image_edit_rejects_bare_file_id_inputs(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/edited.png"}]}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
|
||||
patch("plugins.image_gen.xai.save_url_image", return_value="/tmp/edited.png"):
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(
|
||||
prompt="combine these robots into one product shot",
|
||||
image_url="file_03eb65b1-aa97-482f-9ef0-b04f9172ea00",
|
||||
reference_image_urls=[
|
||||
"file_54b48d6d-28ad-4982-9d72-bd3ac677c9bc",
|
||||
"file_aa11bb22-cc33-44dd-88ee-ff0011223344",
|
||||
],
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "invalid_image_url"
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_multi_image_edit_rejects_more_than_three_sources(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(
|
||||
prompt="combine too many references",
|
||||
image_url="file_1",
|
||||
reference_image_urls=["file_2", "file_3", "file_4"],
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "too_many_references"
|
||||
|
||||
def test_storage_options_are_sent_by_default(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"data": [{"b64_json": "dGVzdA=="}]}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
|
||||
patch("plugins.image_gen.xai.save_b64_image", return_value="/tmp/test.png"):
|
||||
provider = XAIImageGenProvider()
|
||||
provider.generate(prompt="test")
|
||||
|
||||
payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
|
||||
assert payload["storage_options"]["public_url"] is True
|
||||
assert "expires_after" not in payload["storage_options"]
|
||||
assert payload["storage_options"]["filename"].endswith(".png")
|
||||
|
||||
def test_public_url_file_output_wins_over_temporary_url(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"data": [{
|
||||
"url": "https://imgen.x.ai/xai-tmp-imgen-test.jpeg",
|
||||
"file_output": {
|
||||
"file_id": "file-123",
|
||||
"filename": "stored.png",
|
||||
"public_url": "https://xai-files.example/stored.png",
|
||||
"public_url_expires_at": 1234567890,
|
||||
},
|
||||
}],
|
||||
}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp), \
|
||||
patch("plugins.image_gen.xai.save_url_image") as mock_save_url:
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(prompt="A cat playing piano")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image"] == "https://xai-files.example/stored.png"
|
||||
assert result["public_url"] == "https://xai-files.example/stored.png"
|
||||
assert "file_id" not in result
|
||||
mock_save_url.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration test
|
||||
|
|
@ -334,3 +474,21 @@ class TestRegistration:
|
|||
provider = mock_ctx.register_image_gen_provider.call_args[0][0]
|
||||
assert isinstance(provider, XAIImageGenProvider)
|
||||
assert provider.name == "xai"
|
||||
|
||||
|
||||
def test_xai_image_field_expands_user_home(tmp_path, monkeypatch):
|
||||
"""A ~-prefixed local image path must load (expanduser), not raise io_error.
|
||||
|
||||
Pre-flight validation uses ``Path(source).expanduser()`` so a ``~/...`` path
|
||||
passes; ``_xai_image_field`` must expand it too or the load fails spuriously.
|
||||
"""
|
||||
from plugins.image_gen.xai import _xai_image_field
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
img = tmp_path / "pic.png"
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
|
||||
field = _xai_image_field("~/pic.png")
|
||||
assert field["type"] == "image_url"
|
||||
assert field["url"].startswith("data:image/png;base64,")
|
||||
|
|
|
|||
|
|
@ -32,9 +32,9 @@ def test_xai_provider_lists_text_and_current_image_video_models():
|
|||
ids = [model["id"] for model in models]
|
||||
|
||||
assert ids[0] == "grok-imagine-video"
|
||||
assert ids[1] == "grok-imagine-video-1.5-preview"
|
||||
assert ids[1] == "grok-imagine-video-1.5"
|
||||
assert models[1]["modalities"] == ["image"]
|
||||
assert models[1]["aliases"] == ["grok-imagine-video-1.5-2026-05-30"]
|
||||
assert "aliases" not in models[1]
|
||||
|
||||
|
||||
def test_xai_routes_default_models_by_modality():
|
||||
|
|
@ -49,7 +49,7 @@ def test_xai_routes_default_models_by_modality():
|
|||
"grok-imagine-video",
|
||||
modality="image",
|
||||
explicit_model=False,
|
||||
) == "grok-imagine-video-1.5-preview"
|
||||
) == "grok-imagine-video-1.5"
|
||||
assert _resolve_model_for_modality(
|
||||
"grok-imagine-video-1.5-preview",
|
||||
modality="text",
|
||||
|
|
@ -62,15 +62,11 @@ def test_xai_routes_default_models_by_modality():
|
|||
) == "grok-imagine-video-1.5-preview"
|
||||
|
||||
|
||||
def test_xai_capabilities_text_and_image_only():
|
||||
"""xAI was previously advertised with edit/extend operations. The
|
||||
simplified surface only exposes text-to-video and image-to-video —
|
||||
confirm those are the only modalities advertised."""
|
||||
def test_xai_capabilities_keep_generate_surface_only():
|
||||
from plugins.video_gen.xai import XAIVideoGenProvider
|
||||
|
||||
caps = XAIVideoGenProvider().capabilities()
|
||||
assert caps["modalities"] == ["text", "image"]
|
||||
# No 'operations' key in the simplified surface
|
||||
assert "operations" not in caps
|
||||
assert caps["max_reference_images"] == 7
|
||||
|
||||
|
|
@ -148,3 +144,45 @@ def test_xai_no_operation_kwarg():
|
|||
assert result["success"] is False
|
||||
# auth_required, NOT some signature error
|
||||
assert result["error_type"] in {"auth_required", "api_error"}
|
||||
|
||||
|
||||
def test_xai_video_output_urls_prefers_stored_public_url():
|
||||
from plugins.video_gen.xai import _xai_video_output_urls
|
||||
|
||||
public_url, temporary, stored = _xai_video_output_urls({
|
||||
"url": "https://vidgen.x.ai/xai-vidgen-bucket/out.mp4",
|
||||
"file_output": {
|
||||
"public_url": "https://files-cdn.x.ai/token/file_abc.mp4",
|
||||
"file_id": "file_abc",
|
||||
},
|
||||
})
|
||||
assert public_url == "https://files-cdn.x.ai/token/file_abc.mp4"
|
||||
assert stored == "https://files-cdn.x.ai/token/file_abc.mp4"
|
||||
assert temporary == "https://vidgen.x.ai/xai-vidgen-bucket/out.mp4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_input_from_public_url_uses_url_field():
|
||||
from plugins.video_gen.xai import _video_input_from_public_url
|
||||
|
||||
url = "https://files-cdn.x.ai/kRQVP6PRQlioVAUNC3GAdg/file_1faca9c3-9411-46ad-bb41-b9b8527789e6.mp4"
|
||||
result = await _video_input_from_public_url(
|
||||
url,
|
||||
api_key="test-key",
|
||||
base_url="https://api.x.ai/v1",
|
||||
)
|
||||
assert result == {"url": url}
|
||||
|
||||
|
||||
def test_video_input_from_public_url_rejects_bare_file_id():
|
||||
import asyncio
|
||||
from plugins.video_gen.xai import _video_input_from_public_url
|
||||
|
||||
result = asyncio.run(
|
||||
_video_input_from_public_url(
|
||||
"file_1faca9c3-9411-46ad-bb41-b9b8527789e6",
|
||||
api_key="test-key",
|
||||
base_url="https://api.x.ai/v1",
|
||||
)
|
||||
)
|
||||
assert result is None
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ class TestXAIPayload:
|
|||
provider, captured = xai_provider
|
||||
provider.generate("animate this", image_url="https://example.com/cat.png")
|
||||
payload = _last_post(captured)["json"]
|
||||
assert payload["model"] == "grok-imagine-video-1.5-preview"
|
||||
assert payload["model"] == "grok-imagine-video-1.5"
|
||||
assert payload["image"] == {"url": "https://example.com/cat.png"}
|
||||
|
||||
def test_local_image_path_is_sent_as_data_uri(self, xai_provider, tmp_path):
|
||||
|
|
@ -133,7 +133,7 @@ class TestXAIPayload:
|
|||
provider.generate("animate this", image_url=str(image_path))
|
||||
|
||||
payload = _last_post(captured)["json"]
|
||||
assert payload["model"] == "grok-imagine-video-1.5-preview"
|
||||
assert payload["model"] == "grok-imagine-video-1.5"
|
||||
assert payload["image"]["url"].startswith("data:image/png;base64,")
|
||||
|
||||
def test_explicit_model_override_is_honored_for_image(self, xai_provider):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue