test_xiaomi_provider.py
1 """Tests for Xiaomi MiMo provider support.""" 2 3 import os 4 5 import pytest 6 7 from hermes_cli.auth import ( 8 PROVIDER_REGISTRY, 9 resolve_provider, 10 get_api_key_provider_status, 11 resolve_api_key_provider_credentials, 12 AuthError, 13 ) 14 15 16 # ============================================================================= 17 # Provider Registry 18 # ============================================================================= 19 20 21 class TestXiaomiProviderRegistry: 22 """Verify Xiaomi is registered correctly in the PROVIDER_REGISTRY.""" 23 24 def test_registered(self): 25 assert "xiaomi" in PROVIDER_REGISTRY 26 27 def test_name(self): 28 assert PROVIDER_REGISTRY["xiaomi"].name == "Xiaomi MiMo" 29 30 def test_auth_type(self): 31 assert PROVIDER_REGISTRY["xiaomi"].auth_type == "api_key" 32 33 def test_inference_base_url(self): 34 assert PROVIDER_REGISTRY["xiaomi"].inference_base_url == "https://api.xiaomimimo.com/v1" 35 36 def test_api_key_env_vars(self): 37 assert PROVIDER_REGISTRY["xiaomi"].api_key_env_vars == ("XIAOMI_API_KEY",) 38 39 def test_base_url_env_var(self): 40 assert PROVIDER_REGISTRY["xiaomi"].base_url_env_var == "XIAOMI_BASE_URL" 41 42 43 # ============================================================================= 44 # Aliases 45 # ============================================================================= 46 47 48 class TestXiaomiAliases: 49 """All aliases should resolve to 'xiaomi'.""" 50 51 @pytest.mark.parametrize("alias", [ 52 "xiaomi", "mimo", "xiaomi-mimo", 53 ]) 54 def test_alias_resolves(self, alias, monkeypatch): 55 # Clear env to avoid auto-detection interfering 56 for key in ("XIAOMI_API_KEY",): 57 monkeypatch.delenv(key, raising=False) 58 monkeypatch.setenv("XIAOMI_API_KEY", "sk-test-key-12345678") 59 assert resolve_provider(alias) == "xiaomi" 60 61 def test_normalize_provider_models_py(self): 62 from hermes_cli.models import normalize_provider 63 assert normalize_provider("mimo") == "xiaomi" 64 assert normalize_provider("xiaomi-mimo") == "xiaomi" 65 66 def test_normalize_provider_providers_py(self): 67 from hermes_cli.providers import normalize_provider 68 assert normalize_provider("mimo") == "xiaomi" 69 assert normalize_provider("xiaomi-mimo") == "xiaomi" 70 71 72 # ============================================================================= 73 # Auto-detection 74 # ============================================================================= 75 76 77 class TestXiaomiAutoDetection: 78 """Setting XIAOMI_API_KEY should auto-detect the provider.""" 79 80 def test_auto_detect(self, monkeypatch): 81 # Clear all other provider env vars 82 for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", 83 "DEEPSEEK_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY", 84 "DASHSCOPE_API_KEY", "XAI_API_KEY", "KIMI_API_KEY", 85 "MINIMAX_API_KEY", "AI_GATEWAY_API_KEY", "KILOCODE_API_KEY", 86 "HF_TOKEN", "GLM_API_KEY", "COPILOT_GITHUB_TOKEN", 87 "GH_TOKEN", "GITHUB_TOKEN", "MINIMAX_CN_API_KEY", 88 "TOKENHUB_API_KEY", "ARCEEAI_API_KEY"): 89 monkeypatch.delenv(var, raising=False) 90 monkeypatch.setenv("XIAOMI_API_KEY", "sk-xiaomi-test-12345678") 91 provider = resolve_provider("auto") 92 assert provider == "xiaomi" 93 94 95 # ============================================================================= 96 # Credentials 97 # ============================================================================= 98 99 100 class TestXiaomiCredentials: 101 """Test credential resolution for the xiaomi provider.""" 102 103 def test_status_configured(self, monkeypatch): 104 monkeypatch.setenv("XIAOMI_API_KEY", "sk-test-12345678") 105 status = get_api_key_provider_status("xiaomi") 106 assert status["configured"] 107 108 def test_status_not_configured(self, monkeypatch): 109 monkeypatch.delenv("XIAOMI_API_KEY", raising=False) 110 status = get_api_key_provider_status("xiaomi") 111 assert not status["configured"] 112 113 def test_resolve_credentials(self, monkeypatch): 114 monkeypatch.setenv("XIAOMI_API_KEY", "sk-test-12345678") 115 monkeypatch.delenv("XIAOMI_BASE_URL", raising=False) 116 creds = resolve_api_key_provider_credentials("xiaomi") 117 assert creds["api_key"] == "sk-test-12345678" 118 assert creds["base_url"] == "https://api.xiaomimimo.com/v1" 119 120 def test_custom_base_url_override(self, monkeypatch): 121 monkeypatch.setenv("XIAOMI_API_KEY", "sk-test-12345678") 122 monkeypatch.setenv("XIAOMI_BASE_URL", "https://custom.xiaomi.example/v1") 123 creds = resolve_api_key_provider_credentials("xiaomi") 124 assert creds["base_url"] == "https://custom.xiaomi.example/v1" 125 126 127 # ============================================================================= 128 # Model catalog (dynamic — no static list) 129 # ============================================================================= 130 131 132 class TestXiaomiModelCatalog: 133 """Xiaomi uses dynamic model discovery via models.dev.""" 134 135 def test_models_dev_mapping(self): 136 from agent.models_dev import PROVIDER_TO_MODELS_DEV 137 assert PROVIDER_TO_MODELS_DEV["xiaomi"] == "xiaomi" 138 139 def test_static_model_list_fallback(self): 140 """Static _PROVIDER_MODELS fallback must exist for model picker. 141 142 We only assert the provider key is present — the specific model 143 names are data that changes with upstream releases and doesn't 144 belong in tests. 145 """ 146 from hermes_cli.models import _PROVIDER_MODELS 147 assert "xiaomi" in _PROVIDER_MODELS 148 assert len(_PROVIDER_MODELS["xiaomi"]) >= 1 149 150 def test_list_agentic_models_mock(self, monkeypatch): 151 """When models.dev returns Xiaomi data, list_agentic_models should return models.""" 152 from agent import models_dev as md 153 154 fake_data = { 155 "xiaomi": { 156 "name": "Xiaomi", 157 "api": "https://api.xiaomimimo.com/v1", 158 "env": ["XIAOMI_API_KEY"], 159 "models": { 160 "mimo-v2-pro": { 161 "limit": {"context": 1000000}, 162 "tool_call": True, 163 }, 164 "mimo-v2-omni": { 165 "limit": {"context": 256000}, 166 "tool_call": True, 167 }, 168 "mimo-v2-flash": { 169 "limit": {"context": 256000}, 170 "tool_call": True, 171 }, 172 }, 173 } 174 } 175 monkeypatch.setattr(md, "fetch_models_dev", lambda: fake_data) 176 177 result = md.list_agentic_models("xiaomi") 178 assert "mimo-v2-pro" in result 179 assert "mimo-v2-flash" in result 180 181 182 # ============================================================================= 183 # Normalization 184 # ============================================================================= 185 186 187 class TestXiaomiNormalization: 188 """Model name normalization — Xiaomi is a direct provider.""" 189 190 def test_vendor_prefix_mapping(self): 191 from hermes_cli.model_normalize import _VENDOR_PREFIXES 192 assert _VENDOR_PREFIXES.get("mimo") == "xiaomi" 193 194 def test_matching_prefix_strip(self): 195 """xiaomi/mimo-v2-pro should normalize to mimo-v2-pro for direct API.""" 196 from hermes_cli.model_normalize import _MATCHING_PREFIX_STRIP_PROVIDERS 197 assert "xiaomi" in _MATCHING_PREFIX_STRIP_PROVIDERS 198 199 def test_lowercase_model_provider(self): 200 """Xiaomi must be in _LOWERCASE_MODEL_PROVIDERS.""" 201 from hermes_cli.model_normalize import _LOWERCASE_MODEL_PROVIDERS 202 assert "xiaomi" in _LOWERCASE_MODEL_PROVIDERS 203 204 def test_lowercase_subset_of_matching_prefix(self): 205 """_LOWERCASE_MODEL_PROVIDERS must be a subset of _MATCHING_PREFIX_STRIP_PROVIDERS. 206 207 Otherwise the .lower() code path is unreachable dead code — the 208 provider check at line 422 gates entry to the block. 209 """ 210 from hermes_cli.model_normalize import ( 211 _LOWERCASE_MODEL_PROVIDERS, 212 _MATCHING_PREFIX_STRIP_PROVIDERS, 213 ) 214 assert _LOWERCASE_MODEL_PROVIDERS.issubset(_MATCHING_PREFIX_STRIP_PROVIDERS), ( 215 f"_LOWERCASE_MODEL_PROVIDERS has entries not in _MATCHING_PREFIX_STRIP_PROVIDERS: " 216 f"{_LOWERCASE_MODEL_PROVIDERS - _MATCHING_PREFIX_STRIP_PROVIDERS}" 217 ) 218 219 def test_normalize_strips_provider_prefix(self): 220 from hermes_cli.model_normalize import normalize_model_for_provider 221 result = normalize_model_for_provider("xiaomi/mimo-v2-pro", "xiaomi") 222 assert result == "mimo-v2-pro" 223 224 def test_normalize_bare_name_unchanged(self): 225 from hermes_cli.model_normalize import normalize_model_for_provider 226 result = normalize_model_for_provider("mimo-v2-pro", "xiaomi") 227 assert result == "mimo-v2-pro" 228 229 @pytest.mark.parametrize("empty_input", ["", None, " "]) 230 def test_normalize_empty_and_none(self, empty_input): 231 """None, empty, and whitespace-only inputs return empty string.""" 232 from hermes_cli.model_normalize import normalize_model_for_provider 233 result = normalize_model_for_provider(empty_input, "xiaomi") 234 assert result == "" 235 236 @pytest.mark.parametrize("input_name,expected", [ 237 ("MiMo-V2.5-Pro", "mimo-v2.5-pro"), 238 ("MIMO-V2.5-PRO", "mimo-v2.5-pro"), 239 ("MiMo-v2.5-pro", "mimo-v2.5-pro"), 240 ("mimo-v2.5-pro", "mimo-v2.5-pro"), # already lowercase 241 ("MiMo-V2-Pro", "mimo-v2-pro"), 242 ("MiMo-V2-Omni", "mimo-v2-omni"), 243 ("MiMo-V2-Flash", "mimo-v2-flash"), 244 ("MiMo-V2.5", "mimo-v2.5"), 245 ]) 246 def test_normalize_lowercases_mixed_case(self, input_name, expected): 247 """Xiaomi's API requires lowercase model IDs — mixed case from docs must be lowered.""" 248 from hermes_cli.model_normalize import normalize_model_for_provider 249 result = normalize_model_for_provider(input_name, "xiaomi") 250 assert result == expected 251 252 @pytest.mark.parametrize("input_name,expected", [ 253 ("xiaomi/MiMo-V2.5-Pro", "mimo-v2.5-pro"), 254 ("xiaomi/MIMO-V2.5-PRO", "mimo-v2.5-pro"), 255 ("xiaomi/mimo-v2.5-pro", "mimo-v2.5-pro"), 256 ]) 257 def test_normalize_strips_prefix_and_lowercases(self, input_name, expected): 258 """Provider prefix stripping AND lowercasing must both work together.""" 259 from hermes_cli.model_normalize import normalize_model_for_provider 260 result = normalize_model_for_provider(input_name, "xiaomi") 261 assert result == expected 262 263 264 # ============================================================================= 265 # URL mapping 266 # ============================================================================= 267 268 269 class TestXiaomiURLMapping: 270 """Test URL → provider inference for Xiaomi endpoints.""" 271 272 def test_url_to_provider(self): 273 from agent.model_metadata import _URL_TO_PROVIDER 274 assert _URL_TO_PROVIDER.get("api.xiaomimimo.com") == "xiaomi" 275 276 def test_provider_prefixes(self): 277 from agent.model_metadata import _PROVIDER_PREFIXES 278 assert "xiaomi" in _PROVIDER_PREFIXES 279 assert "mimo" in _PROVIDER_PREFIXES 280 assert "xiaomi-mimo" in _PROVIDER_PREFIXES 281 282 def test_infer_from_url(self): 283 from agent.model_metadata import _infer_provider_from_url 284 assert _infer_provider_from_url("https://api.xiaomimimo.com/v1") == "xiaomi" 285 286 def test_infer_from_regional_urls(self): 287 """Regional token-plan endpoints should also resolve to xiaomi.""" 288 from agent.model_metadata import _infer_provider_from_url 289 assert _infer_provider_from_url("https://token-plan-ams.xiaomimimo.com/v1") == "xiaomi" 290 assert _infer_provider_from_url("https://token-plan-cn.xiaomimimo.com/v1") == "xiaomi" 291 assert _infer_provider_from_url("https://token-plan-sgp.xiaomimimo.com/v1") == "xiaomi" 292 293 294 # ============================================================================= 295 # providers.py 296 # ============================================================================= 297 298 299 class TestXiaomiProvidersModule: 300 """Test Xiaomi in the unified providers module.""" 301 302 def test_overlay_exists(self): 303 from hermes_cli.providers import HERMES_OVERLAYS 304 assert "xiaomi" in HERMES_OVERLAYS 305 overlay = HERMES_OVERLAYS["xiaomi"] 306 assert overlay.transport == "openai_chat" 307 assert overlay.base_url_env_var == "XIAOMI_BASE_URL" 308 assert not overlay.is_aggregator 309 310 def test_alias_resolves(self): 311 from hermes_cli.providers import normalize_provider 312 assert normalize_provider("mimo") == "xiaomi" 313 assert normalize_provider("xiaomi-mimo") == "xiaomi" 314 315 def test_label(self): 316 from hermes_cli.providers import get_label 317 assert get_label("xiaomi") == "Xiaomi MiMo" 318 319 def test_get_provider(self): 320 pdef = None 321 try: 322 from hermes_cli.providers import get_provider 323 pdef = get_provider("xiaomi") 324 except Exception: 325 pass 326 if pdef is not None: 327 assert pdef.id == "xiaomi" 328 assert pdef.transport == "openai_chat" 329 330 331 # ============================================================================= 332 # Auxiliary client 333 # ============================================================================= 334 335 336 class TestXiaomiAuxiliary: 337 """Xiaomi auxiliary routing: vision → omni, non-vision → user's main model, never flash.""" 338 339 def test_no_flash_in_aux_models(self): 340 """mimo-v2-flash must NEVER be used for automatic aux routing.""" 341 from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS 342 assert "xiaomi" not in _API_KEY_PROVIDER_AUX_MODELS 343 344 def test_vision_model_override(self): 345 """Xiaomi vision tasks should use mimo-v2.5 (multimodal), not the main model.""" 346 from agent.auxiliary_client import _PROVIDER_VISION_MODELS 347 assert "xiaomi" in _PROVIDER_VISION_MODELS 348 assert _PROVIDER_VISION_MODELS["xiaomi"] == "mimo-v2.5" 349 350 351 # ============================================================================= 352 # Agent init (no SyntaxError, correct api_mode) 353 # ============================================================================= 354 355 356 class TestXiaomiDoctor: 357 """Verify hermes doctor recognizes Xiaomi env vars.""" 358 359 def test_provider_env_hints(self): 360 from hermes_cli.doctor import _PROVIDER_ENV_HINTS 361 assert "XIAOMI_API_KEY" in _PROVIDER_ENV_HINTS 362 363 364 class TestXiaomiAgentInit: 365 """Verify the agent can be constructed with xiaomi provider without errors.""" 366 367 def test_no_syntax_errors(self): 368 """Importing run_agent with xiaomi should not raise.""" 369 import importlib 370 importlib.import_module("run_agent") 371 372 def test_api_mode_is_chat_completions(self): 373 from hermes_cli.providers import HERMES_OVERLAYS, TRANSPORT_TO_API_MODE 374 overlay = HERMES_OVERLAYS["xiaomi"] 375 api_mode = TRANSPORT_TO_API_MODE[overlay.transport] 376 assert api_mode == "chat_completions"