import sys import types from unittest.mock import patch # ── Mocking context-length/metadata and setup requirements for routes testing ── def fake_get_model_context_length(model, base_url="", **kwargs): return 1048576 # Ensure an 'agent' module exists in sys.modules to prevent ModuleNotFoundError # when api.routes is imported on a CI runner that has ONLY the WebUI repo (no # hermes-agent package). CRITICAL: only install the fake when the REAL agent # package is not importable. Unconditionally doing `sys.modules["agent"] = fake` # with `fake.__path__ = []` clobbers the genuine, importable agent package for # the WHOLE process (this runs at collection time and is never restored) — a # later `from agent. import ...` in the full suite (e.g. hermes_state's # `from agent.memory_manager import sanitize_context`) then fails with # ModuleNotFoundError. Guarding on find_spec keeps the real package intact # locally while preserving the CI import path. Nothing in this module asserts # against the fake metadata, so the shim is purely an import guard. def _real_agent_metadata_importable() -> bool: """True only if the genuine agent.model_metadata can actually be imported. ``importlib.util.find_spec`` RAISES ``ValueError: agent.__spec__ is None`` when a spec-less partial ``agent`` module is already in ``sys.modules`` (which happens during CI collection), so it can't be probed bare. Attempt the real import defensively instead: ANY failure means the genuine package isn't usable here, so the shim should be installed. """ try: import agent.model_metadata as _amm # noqa: F401 return getattr(_amm, "get_model_context_length", None) is not None except Exception: return False if not _real_agent_metadata_importable(): fake_agent = sys.modules.get("agent") or types.ModuleType("agent") if not hasattr(fake_agent, "__path__"): fake_agent.__path__ = [] metadata = types.ModuleType("agent.model_metadata") metadata.get_model_context_length = fake_get_model_context_length fake_agent.model_metadata = metadata # type: ignore[attr-defined] sys.modules["agent"] = fake_agent sys.modules["agent.model_metadata"] = metadata from api.routes import _normalize_provider_id, _resolve_compatible_session_model_state def test_normalize_provider_id_custom_prefix_collision(): """Verify that _normalize_provider_id does NOT mis-normalize custom CLI/proxy prefixes. 'gemini_cli' starts with 'gemini', but because it is a custom provider prefix with an underscore suffix, it should not collapse into the first-party 'google' family. It must return '' (unknown) so that it is passed through untouched. """ # Colliding custom prefixes (must return "") assert _normalize_provider_id("gemini_cli") == "" assert _normalize_provider_id("gemini-cli") == "" assert _normalize_provider_id("gpt_proxy") == "" assert _normalize_provider_id("claude_gateway") == "" assert _normalize_provider_id("openai_compat") == "" # Supported first-party exact match and aliases (must be normalized correctly) assert _normalize_provider_id("gemini") == "google" assert _normalize_provider_id("google-gemini") == "google" assert _normalize_provider_id("openai-codex") == "openai" assert _normalize_provider_id("claude-code") == "anthropic" assert _normalize_provider_id("custom:newapi") == "custom" def test_resolve_session_model_state_custom_prefix_survives(): """Verify that _resolve_compatible_session_model_state preserves custom prefix model strings. We pass model_provider=None and profile_provider="custom:newapi" to force the state resolver down the 'slow path' (family repair checks) where our _normalize_provider_id() bug actually resides, verifying it survives with changed=False. """ model_id = "gemini_cli/gemini-3-flash-preview" model_provider = None profile_provider = "custom:newapi" profile_default = "x-ai/grok-composer" with patch("api.routes.get_available_models") as mock_gam: # Stub catalog return directly (mock_gam.return_value) mock_gam.return_value = { "active_provider": "custom:newapi", "default_model": "x-ai/grok-composer", "groups": [ { "provider": "custom:newapi", "provider_id": "custom:newapi", "models": [{"id": "gemini_cli/gemini-3-flash-preview", "label": "Gemini 3 Flash"}] } ] } resolved_model, resolved_provider, changed = _resolve_compatible_session_model_state( model_id, model_provider, profile_provider=profile_provider, profile_default_model=profile_default, prefer_cached_catalog=True, ) assert resolved_model == "gemini_cli/gemini-3-flash-preview" assert resolved_provider == "custom:newapi" assert changed is False def test_hyphenated_first_party_provider_ids_still_normalize(): """Registered first-party provider IDs that use a hyphen separator (not ':' or '/') must still normalize to their family via the explicit alias map — the ':/'-only token boundary alone would drop them to '' and silently repair the session model to the profile default (the #4278 regression class, caught on the openai-api provider id). """ # openai-api is a real registered provider id (_PROVIDER_DISPLAY "OpenAI API"). assert _normalize_provider_id("openai-api") == "openai" # The other hyphenated first-party ids already covered, kept here as a class guard. assert _normalize_provider_id("openai-codex") == "openai" assert _normalize_provider_id("google-gemini") == "google" assert _normalize_provider_id("google-ai-studio") == "google" assert _normalize_provider_id("claude-code") == "anthropic" def test_openai_api_session_model_not_repaired_to_default(): """A bare non-default GPT model under an openai-api active/profile provider must be preserved, not silently repaired to the profile default (regression of the openai-api alias gap).""" with patch("api.routes.get_available_models") as mock_gam: mock_gam.return_value = { "active_provider": "openai-api", "default_model": "gpt-5.5", "groups": [ { "provider": "OpenAI API", "provider_id": "openai-api", "models": [ {"id": "gpt-5.5", "label": "GPT-5.5"}, {"id": "gpt-5.5-mini", "label": "GPT-5.5 Mini"}, ], } ], } resolved_model, resolved_provider, changed = _resolve_compatible_session_model_state( "gpt-5.5-mini", None, profile_provider="openai-api", profile_default_model="gpt-5.5", prefer_cached_catalog=True, ) assert resolved_model == "gpt-5.5-mini" assert changed is False def test_streaming_send_path_preserves_custom_namespace_models(): """The chat-SEND path (_apply_profile_provider_context_to_streaming_model in api/streaming.py) had its own ungated bare-prefix loop — a custom namespace like 'gemini_cli/...' starts with 'gemini' and was clobbered to the profile default on send (the #4278 collision on the send path, distinct from the session-load path). The loop is now gated to un-namespaced model ids.""" from api.streaming import _apply_profile_provider_context_to_streaming_model as apply_ctx # Custom-namespace models must survive the send path untouched. for model in ( "gemini_cli/gemini-3-flash-preview", "gpt_proxy/some-model", "claude-relay/m", ): resolved, ctx, changed = apply_ctx(model, None, "custom:newapi", "x-ai/grok-composer") assert resolved == model, model assert changed is False, model # A genuine bare cross-family model must STILL be repaired to the profile default. resolved, ctx, changed = apply_ctx("gpt-5.5", None, "google", "gemini-3-pro") assert resolved == "gemini-3-pro" assert changed is True # A slash-qualified cross-family model must STILL be repaired. resolved, ctx, changed = apply_ctx("gemini/gemini-3-pro", None, "openai-api", "gpt-5.5") assert resolved == "gpt-5.5" assert changed is True