Merge remote-tracking branch 'origin/main' into sid/types-and-lints

# Conflicts:
#	gateway/platforms/base.py
#	gateway/platforms/qqbot/adapter.py
#	gateway/platforms/slack.py
#	hermes_cli/main.py
#	scripts/batch_runner.py
#	tools/skills_tool.py
#	uv.lock
This commit is contained in:
alt-glitch 2026-04-21 20:28:45 +05:30
commit a9ed7cb3b4
117 changed files with 7791 additions and 611 deletions

View file

@ -921,17 +921,13 @@ class TestKimiMoonshotModelListIsolation:
leaked = set(moonshot_models) & coding_plan_only
assert not leaked, f"Moonshot list contains Coding Plan-only models: {leaked}"
def test_moonshot_list_contains_shared_models(self):
def test_moonshot_list_non_empty(self):
from hermes_cli.main import _PROVIDER_MODELS
moonshot_models = _PROVIDER_MODELS["moonshot"]
assert "kimi-k2.5" in moonshot_models
assert "kimi-k2-thinking" in moonshot_models
assert len(_PROVIDER_MODELS["moonshot"]) >= 1
def test_coding_plan_list_contains_plan_specific_models(self):
def test_coding_plan_list_non_empty(self):
from hermes_cli.main import _PROVIDER_MODELS
coding_models = _PROVIDER_MODELS["kimi-coding"]
assert "kimi-for-coding" in coding_models
assert "kimi-k2-thinking-turbo" in coding_models
assert len(_PROVIDER_MODELS["kimi-coding"]) >= 1
# =============================================================================
@ -944,14 +940,12 @@ class TestHuggingFaceModels:
def test_main_provider_models_has_huggingface(self):
from hermes_cli.main import _PROVIDER_MODELS
assert "huggingface" in _PROVIDER_MODELS
models = _PROVIDER_MODELS["huggingface"]
assert len(models) >= 6, "Expected at least 6 curated HF models"
assert len(_PROVIDER_MODELS["huggingface"]) >= 1
def test_models_py_has_huggingface(self):
from hermes_cli.models import _PROVIDER_MODELS
assert "huggingface" in _PROVIDER_MODELS
models = _PROVIDER_MODELS["huggingface"]
assert len(models) >= 6
assert len(_PROVIDER_MODELS["huggingface"]) >= 1
def test_model_lists_match(self):
"""Model lists in main.py and models.py should be identical."""

View file

@ -115,12 +115,12 @@ class TestArceeCredentials:
class TestArceeModelCatalog:
def test_static_model_list(self):
"""Arcee has a static _PROVIDER_MODELS catalog entry. Specific model
names change with releases and don't belong in tests.
"""
from hermes_cli.models import _PROVIDER_MODELS
assert "arcee" in _PROVIDER_MODELS
models = _PROVIDER_MODELS["arcee"]
assert "trinity-large-thinking" in models
assert "trinity-large-preview" in models
assert "trinity-mini" in models
assert len(_PROVIDER_MODELS["arcee"]) >= 1
def test_canonical_provider_entry(self):
from hermes_cli.models import CANONICAL_PROVIDERS

View file

@ -1011,3 +1011,466 @@ def test_seed_from_singletons_respects_codex_suppression(tmp_path, monkeypatch):
# Verify the auth store was NOT modified (no auto-import happened)
after = json.loads((hermes_home / "auth.json").read_text())
assert "openai-codex" not in after.get("providers", {})
def test_auth_remove_env_seeded_suppresses_shell_exported_var(tmp_path, monkeypatch, capsys):
"""`hermes auth remove xai 1` must stick even when the env var is exported
by the shell (not written into ~/.hermes/.env). Before PR for #13371 the
removal silently restored on next load_pool() because _seed_from_env()
re-read os.environ. Now env:<VAR> is suppressed in auth.json.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Simulate shell export (NOT written to .env)
monkeypatch.setenv("XAI_API_KEY", "sk-xai-shell-export")
(hermes_home / ".env").write_text("")
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"xai": [{
"id": "env-1",
"label": "XAI_API_KEY",
"auth_type": "api_key",
"priority": 0,
"source": "env:XAI_API_KEY",
"access_token": "sk-xai-shell-export",
"base_url": "https://api.x.ai/v1",
}]
},
},
)
from types import SimpleNamespace
from hermes_cli.auth_commands import auth_remove_command
auth_remove_command(SimpleNamespace(provider="xai", target="1"))
# Suppression marker written
after = json.loads((hermes_home / "auth.json").read_text())
assert "env:XAI_API_KEY" in after.get("suppressed_sources", {}).get("xai", [])
# Diagnostic printed pointing at the shell
out = capsys.readouterr().out
assert "still set in your shell environment" in out
assert "Cleared XAI_API_KEY from .env" not in out # wasn't in .env
# Fresh simulation: shell re-exports, reload pool
monkeypatch.setenv("XAI_API_KEY", "sk-xai-shell-export")
from agent.credential_pool import load_pool
pool = load_pool("xai")
assert not pool.has_credentials(), "pool must stay empty — env:XAI_API_KEY suppressed"
def test_auth_remove_env_seeded_dotenv_only_no_shell_hint(tmp_path, monkeypatch, capsys):
"""When the env var lives only in ~/.hermes/.env (not the shell), the
shell-hint should NOT be printed avoid scaring the user about a
non-existent shell export.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Key ONLY in .env, shell must not have it
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
(hermes_home / ".env").write_text("DEEPSEEK_API_KEY=sk-ds-only\n")
# Mimic load_env() populating os.environ
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-ds-only")
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"deepseek": [{
"id": "env-1",
"label": "DEEPSEEK_API_KEY",
"auth_type": "api_key",
"priority": 0,
"source": "env:DEEPSEEK_API_KEY",
"access_token": "sk-ds-only",
}]
},
},
)
from types import SimpleNamespace
from hermes_cli.auth_commands import auth_remove_command
auth_remove_command(SimpleNamespace(provider="deepseek", target="1"))
out = capsys.readouterr().out
assert "Cleared DEEPSEEK_API_KEY from .env" in out
assert "still set in your shell environment" not in out
assert (hermes_home / ".env").read_text().strip() == ""
def test_auth_add_clears_env_suppression_for_provider(tmp_path, monkeypatch):
"""Re-adding a credential via `hermes auth add <provider>` clears any
env:<VAR> suppression marker strong signal the user wants auth back.
Matches the Codex device_code re-link behaviour.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("XAI_API_KEY", raising=False)
_write_auth_store(
tmp_path,
{
"version": 1,
"providers": {},
"suppressed_sources": {"xai": ["env:XAI_API_KEY"]},
},
)
from types import SimpleNamespace
from hermes_cli.auth import is_source_suppressed
from hermes_cli.auth_commands import auth_add_command
assert is_source_suppressed("xai", "env:XAI_API_KEY") is True
auth_add_command(SimpleNamespace(
provider="xai", auth_type="api_key",
api_key="sk-xai-manual", label="manual",
))
assert is_source_suppressed("xai", "env:XAI_API_KEY") is False
def test_seed_from_env_respects_env_suppression(tmp_path, monkeypatch):
"""_seed_from_env() must skip env:<VAR> sources that the user suppressed
via `hermes auth remove`. This is the gate that prevents shell-exported
keys from resurrecting removed credentials.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("XAI_API_KEY", "sk-xai-shell-export")
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {},
"suppressed_sources": {"xai": ["env:XAI_API_KEY"]},
}))
from agent.credential_pool import _seed_from_env
entries = []
changed, active = _seed_from_env("xai", entries)
assert changed is False
assert entries == []
assert active == set()
def test_seed_from_env_respects_openrouter_suppression(tmp_path, monkeypatch):
"""OpenRouter is the special-case branch in _seed_from_env; verify it
honours suppression too.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-shell-export")
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {},
"suppressed_sources": {"openrouter": ["env:OPENROUTER_API_KEY"]},
}))
from agent.credential_pool import _seed_from_env
entries = []
changed, active = _seed_from_env("openrouter", entries)
assert changed is False
assert entries == []
assert active == set()
# =============================================================================
# Unified credential-source stickiness — every source Hermes reads from has a
# registered RemovalStep in agent.credential_sources, and every seeding path
# gates on is_source_suppressed. Below: one test per source proving remove
# sticks across a fresh load_pool() call.
# =============================================================================
def test_seed_from_singletons_respects_nous_suppression(tmp_path, monkeypatch):
"""nous device_code must not re-seed from auth.json when suppressed."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {"nous": {"access_token": "tok", "refresh_token": "r", "expires_at": 9999999999}},
"suppressed_sources": {"nous": ["device_code"]},
}))
from agent.credential_pool import _seed_from_singletons
entries = []
changed, active = _seed_from_singletons("nous", entries)
assert changed is False
assert entries == []
assert active == set()
def test_seed_from_singletons_respects_copilot_suppression(tmp_path, monkeypatch):
"""copilot gh_cli must not re-seed when suppressed."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {},
"suppressed_sources": {"copilot": ["gh_cli"]},
}))
# Stub resolve_copilot_token to return a live token
import hermes_cli.copilot_auth as ca
monkeypatch.setattr(ca, "resolve_copilot_token", lambda: ("ghp_fake", "gh auth token"))
from agent.credential_pool import _seed_from_singletons
entries = []
changed, active = _seed_from_singletons("copilot", entries)
assert changed is False
assert entries == []
assert active == set()
def test_seed_from_singletons_respects_qwen_suppression(tmp_path, monkeypatch):
"""qwen-oauth qwen-cli must not re-seed from ~/.qwen/oauth_creds.json when suppressed."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {},
"suppressed_sources": {"qwen-oauth": ["qwen-cli"]},
}))
import hermes_cli.auth as ha
monkeypatch.setattr(ha, "resolve_qwen_runtime_credentials", lambda **kw: {
"api_key": "tok", "source": "qwen-cli", "base_url": "https://q",
})
from agent.credential_pool import _seed_from_singletons
entries = []
changed, active = _seed_from_singletons("qwen-oauth", entries)
assert changed is False
assert entries == []
assert active == set()
def test_seed_from_singletons_respects_hermes_pkce_suppression(tmp_path, monkeypatch):
"""anthropic hermes_pkce must not re-seed from ~/.hermes/.anthropic_oauth.json when suppressed."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
import yaml
(hermes_home / "config.yaml").write_text(yaml.dump({"model": {"provider": "anthropic", "model": "claude"}}))
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {},
"suppressed_sources": {"anthropic": ["hermes_pkce"]},
}))
# Stub the readers so only hermes_pkce is "available"; claude_code returns None
import agent.anthropic_adapter as aa
monkeypatch.setattr(aa, "read_hermes_oauth_credentials", lambda: {
"accessToken": "tok", "refreshToken": "r", "expiresAt": 9999999999000,
})
monkeypatch.setattr(aa, "read_claude_code_credentials", lambda: None)
from agent.credential_pool import _seed_from_singletons
entries = []
changed, active = _seed_from_singletons("anthropic", entries)
# hermes_pkce suppressed, claude_code returns None → nothing should be seeded
assert entries == []
assert "hermes_pkce" not in active
def test_seed_custom_pool_respects_config_suppression(tmp_path, monkeypatch):
"""Custom provider config:<name> source must not re-seed when suppressed."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
import yaml
(hermes_home / "config.yaml").write_text(yaml.dump({
"model": {},
"custom_providers": [
{"name": "my", "base_url": "https://c.example.com", "api_key": "sk-custom"},
],
}))
from agent.credential_pool import _seed_custom_pool, get_custom_provider_pool_key
pool_key = get_custom_provider_pool_key("https://c.example.com")
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {},
"suppressed_sources": {pool_key: ["config:my"]},
}))
entries = []
changed, active = _seed_custom_pool(pool_key, entries)
assert changed is False
assert entries == []
assert "config:my" not in active
def test_credential_sources_registry_has_expected_steps():
"""Sanity check — the registry contains the expected RemovalSteps.
Guards against accidentally dropping a step during future refactors.
If you add a new credential source, add it to the expected set below.
"""
from agent.credential_sources import _REGISTRY
descriptions = {step.description for step in _REGISTRY}
expected = {
"gh auth token / COPILOT_GITHUB_TOKEN / GH_TOKEN",
"Any env-seeded credential (XAI_API_KEY, DEEPSEEK_API_KEY, etc.)",
"~/.claude/.credentials.json",
"~/.hermes/.anthropic_oauth.json",
"auth.json providers.nous",
"auth.json providers.openai-codex + ~/.codex/auth.json",
"~/.qwen/oauth_creds.json",
"Custom provider config.yaml api_key field",
}
assert descriptions == expected, f"Registry mismatch. Got: {descriptions}"
def test_credential_sources_find_step_returns_none_for_manual():
"""Manual entries have nothing external to clean up — no step registered."""
from agent.credential_sources import find_removal_step
assert find_removal_step("openrouter", "manual") is None
assert find_removal_step("xai", "manual") is None
def test_credential_sources_find_step_copilot_before_generic_env(tmp_path, monkeypatch):
"""copilot env:GH_TOKEN must dispatch to the copilot step, not the
generic env-var step. The copilot step handles the duplicate-source
problem (same token seeded as both gh_cli and env:<VAR>); the generic
env step would only suppress one of the variants.
"""
from agent.credential_sources import find_removal_step
step = find_removal_step("copilot", "env:GH_TOKEN")
assert step is not None
assert "copilot" in step.description.lower() or "gh" in step.description.lower()
# Generic step still matches any other provider's env var
step = find_removal_step("xai", "env:XAI_API_KEY")
assert step is not None
assert "env-seeded" in step.description.lower()
def test_auth_remove_copilot_suppresses_all_variants(tmp_path, monkeypatch):
"""Removing any copilot source must suppress gh_cli + all env:* variants
so the duplicate-seed paths don't resurrect the credential.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"copilot": [{
"id": "c1",
"label": "gh auth token",
"auth_type": "api_key",
"priority": 0,
"source": "gh_cli",
"access_token": "ghp_fake",
}]
},
},
)
from types import SimpleNamespace
from hermes_cli.auth import is_source_suppressed
from hermes_cli.auth_commands import auth_remove_command
auth_remove_command(SimpleNamespace(provider="copilot", target="1"))
assert is_source_suppressed("copilot", "gh_cli")
assert is_source_suppressed("copilot", "env:COPILOT_GITHUB_TOKEN")
assert is_source_suppressed("copilot", "env:GH_TOKEN")
assert is_source_suppressed("copilot", "env:GITHUB_TOKEN")
def test_auth_add_clears_all_suppressions_including_non_env(tmp_path, monkeypatch):
"""Re-adding a credential via `hermes auth add <provider>` clears ALL
suppression markers for the provider, not just env:*. This matches
the single "re-engage" semantic the user wants auth back, period.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
_write_auth_store(
tmp_path,
{
"version": 1,
"providers": {},
"suppressed_sources": {
"copilot": ["gh_cli", "env:GH_TOKEN", "env:COPILOT_GITHUB_TOKEN"],
},
},
)
from types import SimpleNamespace
from hermes_cli.auth import is_source_suppressed
from hermes_cli.auth_commands import auth_add_command
auth_add_command(SimpleNamespace(
provider="copilot", auth_type="api_key",
api_key="ghp-manual", label="m",
))
assert not is_source_suppressed("copilot", "gh_cli")
assert not is_source_suppressed("copilot", "env:GH_TOKEN")
assert not is_source_suppressed("copilot", "env:COPILOT_GITHUB_TOKEN")
def test_auth_remove_codex_manual_device_code_suppresses_canonical(tmp_path, monkeypatch):
"""Removing a manual:device_code entry (from `hermes auth add openai-codex`)
must suppress the canonical ``device_code`` key, not ``manual:device_code``.
The re-seed gate in _seed_from_singletons checks ``device_code``.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
_write_auth_store(
tmp_path,
{
"version": 1,
"providers": {"openai-codex": {"tokens": {"access_token": "t", "refresh_token": "r"}}},
"credential_pool": {
"openai-codex": [{
"id": "cdx",
"label": "manual-codex",
"auth_type": "oauth",
"priority": 0,
"source": "manual:device_code",
"access_token": "t",
}]
},
},
)
from types import SimpleNamespace
from hermes_cli.auth import is_source_suppressed
from hermes_cli.auth_commands import auth_remove_command
auth_remove_command(SimpleNamespace(provider="openai-codex", target="1"))
assert is_source_suppressed("openai-codex", "device_code")

View file

@ -459,7 +459,8 @@ class TestCustomProviderCompatibility:
migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert raw["_config_version"] == 21
from hermes_cli.config import DEFAULT_CONFIG
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
assert raw["providers"]["openai-direct"] == {
"api": "https://api.openai.com/v1",
"api_key": "test-key",
@ -501,7 +502,8 @@ class TestCustomProviderCompatibility:
assert compatible[0]["provider_key"] == "openai-direct"
assert compatible[0]["api_mode"] == "codex_responses"
def test_compatible_custom_providers_prefers_api_then_url_then_base_url(self, tmp_path):
def test_compatible_custom_providers_prefers_base_url_then_url_then_api(self, tmp_path):
"""URL field precedence is base_url > url > api (PR #9332)."""
config_path = tmp_path / "config.yaml"
config_path.write_text(
yaml.safe_dump(
@ -526,7 +528,7 @@ class TestCustomProviderCompatibility:
assert compatible == [
{
"name": "My Provider",
"base_url": "https://api.example.com/v1",
"base_url": "https://base.example.com/v1",
"provider_key": "my-provider",
}
]
@ -606,7 +608,8 @@ class TestInterimAssistantMessageConfig:
migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert raw["_config_version"] == 21
from hermes_cli.config import DEFAULT_CONFIG
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
assert raw["display"]["tool_progress"] == "off"
assert raw["display"]["interim_assistant_messages"] is True
@ -626,7 +629,8 @@ class TestDiscordChannelPromptsConfig:
migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert raw["_config_version"] == 21
from hermes_cli.config import DEFAULT_CONFIG
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
assert raw["discord"]["auto_thread"] is True
assert raw["discord"]["channel_prompts"] == {}

View file

@ -125,18 +125,12 @@ class TestGeminiCredentials:
# ── Model Catalog ──
class TestGeminiModelCatalog:
def test_provider_models_exist(self):
def test_provider_entry_exists(self):
"""Gemini provider has a model catalog entry. Specific model names
are data that changes with Google releases and don't belong in tests.
"""
assert "gemini" in _PROVIDER_MODELS
models = _PROVIDER_MODELS["gemini"]
assert "gemini-2.5-pro" in models
assert "gemini-2.5-flash" in models
assert "gemma-4-31b-it" not in models
def test_provider_models_has_3x(self):
models = _PROVIDER_MODELS["gemini"]
assert "gemini-3.1-pro-preview" in models
assert "gemini-3-flash-preview" in models
assert "gemini-3.1-flash-lite-preview" in models
assert len(_PROVIDER_MODELS["gemini"]) >= 1
def test_provider_label(self):
assert "gemini" in _PROVIDER_LABELS

View file

@ -457,29 +457,62 @@ class TestValidateApiNotFound:
assert "not found" in result["message"]
# -- validate — API unreachable — reject with guidance ----------------
# -- validate — API unreachable — soft-accept via catalog or warning --------
class TestValidateApiFallback:
def test_any_model_rejected_when_api_down(self):
result = _validate("anthropic/claude-opus-4.6", api_models=None)
assert result["accepted"] is False
assert result["persist"] is False
"""When /models is unreachable, the validator must accept the model (with
a warning) rather than reject it outright otherwise provider switches
fail in the gateway for any provider whose /models endpoint is down or
doesn't exist (e.g. opencode-go returns 404 HTML).
def test_unknown_model_also_rejected_when_api_down(self):
result = _validate("anthropic/claude-next-gen", api_models=None)
assert result["accepted"] is False
assert result["persist"] is False
assert "could not reach" in result["message"].lower()
Two paths:
1. Provider has a curated catalog (``_PROVIDER_MODELS`` / live fetch):
validate against it (recognized=True for known models,
recognized=False with 'Note:' for unknown).
2. Provider has no catalog: accept with a generic 'Note:' warning.
def test_zai_model_rejected_when_api_down(self):
In both cases ``accepted`` and ``persist`` must be True so the gateway can
write the ``_session_model_overrides`` entry.
"""
def test_known_model_accepted_via_catalog_when_api_down(self):
# Force the openrouter catalog lookup to return a deterministic list.
with patch(
"hermes_cli.models.provider_model_ids",
return_value=["anthropic/claude-opus-4.6", "openai/gpt-5.4"],
):
result = _validate("anthropic/claude-opus-4.6", api_models=None)
assert result["accepted"] is True
assert result["persist"] is True
assert result["recognized"] is True
def test_unknown_model_accepted_with_note_when_api_down(self):
with patch(
"hermes_cli.models.provider_model_ids",
return_value=["anthropic/claude-opus-4.6", "openai/gpt-5.4"],
):
result = _validate("anthropic/claude-next-gen", api_models=None)
assert result["accepted"] is True
assert result["persist"] is True
assert result["recognized"] is False
# Message flags it as unverified against the catalog.
assert "not found" in result["message"].lower() or "note" in result["message"].lower()
def test_zai_known_model_accepted_via_catalog_when_api_down(self):
# glm-5 is in the zai curated catalog (_PROVIDER_MODELS["zai"]).
result = _validate("glm-5", provider="zai", api_models=None)
assert result["accepted"] is False
assert result["persist"] is False
assert result["accepted"] is True
assert result["persist"] is True
assert result["recognized"] is True
def test_unknown_provider_rejected_when_api_down(self):
result = _validate("some-model", provider="totally-unknown", api_models=None)
assert result["accepted"] is False
assert result["persist"] is False
def test_unknown_provider_soft_accepted_when_api_down(self):
# No catalog for unknown providers — soft-accept with a Note.
with patch("hermes_cli.models.provider_model_ids", return_value=[]):
result = _validate("some-model", provider="totally-unknown", api_models=None)
assert result["accepted"] is True
assert result["persist"] is True
assert result["recognized"] is False
assert "note" in result["message"].lower()
def test_custom_endpoint_warns_with_probed_url_and_v1_hint(self):
with patch(

View file

@ -88,6 +88,131 @@ class TestFetchOpenRouterModels:
assert models == OPENROUTER_MODELS
def test_filters_out_models_without_tool_support(self, monkeypatch):
"""Models whose supported_parameters omits 'tools' must not appear in the picker.
hermes-agent is tool-calling-first surfacing a non-tool model leads to
immediate runtime failures when the user selects it. Ported from
Kilo-Org/kilocode#9068.
"""
class _Resp:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
# opus-4.6 advertises tools → kept
# nano-image has explicit supported_parameters that OMITS tools → dropped
# qwen3.6-plus advertises tools → kept
return (
b'{"data":['
b'{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"},'
b'"supported_parameters":["temperature","tools","tool_choice"]},'
b'{"id":"google/gemini-3-pro-image-preview","pricing":{"prompt":"0.00001","completion":"0.00003"},'
b'"supported_parameters":["temperature","response_format"]},'
b'{"id":"qwen/qwen3.6-plus","pricing":{"prompt":"0.000000325","completion":"0.00000195"},'
b'"supported_parameters":["tools","temperature"]}'
b']}'
)
# Include the image-only id in the curated list so it has a chance to be surfaced.
monkeypatch.setattr(
_models_mod,
"OPENROUTER_MODELS",
[
("anthropic/claude-opus-4.6", ""),
("google/gemini-3-pro-image-preview", ""),
("qwen/qwen3.6-plus", ""),
],
)
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()):
models = fetch_openrouter_models(force_refresh=True)
ids = [mid for mid, _ in models]
assert "anthropic/claude-opus-4.6" in ids
assert "qwen/qwen3.6-plus" in ids
# Image-only model advertised supported_parameters WITHOUT tools → must be dropped.
assert "google/gemini-3-pro-image-preview" not in ids
def test_permissive_when_supported_parameters_missing(self, monkeypatch):
"""Models missing the supported_parameters field keep appearing in the picker.
Some OpenRouter-compatible gateways (Nous Portal, private mirrors, older
catalog snapshots) don't populate supported_parameters. Treating missing
as 'unknown → allow' prevents the picker from silently emptying on
those gateways.
"""
class _Resp:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
# No supported_parameters field at all on either entry.
return (
b'{"data":['
b'{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"}},'
b'{"id":"qwen/qwen3.6-plus","pricing":{"prompt":"0.000000325","completion":"0.00000195"}}'
b']}'
)
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()):
models = fetch_openrouter_models(force_refresh=True)
ids = [mid for mid, _ in models]
assert "anthropic/claude-opus-4.6" in ids
assert "qwen/qwen3.6-plus" in ids
class TestOpenRouterToolSupportHelper:
"""Unit tests for _openrouter_model_supports_tools (Kilo port #9068)."""
def test_tools_in_supported_parameters(self):
from hermes_cli.models import _openrouter_model_supports_tools
assert _openrouter_model_supports_tools(
{"id": "x", "supported_parameters": ["temperature", "tools"]}
) is True
def test_tools_missing_from_supported_parameters(self):
from hermes_cli.models import _openrouter_model_supports_tools
assert _openrouter_model_supports_tools(
{"id": "x", "supported_parameters": ["temperature", "response_format"]}
) is False
def test_supported_parameters_absent_is_permissive(self):
"""Missing field → allow (so older / non-OR gateways still work)."""
from hermes_cli.models import _openrouter_model_supports_tools
assert _openrouter_model_supports_tools({"id": "x"}) is True
def test_supported_parameters_none_is_permissive(self):
from hermes_cli.models import _openrouter_model_supports_tools
assert _openrouter_model_supports_tools({"id": "x", "supported_parameters": None}) is True
def test_supported_parameters_malformed_is_permissive(self):
"""Malformed (non-list) value → allow rather than silently drop."""
from hermes_cli.models import _openrouter_model_supports_tools
assert _openrouter_model_supports_tools(
{"id": "x", "supported_parameters": "tools,temperature"}
) is True
def test_non_dict_item_is_permissive(self):
from hermes_cli.models import _openrouter_model_supports_tools
assert _openrouter_model_supports_tools(None) is True
assert _openrouter_model_supports_tools("anthropic/claude-opus-4.6") is True
def test_empty_supported_parameters_list_drops_model(self):
"""Explicit empty list → no tools → drop."""
from hermes_cli.models import _openrouter_model_supports_tools
assert _openrouter_model_supports_tools(
{"id": "x", "supported_parameters": []}
) is False
class TestFindOpenrouterSlug:
def test_exact_match(self):

View file

@ -15,7 +15,7 @@ def test_opencode_go_appears_when_api_key_set():
opencode_go = next((p for p in providers if p["slug"] == "opencode-go"), None)
assert opencode_go is not None, "opencode-go should appear when OPENCODE_GO_API_KEY is set"
assert opencode_go["models"] == ["kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5"]
assert opencode_go["models"] == ["kimi-k2.6", "kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5"]
# opencode-go can appear as "built-in" (from PROVIDER_TO_MODELS_DEV when
# models.dev is reachable) or "hermes" (from HERMES_OVERLAYS fallback when
# the API is unavailable, e.g. in CI).

View file

@ -0,0 +1,133 @@
"""Tests for the static-catalog fallback in validate_requested_model.
OpenCode Go and OpenCode Zen publish an OpenAI-compatible API at paths that do
NOT expose ``/models`` (the path returns the marketing site's HTML 404). This
caused ``validate_requested_model`` to return ``accepted=False`` for every
model on those providers, which in turn made ``switch_model()`` fail and the
gateway's ``/model <name> --provider opencode-go`` command never write to
``_session_model_overrides``.
These tests cover the catalog-fallback path: when ``fetch_api_models`` returns
``None``, the validator must consult ``provider_model_ids()`` for the provider
(populated from ``_PROVIDER_MODELS``) rather than rejecting outright.
"""
from unittest.mock import patch
from hermes_cli.models import validate_requested_model
_UNREACHABLE_PROBE = {
"models": None,
"probed_url": "https://opencode.ai/zen/go/v1/models",
"resolved_base_url": "https://opencode.ai/zen/go/v1",
"suggested_base_url": None,
"used_fallback": False,
}
def _patched(func):
"""Decorator: force fetch_api_models / probe_api_models to simulate an
unreachable /models endpoint, proving the catalog path is used."""
def wrapper(*args, **kwargs):
with patch("hermes_cli.models.fetch_api_models", return_value=None), \
patch("hermes_cli.models.probe_api_models", return_value=_UNREACHABLE_PROBE):
return func(*args, **kwargs)
wrapper.__name__ = func.__name__
return wrapper
# ---------------------------------------------------------------------------
# opencode-go: curated catalog in _PROVIDER_MODELS
# ---------------------------------------------------------------------------
@_patched
def test_opencode_go_known_model_accepted():
"""A model present in the opencode-go curated catalog must be accepted
even when /models is unreachable."""
result = validate_requested_model("kimi-k2.6", "opencode-go")
assert result["accepted"] is True
assert result["persist"] is True
assert result["recognized"] is True
assert result["message"] is None
@_patched
def test_opencode_go_known_model_case_insensitive():
"""Catalog lookup is case-insensitive."""
result = validate_requested_model("KIMI-K2.6", "opencode-go")
assert result["accepted"] is True
assert result["recognized"] is True
@_patched
def test_opencode_go_typo_auto_corrected():
"""A close typo (>= 0.9 similarity) is auto-corrected to the catalog
entry."""
# 'kimi-k2.55' vs 'kimi-k2.5' ratio ≈ 0.95 — within the 0.9 cutoff.
result = validate_requested_model("kimi-k2.55", "opencode-go")
assert result["accepted"] is True
assert result["recognized"] is True
assert result.get("corrected_model") == "kimi-k2.5"
@_patched
def test_opencode_go_unknown_model_accepted_with_suggestion():
"""An unknown model that has a medium-similarity match (>= 0.5 but < 0.9)
is accepted with recognized=False and a 'similar models' hint. The key
invariant: the gateway MUST be able to persist this override, so
accepted/persist must both be True."""
# 'kimi-k3-preview' vs 'kimi-k2.6' — similar enough to suggest, not to auto-correct.
result = validate_requested_model("kimi-k3-preview", "opencode-go")
assert result["accepted"] is True
assert result["persist"] is True
assert result["recognized"] is False
assert "kimi-k3-preview" in result["message"]
assert "curated catalog" in result["message"]
@_patched
def test_opencode_go_totally_unknown_model_still_accepted():
"""A model with zero similarity to the catalog is still accepted (no
suggestion line) so the user can try a model that hasn't made it into the
curated list yet."""
result = validate_requested_model("some-brand-new-model", "opencode-go")
assert result["accepted"] is True
assert result["persist"] is True
assert result["recognized"] is False
# No suggestion text (no close matches)
assert "Similar models" not in result["message"]
assert "opencode" in result["message"].lower() or "opencode go" in result["message"].lower()
# ---------------------------------------------------------------------------
# opencode-zen: same pattern as opencode-go
# ---------------------------------------------------------------------------
@_patched
def test_opencode_zen_known_model_accepted():
"""opencode-zen also uses _PROVIDER_MODELS; kimi-k2 is in its catalog."""
result = validate_requested_model("kimi-k2", "opencode-zen")
assert result["accepted"] is True
assert result["recognized"] is True
# ---------------------------------------------------------------------------
# Unknown provider with no catalog: soft-accept (honors the comment's intent)
# ---------------------------------------------------------------------------
@_patched
def test_provider_without_catalog_accepts_with_warning():
"""When a provider has no entry in _PROVIDER_MODELS and /models is
unreachable, accept the model with a 'Note:' warning rather than reject.
This matches the in-code comment: 'Accept and persist, but warn so typos
don't silently break things.'"""
# Use a made-up provider name that won't resolve to any catalog.
result = validate_requested_model("some-model", "provider-that-does-not-exist")
assert result["accepted"] is True
assert result["persist"] is True
assert result["recognized"] is False
assert "Note:" in result["message"]

View file

@ -1412,3 +1412,90 @@ def test_named_custom_runtime_no_model_when_absent(monkeypatch):
resolved = rp.resolve_runtime_provider(requested="my-server")
assert "model" not in resolved
# ---------------------------------------------------------------------------
# GHSA-76xc-57q6-vm5m — Ollama URL substring leak
#
# Same bug class as the previously-fixed GHSA-xf8p-v2cg-h7h5 (OpenRouter).
# _resolve_openrouter_runtime's custom-endpoint branch selects OLLAMA_API_KEY
# when the base_url "looks like" ollama.com. Previous implementation used
# raw substring match; a custom base_url whose PATH or look-alike host
# merely contained "ollama.com" leaked OLLAMA_API_KEY to that endpoint.
# Fix: use base_url_host_matches (same helper as the OpenRouter sweep).
# ---------------------------------------------------------------------------
class TestOllamaUrlSubstringLeak:
"""Call-site regression tests for the fix in _resolve_openrouter_runtime."""
def _make_cfg(self, base_url):
return {"base_url": base_url, "api_key": "", "provider": "custom"}
def test_ollama_key_not_leaked_to_path_injection(self, monkeypatch):
"""http://127.0.0.1:9000/ollama.com/v1 — attacker endpoint with
ollama.com in PATH. Must resolve to OPENAI_API_KEY, not OLLAMA_API_KEY."""
monkeypatch.setenv("OPENAI_API_KEY", "oa-secret")
monkeypatch.setenv("OPENROUTER_API_KEY", "or-secret")
monkeypatch.setenv("OLLAMA_API_KEY", "ol-SECRET-should-not-leak")
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "custom")
monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg(
"http://127.0.0.1:9000/ollama.com/v1"
))
monkeypatch.setattr(rp, "load_pool", lambda provider: None)
monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
resolved = rp.resolve_runtime_provider(requested="custom")
assert "ol-SECRET" not in resolved["api_key"], (
"OLLAMA_API_KEY must not be sent to an endpoint whose "
"hostname is not ollama.com (GHSA-76xc-57q6-vm5m)"
)
assert resolved["api_key"] == "oa-secret"
def test_ollama_key_not_leaked_to_lookalike_host(self, monkeypatch):
"""ollama.com.attacker.test — look-alike host. OLLAMA_API_KEY
must not be sent."""
monkeypatch.setenv("OPENAI_API_KEY", "oa-secret")
monkeypatch.setenv("OLLAMA_API_KEY", "ol-SECRET-should-not-leak")
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "custom")
monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg(
"http://ollama.com.attacker.test:9000/v1"
))
monkeypatch.setattr(rp, "load_pool", lambda provider: None)
monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
resolved = rp.resolve_runtime_provider(requested="custom")
assert "ol-SECRET" not in resolved["api_key"]
assert resolved["api_key"] == "oa-secret"
def test_ollama_key_sent_to_genuine_ollama_com(self, monkeypatch):
"""https://ollama.com/v1 — legit Ollama Cloud. OLLAMA_API_KEY
should be used."""
monkeypatch.setenv("OPENAI_API_KEY", "oa-secret")
monkeypatch.setenv("OLLAMA_API_KEY", "ol-legit-key")
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "custom")
monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg(
"https://ollama.com/v1"
))
monkeypatch.setattr(rp, "load_pool", lambda provider: None)
monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
resolved = rp.resolve_runtime_provider(requested="custom")
assert resolved["api_key"] == "ol-legit-key"
def test_ollama_key_sent_to_ollama_subdomain(self, monkeypatch):
"""https://api.ollama.com/v1 — legit subdomain."""
monkeypatch.setenv("OPENAI_API_KEY", "oa-secret")
monkeypatch.setenv("OLLAMA_API_KEY", "ol-legit-key")
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "custom")
monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg(
"https://api.ollama.com/v1"
))
monkeypatch.setattr(rp, "load_pool", lambda provider: None)
monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
resolved = rp.resolve_runtime_provider(requested="custom")
assert resolved["api_key"] == "ol-legit-key"

View file

@ -0,0 +1,148 @@
"""Tests for GHSA-ppp5-vxwm-4cf7 — Host-header validation.
DNS rebinding defence: a victim browser that has the dashboard open
could be tricked into fetching from an attacker-controlled hostname
that TTL-flips to 127.0.0.1. Same-origin / CORS checks won't help —
the browser now treats the attacker origin as same-origin. Validating
the Host header at the application layer rejects the attack.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
_repo = str(Path(__file__).resolve().parents[1])
if _repo not in sys.path:
sys.path.insert(0, _repo)
class TestHostHeaderValidator:
"""Unit test the _is_accepted_host helper directly — cheaper and
more thorough than spinning up the full FastAPI app."""
def test_loopback_bind_accepts_loopback_names(self):
from hermes_cli.web_server import _is_accepted_host
for bound in ("127.0.0.1", "localhost", "::1"):
for host_header in (
"127.0.0.1", "127.0.0.1:9119",
"localhost", "localhost:9119",
"[::1]", "[::1]:9119",
):
assert _is_accepted_host(host_header, bound), (
f"bound={bound} must accept host={host_header}"
)
def test_loopback_bind_rejects_attacker_hostnames(self):
"""The core rebinding defence: attacker-controlled hosts that
TTL-flip to 127.0.0.1 must be rejected."""
from hermes_cli.web_server import _is_accepted_host
for bound in ("127.0.0.1", "localhost"):
for attacker in (
"evil.example",
"evil.example:9119",
"rebind.attacker.test:80",
"localhost.attacker.test", # subdomain trick
"127.0.0.1.evil.test", # lookalike IP prefix
"", # missing Host
):
assert not _is_accepted_host(attacker, bound), (
f"bound={bound} must reject attacker host={attacker!r}"
)
def test_zero_zero_bind_accepts_anything(self):
"""0.0.0.0 means operator explicitly opted into all-interfaces
(requires --insecure). No Host-layer defence is possible rely
on operator network controls."""
from hermes_cli.web_server import _is_accepted_host
for host in ("10.0.0.5", "evil.example", "my-server.corp.net"):
assert _is_accepted_host(host, "0.0.0.0")
assert _is_accepted_host(host + ":9119", "0.0.0.0")
def test_explicit_non_loopback_bind_requires_exact_match(self):
"""If the operator bound to a specific non-loopback hostname,
the Host header must match exactly."""
from hermes_cli.web_server import _is_accepted_host
assert _is_accepted_host("my-server.corp.net", "my-server.corp.net")
assert _is_accepted_host("my-server.corp.net:9119", "my-server.corp.net")
# Different host — reject
assert not _is_accepted_host("evil.example", "my-server.corp.net")
# Loopback — reject (we bound to a specific non-loopback name)
assert not _is_accepted_host("localhost", "my-server.corp.net")
def test_case_insensitive_comparison(self):
"""Host headers are case-insensitive per RFC — accept variations."""
from hermes_cli.web_server import _is_accepted_host
assert _is_accepted_host("LOCALHOST", "127.0.0.1")
assert _is_accepted_host("LocalHost:9119", "127.0.0.1")
class TestHostHeaderMiddleware:
"""End-to-end test via the FastAPI app — verify the middleware
rejects bad Host headers with 400."""
def test_rebinding_request_rejected(self):
from fastapi.testclient import TestClient
from hermes_cli.web_server import app
# Simulate start_server having set the bound_host
app.state.bound_host = "127.0.0.1"
try:
client = TestClient(app)
# The TestClient sends Host: testserver by default — which is
# NOT a loopback alias, so the middleware must reject it.
resp = client.get(
"/api/status",
headers={"Host": "evil.example"},
)
assert resp.status_code == 400
assert "Invalid Host header" in resp.json()["detail"]
finally:
# Clean up so other tests don't inherit the bound_host
if hasattr(app.state, "bound_host"):
del app.state.bound_host
def test_legit_loopback_request_accepted(self):
from fastapi.testclient import TestClient
from hermes_cli.web_server import app
app.state.bound_host = "127.0.0.1"
try:
client = TestClient(app)
# /api/status is in _PUBLIC_API_PATHS — passes auth — so the
# only thing that can reject is the host header middleware
resp = client.get(
"/api/status",
headers={"Host": "localhost:9119"},
)
# Either 200 (endpoint served) or some other non-400 —
# just not the host-rejection 400
assert resp.status_code != 400 or (
"Invalid Host header" not in resp.json().get("detail", "")
)
finally:
if hasattr(app.state, "bound_host"):
del app.state.bound_host
def test_no_bound_host_skips_validation(self):
"""If app.state.bound_host isn't set (e.g. running under test
infra without calling start_server), middleware must pass through
rather than crash."""
from fastapi.testclient import TestClient
from hermes_cli.web_server import app
# Make sure bound_host isn't set
if hasattr(app.state, "bound_host"):
del app.state.bound_host
client = TestClient(app)
resp = client.get("/api/status")
# Should get through to the status endpoint, not a 400
assert resp.status_code != 400

View file

@ -136,13 +136,15 @@ class TestXiaomiModelCatalog:
assert PROVIDER_TO_MODELS_DEV["xiaomi"] == "xiaomi"
def test_static_model_list_fallback(self):
"""Static _PROVIDER_MODELS fallback must exist for model picker."""
"""Static _PROVIDER_MODELS fallback must exist for model picker.
We only assert the provider key is present the specific model
names are data that changes with upstream releases and doesn't
belong in tests.
"""
from hermes_cli.models import _PROVIDER_MODELS
assert "xiaomi" in _PROVIDER_MODELS
models = _PROVIDER_MODELS["xiaomi"]
assert "mimo-v2-pro" in models
assert "mimo-v2-omni" in models
assert "mimo-v2-flash" in models
assert len(_PROVIDER_MODELS["xiaomi"]) >= 1
def test_list_agentic_models_mock(self, monkeypatch):
"""When models.dev returns Xiaomi data, list_agentic_models should return models."""