Files
hermes-agent/tests/hermes_cli/test_openrouter_preset_validation.py
T
teknium1 d595e636c8 fix(model): a selected model id is never rewritten to a catalog neighbour
A user who picked `deepseek-v4.1-flash` on their own custom endpoint kept
landing on `deepseek-v4-flash-0731`. Three sites each "helped" by diffing
the pick against a catalog and moving it:

- hermes_cli/models_validate.py: the shared catalog matcher auto-corrected
  any id within difflib ratio 0.9 of a listed one (`corrected_model`), and
  model_switch applied it. Version bumps, dated snapshots and qualifiers
  all sit inside 0.9 of a sibling, so a newer release the listing lacked
  was swapped for the older one under the user's label. The matcher now
  does exact membership -> suggestion text only; the id goes to the wire
  verbatim and a genuine typo is refused with the listed siblings named.
  Every branch that carried the correction (live listing, static catalog,
  curated fallback, MiniMax, Anthropic, custom, OpenRouter preset base)
  loses it in one place.

- hermes_cli/model_switch.py: a `providers.<key>` endpoint reached by its
  bare key (the slug Desktop picker rows carry) validated as a built-in
  and hit the hard-rejecting live-listing branch; the same endpoint as
  `custom:<key>` soft-accepted. Both spellings now validate as the user's
  custom endpoint.

- apps/desktop: `manualPickRemoved` (composer reseed) and
  `reconcileSelectionAfterCatalogRefresh` (Refresh Models) retargeted a
  sticky pick to the profile default / the row's first model whenever the
  provider row did not list it. Rows are hints (discovered, curated,
  capped); the gateway's switch result is the only authority on a pick.
  Both helpers are removed; the pick stays put.

Tests: change-detectors pinning the swap are rewritten as invariants
(never `corrected_model`; unlisted id on a user endpoint is kept and
warned; typo is refused with a suggestion); proven red on origin/main.
2026-09-12 14:05:36 -07:00

226 lines
7.0 KiB
Python

"""Regression coverage for OpenRouter preset references (issue #31739)."""
import json
import os
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from hermes_cli.models_validate import validate_requested_model
@pytest.mark.parametrize(
"model_name",
["@preset/email-copywriter", "@preset/Foo_bar.~9"],
)
def test_direct_openrouter_preset_reference_skips_model_listing(model_name):
"""An account-scoped direct preset has no public model row to probe."""
with patch(
"hermes_cli.models.fetch_api_models",
side_effect=AssertionError("direct preset references must not probe /models"),
):
result = validate_requested_model(
model_name,
"openrouter",
api_key="key",
base_url="https://openrouter.ai/api/v1",
)
assert result == {
"accepted": True,
"persist": True,
"recognized": False,
"message": None,
}
def test_combined_openrouter_preset_reference_validates_base_model():
"""Combined references validate the base model, not the preset-decorated ID."""
with patch(
"hermes_cli.models.fetch_api_models",
return_value=["openai/gpt-5.4"],
) as mock_fetch:
result = validate_requested_model(
"openai/gpt-5.4@preset/email-copywriter",
"openrouter",
api_key="key",
base_url="https://openrouter.ai/api/v1",
)
mock_fetch.assert_called_once_with("key", "https://openrouter.ai/api/v1")
assert result == {
"accepted": True,
"persist": True,
"recognized": True,
"message": None,
}
def test_combined_openrouter_preset_reference_rejects_unknown_base_model():
with patch("hermes_cli.models.fetch_api_models", return_value=["openai/gpt-5.4"]):
result = validate_requested_model(
"openai/gpt-5.4-preview@preset/email-copywriter",
"openrouter",
api_key="key",
base_url="https://openrouter.ai/api/v1",
)
assert result["accepted"] is False
assert result["persist"] is False
assert result["recognized"] is False
assert "Similar models" in result["message"]
assert "openai/gpt-5.4" in result["message"]
def test_combined_preset_near_miss_base_is_not_rewritten():
"""A base model close to a listed id is the user's pick, not a typo — the verdict rejects with a
suggestion instead of swapping the model under the preset."""
with patch("hermes_cli.models.fetch_api_models", return_value=["openai/gpt-5.4"]):
result = validate_requested_model(
"openai/gpt-5.44@preset/email-copywriter",
"openrouter",
api_key="key",
base_url="https://openrouter.ai/api/v1",
)
assert result["accepted"] is False
assert "corrected_model" not in result
assert "openai/gpt-5.4" in result["message"]
@pytest.mark.parametrize(
"model_name",
[
"@preset/",
"openai/gpt-5.4@preset/",
"@preset/foo/bar",
"@preset/foo?bar",
"@preset/☃",
"@preset/foo@preset/bar",
"openai/gpt-5.4@preset/foo/bar",
],
)
def test_openrouter_preset_reference_requires_a_url_safe_slug(model_name):
"""Malformed preset references must fail before model-list probing."""
with patch(
"hermes_cli.models.fetch_api_models",
side_effect=AssertionError("malformed presets must not probe /models"),
):
result = validate_requested_model(
model_name,
"openrouter",
api_key="key",
base_url="https://openrouter.ai/api/v1",
)
assert result["accepted"] is False
assert result["persist"] is False
assert result["recognized"] is False
assert "URL-safe" in result["message"]
def test_preset_reference_does_not_bypass_other_provider_validation():
with patch("hermes_cli.models.fetch_api_models", return_value=["gpt-5.4"]):
result = validate_requested_model(
"@preset/email-copywriter",
"openai",
api_key="key",
base_url="https://api.openai.com/v1",
)
assert result["accepted"] is False
assert result["persist"] is False
def test_preset_reference_does_not_bypass_custom_endpoint_validation():
probe = {
"models": ["local-model"],
"probed_url": "https://proxy.example/v1/models",
"resolved_base_url": "https://proxy.example/v1",
"suggested_base_url": None,
"used_fallback": False,
}
with patch("hermes_cli.models.probe_api_models", return_value=probe) as mock_probe:
result = validate_requested_model(
"@preset/email-copywriter",
"openrouter",
api_key="key",
base_url="https://proxy.example/v1",
)
mock_probe.assert_called_once()
assert result["accepted"] is True
assert result["recognized"] is False
assert "custom endpoint's model listing" in result["message"]
def test_configured_alias_switches_preset_through_real_resolution_chain(tmp_path):
"""Exercise config loading, alias resolution, runtime resolution, and validation."""
(tmp_path / "config.yaml").write_text(
"""
model:
default: openai/gpt-5.4
provider: openrouter
base_url: https://openrouter.ai/api/v1
model_aliases:
email-copywriter:
model: '@preset/email-copywriter'
provider: openrouter
""".lstrip(),
encoding="utf-8",
)
script = r"""
import json
import hermes_cli.model_switch as model_switch
import hermes_cli.models as models
def fail_model_probe(*args, **kwargs):
raise AssertionError("preset references must not probe /models")
models.fetch_api_models = fail_model_probe
model_switch.get_model_capabilities = lambda *args, **kwargs: None
model_switch.get_model_info = lambda *args, **kwargs: None
result = model_switch.switch_model(
"email-copywriter",
current_provider="openrouter",
current_model="openai/gpt-5.4",
current_base_url="https://openrouter.ai/api/v1",
current_api_key="key",
)
print(json.dumps(vars(result)))
"""
env = {
"HOME": str(tmp_path),
"HERMES_HOME": str(tmp_path),
"LANG": "C.UTF-8",
"OPENROUTER_API_KEY": "key",
"PATH": os.environ.get("PATH", ""),
"PYTHONIOENCODING": "utf-8",
}
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=Path(__file__).resolve().parents[2],
env=env,
check=False,
capture_output=True,
text=True,
)
assert completed.returncode == 0, (
f"subprocess failed with exit {completed.returncode}\n"
f"stdout:\n{completed.stdout}\n"
f"stderr:\n{completed.stderr}"
)
result = json.loads(completed.stdout.splitlines()[-1])
assert result["success"] is True
assert result["new_model"] == "@preset/email-copywriter"
assert result["target_provider"] == "openrouter"
assert result["resolved_via_alias"] == "email-copywriter"
assert result["warning_message"] == ""