diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 884866dc1173..f823c72a45d5 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1685,6 +1685,21 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo agent._fallback_activated = False agent._fallback_index = 0 + # Drop a credential pool that belongs to the previous provider. The pool + # was seeded at agent init; if it survives a cross-provider /model switch, + # a later recoverable 401/429 can rotate back onto an old-provider entry + # and overwrite the live runtime with the wrong base_url/api_key. + _existing_pool = getattr(agent, "_credential_pool", None) + if _existing_pool is not None: + _pool_provider = (getattr(_existing_pool, "provider", "") or "").strip().lower() + _new_provider_norm = (new_provider or "").strip().lower() + if _pool_provider and _new_provider_norm and _pool_provider != _new_provider_norm: + logger.info( + "Model switch to %s/%s: detaching credential pool seeded for provider %s", + new_provider, new_model, _pool_provider, + ) + agent._credential_pool = None + # When the user deliberately swaps primary providers (e.g. openrouter # → anthropic), drop any fallback entries that target the OLD primary # or the NEW one. The chain was seeded from config at agent init for diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index f1f87c7703ca..be39babd0d82 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -98,7 +98,27 @@ def _provider_base_url(provider: str) -> str: return str(cp_config.get("base_url") or "").strip() return "" pconfig = PROVIDER_REGISTRY.get(provider) - return pconfig.inference_base_url if pconfig else "" + registry_base_url = pconfig.inference_base_url if pconfig else "" + + # Manual credentials should inherit the active provider's configured + # endpoint. Some providers expose multiple compatible hosts for the same + # API-key shape (for example Xiaomi MiMo standard vs Token Plan hosts); if + # `hermes auth add xiaomi` stores the registry default, pool resolution can + # later send a valid Token Plan key to the wrong endpoint. + try: + from hermes_cli.config import load_config + + config = load_config() + model_cfg = config.get("model") if isinstance(config, dict) else None + if isinstance(model_cfg, dict): + configured_provider = _normalize_provider(str(model_cfg.get("provider") or "")) + configured_base_url = str(model_cfg.get("base_url") or "").strip().rstrip("/") + if configured_provider == provider and configured_base_url: + return configured_base_url + except Exception: + pass + + return registry_base_url def _oauth_default_label(provider: str, count: int) -> str: diff --git a/plugins/model-providers/xiaomi/__init__.py b/plugins/model-providers/xiaomi/__init__.py index 8cd378d76097..08d30fd2c38c 100644 --- a/plugins/model-providers/xiaomi/__init__.py +++ b/plugins/model-providers/xiaomi/__init__.py @@ -9,8 +9,12 @@ xiaomi = ProviderProfile( env_vars=("XIAOMI_API_KEY",), base_url="https://api.xiaomimimo.com/v1", supports_health_check=False, # /v1/models returns 401 even with valid key - supports_vision=True, # mimo-v2-omni is vision-capable + supports_vision=True, # mimo-v2-omni and mimo-v2.5 are vision-capable supports_vision_tool_messages=False, # rejects list-type tool content (400 "text is not set") + fallback_models=( + "mimo-v2.5-pro", + "mimo-v2.5", + ), ) register_provider(xiaomi) diff --git a/run_agent.py b/run_agent.py index 2bf27d575101..df43e0b1bbc1 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3962,9 +3962,42 @@ class AIAgent: if merged: self._client_kwargs["default_headers"] = merged + def _pool_entry_swap_base_url(self, entry) -> Any: + """Resolve the base URL to adopt when swapping to a pool credential. + + Env-seeded pool entries store the provider registry default endpoint, + while runtime resolution can layer a user-configured ``model.base_url`` + override on top. If rotation blindly adopts the entry's default URL, + Xiaomi Token Plan sessions get re-pointed from their configured + ``token-plan-*`` host back to the standard API host after a 401/429. + + Keep the current URL when the entry carries only the provider default; + entries with a real per-credential endpoint still win. + """ + runtime_base = getattr(entry, "runtime_base_url", None) or getattr(entry, "base_url", None) + if not runtime_base: + return self.base_url + + current_base = self.base_url if isinstance(self.base_url, str) else "" + if isinstance(runtime_base, str) and current_base: + try: + from hermes_cli.auth import PROVIDER_REGISTRY + pconfig = PROVIDER_REGISTRY.get((getattr(self, "provider", "") or "").strip().lower()) + except Exception: + pconfig = None + default_url = (getattr(pconfig, "inference_base_url", "") or "").rstrip("/") + if ( + default_url + and runtime_base.rstrip("/") == default_url + and current_base.rstrip("/") != default_url + ): + return self.base_url + + return runtime_base + def _swap_credential(self, entry) -> None: runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "") - runtime_base = getattr(entry, "runtime_base_url", None) or getattr(entry, "base_url", None) or self.base_url + runtime_base = self._pool_entry_swap_base_url(entry) if self.api_mode == "anthropic_messages": from agent.anthropic_adapter import build_anthropic_client, _is_oauth_token diff --git a/scripts/release.py b/scripts/release.py index a7461b2179b7..5ecbc823c60c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -53,6 +53,7 @@ AUTHOR_MAP = { "peterhao@Peters-MacBook-Air.local": "pinguarmy", "adalsteinnhelgason@Aalsteinns-MacBook-Pro-3.local": "AIalliAI", "adalsteinnhelgason@users.noreply.github.com": "AIalliAI", + "285906080+AIalliAI@users.noreply.github.com": "AIalliAI", "zhang.hz6666@gmail.com": "HaozheZhang6", "barronlroth@gmail.com": "barronlroth", "ondrej.drapalik@gmail.com": "OndrejDrapalik", @@ -292,6 +293,8 @@ AUTHOR_MAP = { "127238744+teknium1@users.noreply.github.com": "teknium1", "tolle.lege+github@gmail.com": "InB4DevOps", "73686890+InB4DevOps@users.noreply.github.com": "InB4DevOps", + "262052366+jimdawdy-hub@users.noreply.github.com": "jimdawdy-hub", + "25972362+mlaihk@users.noreply.github.com": "mlaihk", "147827411+EloquentBrush@users.noreply.github.com": "AhmetArif0", "97489706+purzbeats@users.noreply.github.com": "purzbeats", "hugosequier@gmail.com": "Hugo-SEQUIER", diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 1723c11e32c0..c0003310b93b 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -62,6 +62,73 @@ def test_auth_add_api_key_persists_manual_entry(tmp_path, monkeypatch): assert entry["access_token"] == "sk-or-manual" +def test_auth_add_api_key_uses_config_base_url_for_active_provider(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("XIAOMI_API_KEY", raising=False) + monkeypatch.delenv("XIAOMI_BASE_URL", raising=False) + _write_auth_store(tmp_path, {"version": 1, "providers": {}}) + token_plan_url = "https://token-plan-ams.xiaomimimo.com/v1" + (hermes_home / "config.yaml").write_text( + yaml.safe_dump( + { + "model": { + "provider": "xiaomi", + "default": "mimo-v2.5-pro", + "base_url": token_plan_url, + } + } + ) + ) + + from hermes_cli.auth_commands import auth_add_command + + class _Args: + provider = "xiaomi" + auth_type = "api-key" + api_key = "sk-xiaomi-token-plan" + label = "token-plan" + + auth_add_command(_Args()) + + payload = json.loads((hermes_home / "auth.json").read_text()) + entries = payload["credential_pool"]["xiaomi"] + entry = next(item for item in entries if item["source"] == "manual") + assert entry["base_url"] == token_plan_url + + +def test_auth_add_api_key_does_not_inherit_other_provider_base_url(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + _write_auth_store(tmp_path, {"version": 1, "providers": {}}) + (hermes_home / "config.yaml").write_text( + yaml.safe_dump( + { + "model": { + "provider": "xiaomi", + "default": "mimo-v2.5-pro", + "base_url": "https://token-plan-ams.xiaomimimo.com/v1", + } + } + ) + ) + + from hermes_cli.auth_commands import auth_add_command + + class _Args: + provider = "anthropic" + auth_type = "api-key" + api_key = "sk-ant-manual" + label = "anthropic" + + auth_add_command(_Args()) + + payload = json.loads((hermes_home / "auth.json").read_text()) + entries = payload["credential_pool"]["anthropic"] + entry = next(item for item in entries if item["source"] == "manual") + assert entry["base_url"] != "https://token-plan-ams.xiaomimimo.com/v1" + + def test_auth_add_anthropic_oauth_persists_pool_entry(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) diff --git a/tests/hermes_cli/test_xiaomi_provider.py b/tests/hermes_cli/test_xiaomi_provider.py index 4a5a7724ad04..dc0256cc0363 100644 --- a/tests/hermes_cli/test_xiaomi_provider.py +++ b/tests/hermes_cli/test_xiaomi_provider.py @@ -145,6 +145,14 @@ class TestXiaomiModelCatalog: assert "xiaomi" in _PROVIDER_MODELS assert len(_PROVIDER_MODELS["xiaomi"]) >= 1 + def test_provider_profile_fallback_models_include_current_mimo(self): + from providers import get_provider_profile + + profile = get_provider_profile("xiaomi") + assert profile is not None + assert "mimo-v2.5-pro" in profile.fallback_models + assert "mimo-v2.5" in profile.fallback_models + def test_list_agentic_models_mock(self, monkeypatch): """When models.dev returns Xiaomi data, list_agentic_models should return models.""" from agent import models_dev as md diff --git a/tests/run_agent/test_44070_credential_swap_base_url.py b/tests/run_agent/test_44070_credential_swap_base_url.py new file mode 100644 index 000000000000..f50d6fad18ac --- /dev/null +++ b/tests/run_agent/test_44070_credential_swap_base_url.py @@ -0,0 +1,131 @@ +"""Credential-pool base_url guards for Xiaomi MiMo Token Plan sessions. + +A Xiaomi session can run on a configured Token Plan base URL while env/manual +pool entries still carry the provider registry default. Credential rotation +must not re-pin the live agent to that default endpoint. +""" + +from unittest.mock import MagicMock +from typing import Any + +from run_agent import AIAgent + + +XIAOMI_DEFAULT = "https://api.xiaomimimo.com/v1" +TOKEN_PLAN = "https://token-plan-cn.xiaomimimo.com/v1" + + +def _bare_agent(provider="xiaomi", base_url=TOKEN_PLAN, api_mode="chat_completions") -> Any: + agent: Any = AIAgent.__new__(AIAgent) + agent.provider = provider + agent.model = "mimo-v2.5-pro" + agent.base_url = base_url + agent.api_mode = api_mode + agent.api_key = "plan-key" + agent._client_kwargs = {"api_key": "plan-key", "base_url": base_url} + return agent + + +def _entry(base_url: str | None = XIAOMI_DEFAULT, key="rotated-key"): + entry = MagicMock() + entry.runtime_api_key = key + entry.access_token = key + entry.runtime_base_url = base_url + entry.base_url = base_url + return entry + + +class TestPoolEntrySwapBaseUrl: + def test_registry_default_entry_keeps_configured_override(self): + agent = _bare_agent() + + assert agent._pool_entry_swap_base_url(_entry(XIAOMI_DEFAULT)) == TOKEN_PLAN + + def test_per_credential_endpoint_still_wins(self): + agent = _bare_agent() + regional = "https://token-plan-sgp.xiaomimimo.com/v1" + + assert agent._pool_entry_swap_base_url(_entry(regional)) == regional + + def test_entry_without_url_keeps_current(self): + agent = _bare_agent() + entry = _entry(None) + entry.runtime_base_url = None + entry.base_url = None + + assert agent._pool_entry_swap_base_url(entry) == TOKEN_PLAN + + def test_agent_on_default_url_adopts_entry_url(self): + agent = _bare_agent(base_url=XIAOMI_DEFAULT) + + assert agent._pool_entry_swap_base_url(_entry(XIAOMI_DEFAULT)) == XIAOMI_DEFAULT + + def test_unknown_provider_adopts_entry_url(self): + agent = _bare_agent(provider="custom") + + assert agent._pool_entry_swap_base_url(_entry(XIAOMI_DEFAULT)) == XIAOMI_DEFAULT + + def test_swap_credential_preserves_override(self): + agent = _bare_agent() + agent._apply_client_headers_for_base_url = MagicMock() + agent._replace_primary_openai_client = MagicMock(return_value=True) + + agent._swap_credential(_entry(XIAOMI_DEFAULT, key="fresh-key")) + + assert agent.base_url == TOKEN_PLAN + assert agent._client_kwargs["base_url"] == TOKEN_PLAN + assert agent.api_key == "fresh-key" + assert agent._client_kwargs["api_key"] == "fresh-key" + agent._replace_primary_openai_client.assert_called_once() + + +class TestSwitchModelDetachesForeignPool: + def _switch_ready_agent(self, pool): + agent = _bare_agent() + agent._credential_pool = pool + agent._config_context_length = None + agent._fallback_activated = True + agent._fallback_index = 3 + agent._fallback_chain = [] + agent._fallback_model = None + agent._cached_system_prompt = "cached" + agent._client_kwargs = {"api_key": "plan-key", "base_url": TOKEN_PLAN} + agent.context_compressor = None + agent._use_prompt_caching = False + agent._use_native_cache_layout = False + agent._anthropic_prompt_cache_policy = MagicMock(return_value=(False, False)) + agent._ensure_lmstudio_runtime_loaded = MagicMock() + agent._create_openai_client = MagicMock(return_value=MagicMock()) + agent._transport_cache = {} + return agent + + def test_cross_provider_switch_detaches_pool(self): + pool = MagicMock() + pool.provider = "xiaomi" + agent = self._switch_ready_agent(pool) + + agent.switch_model( + new_model="deepseek-chat", + new_provider="deepseek", + api_key="ds-key", + base_url="https://api.deepseek.com/v1", + api_mode="chat_completions", + ) + + assert agent._credential_pool is None + assert agent.base_url == "https://api.deepseek.com/v1" + + def test_same_provider_switch_keeps_pool(self): + pool = MagicMock() + pool.provider = "xiaomi" + agent = self._switch_ready_agent(pool) + + agent.switch_model( + new_model="mimo-v2.5", + new_provider="xiaomi", + api_key="plan-key", + base_url=TOKEN_PLAN, + api_mode="chat_completions", + ) + + assert agent._credential_pool is pool