Compare commits

...

1 Commits

Author SHA1 Message Date
Teknium
1a7794ebc4 fix(xiaomi): preserve Token Plan base URLs during auth recovery
Salvages Xiaomi MiMo base-url routing fixes from #44099/#33648/#42699 while keeping base-url configuration in config.yaml rather than expanding env-var guidance.

Co-authored-by: AIalliAI <285906080+AIalliAI@users.noreply.github.com>

Co-authored-by: Jim Dawdy <262052366+jimdawdy-hub@users.noreply.github.com>

Co-authored-by: mlaihk <25972362+mlaihk@users.noreply.github.com>
2026-06-15 08:04:32 -07:00
8 changed files with 284 additions and 3 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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",

View File

@@ -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)

View File

@@ -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

View 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