hermes-agent/tests/plugins/image_gen/test_openrouter_compat_provider.py
Brooklyn Nicholson e92b5c6af8 feat(pets): quality-first OpenRouter model chain + stronger atlas gates + global pet-gen notifications
OpenRouter/Nous image gen now runs a quality-first model chain by default:
attempt the highest-fidelity OpenAI image model first, then fall back to
Gemini 3 Pro Image when it's access-gated/unavailable/times out. An explicit
OPENROUTER_IMAGE_MODEL / config model override pins one model with no fallback.

Atlas validation rejects malformed model output instead of shipping it: adds a
per-state collapse guard (a single sliver/fragment row no longer passes because
other rows are healthy), on top of the existing postage-stamp + multi-pose
checks.

Desktop: pet-gen native notifications are now "global" (not tied to a chat
session), so a background generation started from the command center fires an
OS notification when the user is away even with no active session. Adds a
neutral "This can take up to 5 minutes." banner on step 1, and lets the
provider picker auto-size.

Tests updated/added for the OpenRouter fallback chain, the collapse guard, and
the global notification path.
2026-06-24 23:11:21 -05:00

381 lines
15 KiB
Python

#!/usr/bin/env python3
"""Tests for the OpenRouter-compatible image gen provider (OpenRouter + Nous)."""
from __future__ import annotations
import base64
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
_RUNTIME = "hermes_cli.runtime_provider.resolve_runtime_provider"
_PNG_DATA_URI = "data:image/png;base64,dGVzdC1pbWFnZS1kYXRh" # "test-image-data"
def _runtime_ok(**over):
base = {
"provider": "openrouter",
"api_mode": "chat_completions",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "sk-or-test",
"source": "env",
}
base.update(over)
return base
def _mock_chat_response(images):
resp = MagicMock()
resp.status_code = 200
resp.raise_for_status = MagicMock()
resp.json.return_value = {
"choices": [
{
"message": {
"role": "assistant",
"content": "",
"images": [
{"type": "image_url", "image_url": {"url": u}} for u in images
],
}
}
]
}
return resp
def _openrouter():
from plugins.image_gen.openrouter import OpenRouterCompatImageProvider
return OpenRouterCompatImageProvider(
provider_name="openrouter",
display_name="OpenRouter",
runtime_name="openrouter",
config_key="openrouter",
model_env_var="OPENROUTER_IMAGE_MODEL",
setup_schema={"name": "OpenRouter (image)", "badge": "paid", "env_vars": []},
)
# ---------------------------------------------------------------------------
# Provider class
# ---------------------------------------------------------------------------
class TestProviderClass:
def test_names(self):
from plugins.image_gen.openrouter import _build_providers
names = {p.name for p in _build_providers()}
assert names == {"openrouter", "nous"}
def test_display_names(self):
from plugins.image_gen.openrouter import _build_providers
by_name = {p.name: p for p in _build_providers()}
assert by_name["openrouter"].display_name == "OpenRouter"
assert by_name["nous"].display_name == "Nous Portal"
def test_capabilities_support_image_input(self):
caps = _openrouter().capabilities()
assert "image" in caps["modalities"]
assert caps["max_reference_images"] >= 1
def test_is_available_with_key(self):
with patch(_RUNTIME, return_value=_runtime_ok()):
assert _openrouter().is_available() is True
def test_is_available_without_key(self):
with patch(_RUNTIME, return_value=_runtime_ok(api_key="")):
assert _openrouter().is_available() is False
def test_is_available_on_resolution_error(self):
with patch(_RUNTIME, side_effect=RuntimeError("boom")):
assert _openrouter().is_available() is False
def test_default_model(self):
from plugins.image_gen.openrouter import DEFAULT_MODEL
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value={}):
assert _openrouter().default_model() == DEFAULT_MODEL
# Default must be an image-output model id (provider/model form).
assert "/" in DEFAULT_MODEL and "image" in DEFAULT_MODEL
def test_default_chain_prefers_quality_then_fallback(self):
from plugins.image_gen.openrouter import _FALLBACK_MODEL, _DEFAULT_MODEL_CHAIN
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value={}):
chain = _openrouter()._resolve_model_chain()
assert chain == list(_DEFAULT_MODEL_CHAIN)
assert chain[0].startswith("openai/")
assert chain[-1] == _FALLBACK_MODEL
def test_model_env_override(self, monkeypatch):
monkeypatch.setenv("OPENROUTER_IMAGE_MODEL", "black-forest-labs/flux.2-pro")
assert _openrouter()._resolve_model() == "black-forest-labs/flux.2-pro"
assert _openrouter()._resolve_model_chain() == ["black-forest-labs/flux.2-pro"]
def test_model_config_override(self):
cfg = {"openrouter": {"model": "google/gemini-3.1-flash-image-preview"}}
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=cfg):
assert _openrouter()._resolve_model() == "google/gemini-3.1-flash-image-preview"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class TestHelpers:
def test_to_image_url_part_passthrough_url(self):
from plugins.image_gen.openrouter import _to_image_url_part
assert _to_image_url_part("https://x/y.png") == "https://x/y.png"
assert _to_image_url_part("data:image/png;base64,AAAA") == "data:image/png;base64,AAAA"
def test_to_image_url_part_inlines_local_file(self, tmp_path):
from plugins.image_gen.openrouter import _to_image_url_part
f = tmp_path / "base.png"
f.write_bytes(b"\x89PNG\r\n")
part = _to_image_url_part(str(f))
assert part.startswith("data:image/png;base64,")
decoded = base64.b64decode(part.split(",", 1)[1])
assert decoded == b"\x89PNG\r\n"
def test_to_image_url_part_missing_file(self):
from plugins.image_gen.openrouter import _to_image_url_part
assert _to_image_url_part("/no/such/file.png") is None
def test_extract_images(self):
from plugins.image_gen.openrouter import _extract_images
payload = {
"choices": [
{"message": {"images": [{"image_url": {"url": "data:image/png;base64,AA"}}]}}
]
}
assert _extract_images(payload) == ["data:image/png;base64,AA"]
def test_extract_images_empty(self):
from plugins.image_gen.openrouter import _extract_images
assert _extract_images({"choices": [{"message": {"content": "no image"}}]}) == []
def test_access_error_hint_for_gated_openai_model(self):
from plugins.image_gen.openrouter import _FALLBACK_MODEL, _access_error_hint
hint = _access_error_hint(
"OpenRouter", "openai/gpt-5.4-image-2", "OPENROUTER_IMAGE_MODEL", 404, "No endpoints found"
)
assert hint is not None
assert "openai/gpt-5.4-image-2" in hint
assert "OPENROUTER_IMAGE_MODEL" in hint
assert _FALLBACK_MODEL in hint
# Stays a single line under the humanizer's 200-char truncation.
assert "\n" not in hint and len(hint) <= 200
def test_access_error_hint_ignores_non_openai_models(self):
from plugins.image_gen.openrouter import _access_error_hint
assert _access_error_hint("OpenRouter", "google/gemini-3-pro-image", "X", 404, "boom") is None
def test_access_error_hint_ignores_unrelated_errors(self):
from plugins.image_gen.openrouter import _access_error_hint
# A 200-class transient with an openai model but no access signal → no hint.
assert _access_error_hint("OpenRouter", "openai/gpt-5.4-image-2", "X", 500, "server error") is None
# ---------------------------------------------------------------------------
# generate()
# ---------------------------------------------------------------------------
class TestGenerate:
def test_missing_credentials(self):
with patch(_RUNTIME, return_value=_runtime_ok(api_key="")):
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "missing_api_key"
def test_success_data_uri(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])), \
patch(
"plugins.image_gen.openrouter.save_b64_image",
return_value=Path("/tmp/openrouter_gen.png"),
) as mock_save:
result = _openrouter().generate(prompt="a pet")
assert result["success"] is True
assert result["image"] == "/tmp/openrouter_gen.png"
assert result["provider"] == "openrouter"
mock_save.assert_called_once()
def test_success_http_url(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response(["https://cdn/x.png"])), \
patch(
"plugins.image_gen.openrouter.save_url_image",
return_value=Path("/tmp/openrouter_gen_url.png"),
) as mock_save_url:
result = _openrouter().generate(prompt="a pet")
assert result["success"] is True
assert result["image"] == "/tmp/openrouter_gen_url.png"
mock_save_url.assert_called_once()
def test_empty_response(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([])):
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "empty_response"
def test_payload_shape_and_references(self, tmp_path):
"""Wire payload must carry image modalities, aspect_ratio, and the
reference image inlined as a data URI (this is what makes pet rows
stay on-model)."""
ref = tmp_path / "base.png"
ref.write_bytes(b"\x89PNG\r\n")
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
_openrouter().generate(
prompt="a pet", aspect_ratio="square", reference_images=[str(ref)]
)
payload = mock_post.call_args.kwargs["json"]
assert payload["modalities"] == ["image", "text"]
assert payload["image_config"]["aspect_ratio"] == "1:1"
content = payload["messages"][0]["content"]
assert content[0] == {"type": "text", "text": "a pet"}
image_parts = [c for c in content if c["type"] == "image_url"]
assert len(image_parts) == 1
assert image_parts[0]["image_url"]["url"].startswith("data:image/png;base64,")
def test_auth_header(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
_openrouter().generate(prompt="a pet")
headers = mock_post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer sk-or-test"
def test_posts_to_resolved_base_url(self):
"""Nous routes to its own base URL — proves the same code serves both."""
nous_runtime = _runtime_ok(
provider="nous", base_url="https://inference.nousresearch.com/v1", api_key="nous-tok"
)
with patch(_RUNTIME, return_value=nous_runtime), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
from plugins.image_gen.openrouter import _build_providers
nous = {p.name: p for p in _build_providers()}["nous"]
result = nous.generate(prompt="a pet")
assert result["success"] is True
assert result["provider"] == "nous"
url = mock_post.call_args[0][0]
assert url == "https://inference.nousresearch.com/v1/chat/completions"
def test_api_error(self):
import requests as req_lib
resp = MagicMock()
resp.status_code = 401
resp.text = "Unauthorized"
resp.json.return_value = {"error": {"message": "Invalid API key"}}
resp.raise_for_status.side_effect = req_lib.HTTPError(response=resp)
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=resp) as mock_post:
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert mock_post.call_count == 1
def test_timeout(self):
import requests as req_lib
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", side_effect=req_lib.Timeout()):
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "timeout"
def test_access_gated_model_surfaces_hint(self, monkeypatch):
"""A 404 on an OpenAI image model yields the actionable access hint (not
the misleading generic 'check your key' message)."""
import requests as req_lib
monkeypatch.setenv("OPENROUTER_IMAGE_MODEL", "openai/gpt-5.4-image-2")
resp = MagicMock()
resp.status_code = 404
resp.text = "No endpoints found for openai/gpt-5.4-image-2"
resp.json.return_value = {"error": {"message": "No endpoints found"}}
resp.raise_for_status.side_effect = req_lib.HTTPError(response=resp)
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=resp) as mock_post:
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "model_access"
assert "OpenAI image access" in result["error"]
assert mock_post.call_count == 1 # explicit override: no auto-fallback chain
def test_access_gated_default_model_falls_back_to_gemini(self):
import requests as req_lib
from plugins.image_gen.openrouter import DEFAULT_MODEL, _FALLBACK_MODEL
gated = MagicMock()
gated.status_code = 404
gated.text = f"No endpoints found for {DEFAULT_MODEL}"
gated.json.return_value = {"error": {"message": "No endpoints found"}}
gated.raise_for_status.side_effect = req_lib.HTTPError(response=gated)
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", side_effect=[gated, _mock_chat_response([_PNG_DATA_URI])]) as mock_post, \
patch(
"plugins.image_gen.openrouter.save_b64_image",
return_value=Path("/tmp/openrouter_gen_fallback.png"),
):
result = _openrouter().generate(prompt="a pet")
assert result["success"] is True
assert result["model"] == _FALLBACK_MODEL
assert result["image"] == "/tmp/openrouter_gen_fallback.png"
assert mock_post.call_count == 2
first_model = mock_post.call_args_list[0].kwargs["json"]["model"]
second_model = mock_post.call_args_list[1].kwargs["json"]["model"]
assert first_model == DEFAULT_MODEL
assert second_model == _FALLBACK_MODEL
# ---------------------------------------------------------------------------
# Registration + pet integration
# ---------------------------------------------------------------------------
class TestRegistration:
def test_register_both(self):
from plugins.image_gen.openrouter import register
ctx = MagicMock()
register(ctx)
registered = [c.args[0].name for c in ctx.register_image_gen_provider.call_args_list]
assert set(registered) == {"openrouter", "nous"}
def test_both_are_reference_capable_for_pets(self):
from agent.pet.generate.imagegen import _REF_CAPABLE
assert "openrouter" in _REF_CAPABLE
assert "nous" in _REF_CAPABLE