Convert in-place pkg.__path__=[] mutations on the REAL agent/hermes_cli packages to monkeypatch.setattr (auto-restored), and make test_issue1574's _activate_spawn_fake_agent snapshot+restore the HERMES_WEBUI_AGENT_DIR/PYTHONPATH/sys.path it mutates. Fixes: - test_v050259_sessiondb_fd_leak::test_session_db_close_is_idempotent - test_tls_support::test_tls_startup_failure_fallback_to_http
171 lines
7.7 KiB
Python
171 lines
7.7 KiB
Python
import importlib.util
|
|
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.<sub> 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.
|
|
if (
|
|
importlib.util.find_spec("agent") is None
|
|
or importlib.util.find_spec("agent.model_metadata") is None
|
|
):
|
|
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
|