mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-16 23:21:32 +08:00
Compare commits
1 Commits
fix/cli-wo
...
salvage/xi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a7794ebc4 |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
35
run_agent.py
35
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
131
tests/run_agent/test_44070_credential_swap_base_url.py
Normal file
131
tests/run_agent/test_44070_credential_swap_base_url.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user