fix(model): clear stale endpoint credentials across switches

This commit is contained in:
helix4u 2026-06-19 20:36:09 -06:00 committed by Teknium
parent 95a3affc2e
commit c253b07380
9 changed files with 187 additions and 17 deletions

View file

@ -378,6 +378,99 @@ def test_model_flow_nous_does_not_restore_stale_custom_api_key(tmp_path, monkeyp
assert "api_mode" not in model
def _seed_stale_custom_model(tmp_path, monkeypatch):
import yaml
config_home = tmp_path / "hermes"
config_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(config_home))
config_path = config_home / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"model": {
"provider": "custom",
"default": "glm-5.2",
"base_url": "https://api.neuralwatt.com/v1",
"api_key": "${NEURALWATT_API_KEY}",
"api": "legacy-stale-key",
"api_mode": "anthropic_messages",
}
},
sort_keys=False,
)
)
(config_home / ".env").write_text("")
return config_path
def test_model_flow_openrouter_clears_stale_custom_key(tmp_path, monkeypatch):
import yaml
config_path = _seed_stale_custom_model(tmp_path, monkeypatch)
monkeypatch.setattr(
"hermes_cli.main._prompt_api_key",
lambda *args, **kwargs: ("sk-openrouter", False),
)
monkeypatch.setattr(
"hermes_cli.models.model_ids",
lambda **kwargs: ["anthropic/claude-sonnet-4.6"],
)
monkeypatch.setattr("hermes_cli.models.get_pricing_for_provider", lambda *a, **k: {})
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda *args, **kwargs: "anthropic/claude-sonnet-4.6",
)
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
hermes_main._model_flow_openrouter({}, current_model="glm-5.2")
config = yaml.safe_load(config_path.read_text()) or {}
model = config["model"]
assert model["provider"] == "openrouter"
assert model["default"] == "anthropic/claude-sonnet-4.6"
assert model["api_mode"] == "chat_completions"
assert "api_key" not in model
assert "api" not in model
def test_model_flow_anthropic_clears_stale_custom_key_and_mode(tmp_path, monkeypatch):
import yaml
config_path = _seed_stale_custom_model(tmp_path, monkeypatch)
monkeypatch.setattr("hermes_cli.auth.get_anthropic_key", lambda: "sk-ant-api03-test")
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
lambda: None,
)
monkeypatch.setattr(
"agent.anthropic_adapter.is_claude_code_token_valid",
lambda creds: False,
)
monkeypatch.setattr(
"hermes_cli.model_setup_flows._prompt_auth_credentials_choice",
lambda title: "use",
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda *args, **kwargs: "claude-sonnet-4-6",
)
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
hermes_main._model_flow_anthropic({}, current_model="glm-5.2")
config = yaml.safe_load(config_path.read_text()) or {}
model = config["model"]
assert model["provider"] == "anthropic"
assert model["default"] == "claude-sonnet-4-6"
assert "base_url" not in model
assert "api_key" not in model
assert "api" not in model
assert "api_mode" not in model
def test_model_flow_nous_offers_tool_gateway_prompt_when_unconfigured(monkeypatch, capsys):
from hermes_cli.nous_account import NousPortalAccountInfo

View file

@ -140,7 +140,13 @@ async def _drive_picker(runner, event):
"seed_model",
[
# Already-nested dict (common case).
{"default": "old-model", "provider": "openai-codex"},
{
"default": "old-model",
"provider": "custom",
"base_url": "https://api.custom.example/v1",
"api_key": "sk-stale",
"api_mode": "anthropic_messages",
},
# Flat-string model: must be coerced to a nested dict on a tap (same
# scalar-``model:`` guard the text path has) instead of raising
# ``TypeError`` on assignment.
@ -166,6 +172,8 @@ async def test_picker_tap_persists_by_default(tmp_path, monkeypatch, seed_model)
assert written["model"]["default"] == "gpt-5.5"
assert written["model"]["provider"] == "openrouter"
assert written["model"]["base_url"] == "https://openrouter.ai/api/v1"
assert "api_key" not in written["model"]
assert "api_mode" not in written["model"]
@pytest.mark.asyncio

View file

@ -16,7 +16,7 @@ from __future__ import annotations
import yaml
from hermes_cli.auth import _update_config_for_provider
from hermes_cli.config import get_config_path
from hermes_cli.config import clear_model_endpoint_credentials, get_config_path
def _read_model_cfg() -> dict:
@ -49,6 +49,23 @@ def _seed_custom_provider_config(api_mode: str = "anthropic_messages") -> None:
class TestUpdateConfigForProviderClearsStaleCustomFields:
def test_clear_model_endpoint_credentials_removes_key_alias_and_mode(self):
model_cfg = {
"provider": "openrouter",
"default": "anthropic/claude-sonnet-4.6",
"api_key": "sk-stale",
"api": "sk-legacy-stale",
"api_mode": "anthropic_messages",
}
returned = clear_model_endpoint_credentials(model_cfg)
assert returned is model_cfg
assert "api_key" not in model_cfg
assert "api" not in model_cfg
assert "api_mode" not in model_cfg
assert model_cfg["provider"] == "openrouter"
def test_switching_to_openrouter_clears_api_key_and_api_mode(self):
_seed_custom_provider_config()

View file

@ -2327,9 +2327,10 @@ class TestWebServerEndpoints:
# api_key follows the same lifecycle as base_url:
# supplied → persisted.
out = _apply_main_model_assignment(
{}, "custom", "m", "http://x/v1", "sk-secret"
{"api": "sk-legacy-old"}, "custom", "m", "http://x/v1", "sk-secret"
)
assert out["api_key"] == "sk-secret"
assert "api" not in out
# same provider, no new key → existing key preserved (re-picking a model
# on the same custom endpoint must not wipe the saved key).
@ -2342,9 +2343,12 @@ class TestWebServerEndpoints:
# switching providers without a new key → stale key cleared.
out = _apply_main_model_assignment(
{"provider": "custom", "api_key": "sk-old"}, "openrouter", "m"
{"provider": "custom", "api_key": "sk-old", "api_mode": "anthropic_messages"},
"openrouter",
"m",
)
assert out["api_key"] == ""
assert "api_key" not in out
assert "api_mode" not in out
def test_parse_model_ids_handles_openai_and_bare_shapes(self):
"""Model discovery must tolerate the common /v1/models shapes and