From 69bf2878bcd727a951e64677499ab4381f169c3f Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 26 Apr 2026 18:47:38 -0700 Subject: [PATCH] v0.50.224: legacy @provider session models, Docker Hindsight dependency (#1131) * Fix legacy at-provider session models * Fix Hindsight dependency in Docker WebUI venv --------- Co-authored-by: Frank Song --- CHANGELOG.md | 10 ++ api/routes.py | 69 +++++++++++ docker_init.bash | 15 +++ ...st_issue926_hindsight_docker_dependency.py | 32 +++++ tests/test_provider_mismatch.py | 117 ++++++++++++++++++ 5 files changed, 243 insertions(+) create mode 100644 tests/test_issue926_hindsight_docker_dependency.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e83641de..2a31e74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ ## [Unreleased] ### Fixed +- **Legacy `@provider:model` session models** — persisted sessions with an + old explicit provider hint (for example `@copilot:gpt-5.5`) now pass through + the same stale-model compatibility recovery as slash-prefixed session models, + so they can continue after the active provider changes. (`api/routes.py`) +- **Docker Hindsight memory provider dependency** — Docker startup now ensures + `hindsight-client` is installed in the WebUI container venv, even on fast + restarts where `/app/venv/.deps_installed` already exists. This lets + two-container WebUI deployments import Hermes Agent's Hindsight memory + provider without a manual container-side install. (`docker_init.bash`, + `tests/test_issue926_hindsight_docker_dependency.py`) Closes #926. ## v0.50.223 — 2026-04-26 diff --git a/api/routes.py b/api/routes.py index f7389af8..b61d2334 100644 --- a/api/routes.py +++ b/api/routes.py @@ -216,6 +216,44 @@ def _normalize_provider_id(value: str | None) -> str: return "" +def _catalog_provider_id_sets(catalog: dict) -> tuple[set[str], set[str]]: + raw_provider_ids: set[str] = set() + normalized_provider_ids: set[str] = set() + for group in catalog.get("groups") or []: + raw = str(group.get("provider_id") or "").strip().lower() + if not raw: + continue + raw_provider_ids.add(raw) + normalized = _normalize_provider_id(raw) + if normalized: + normalized_provider_ids.add(normalized) + return raw_provider_ids, normalized_provider_ids + + +def _catalog_has_provider( + provider_raw: str, + provider_normalized: str, + raw_provider_ids: set[str], + normalized_provider_ids: set[str], +) -> bool: + return ( + provider_raw in raw_provider_ids + or (provider_normalized and provider_normalized in raw_provider_ids) + or (provider_normalized and provider_normalized in normalized_provider_ids) + ) + + +def _model_matches_active_provider_family( + model: str, + active_provider: str, +) -> bool: + model_lower = model.lower() + for bare_prefix in ("gpt", "claude", "gemini"): + if model_lower.startswith(bare_prefix): + return _normalize_provider_id(bare_prefix) == active_provider + return False + + def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]: """Return (effective_model, was_normalized) for persisted session models. @@ -241,6 +279,37 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]: if not active_provider and not raw_active_provider: return model, False + if model.startswith("@") and ":" in model: + provider_hint, bare_model = model[1:].split(":", 1) + provider_raw = provider_hint.strip().lower() + provider_normalized = _normalize_provider_id(provider_raw) + bare_model = bare_model.strip() + if not provider_raw or not bare_model: + return model, False + + raw_provider_ids, normalized_provider_ids = _catalog_provider_id_sets(catalog) + hint_matches_active = ( + provider_raw == raw_active_provider + or provider_raw == active_provider + or (provider_normalized and provider_normalized == active_provider) + ) + if hint_matches_active: + return bare_model, True + + if _catalog_has_provider( + provider_raw, + provider_normalized, + raw_provider_ids, + normalized_provider_ids, + ): + return model, False + + if _model_matches_active_provider_family(bare_model, active_provider): + return bare_model, True + if default_model: + return default_model, True + return model, False + slash = model.find("/") if slash < 0: model_lower = model.lower() diff --git a/docker_init.bash b/docker_init.bash index 933dde9f..88f21456 100644 --- a/docker_init.bash +++ b/docker_init.bash @@ -285,6 +285,19 @@ echo "";echo "== Activating hermes webui's virtual environment" source /app/venv/bin/activate || error_exit "Failed to activate hermeswebui virtual environment" test -x /app/venv/bin/python3 +ensure_hindsight_client_docker_dependency() { + # Keep this outside the .deps_installed fast-restart guard so existing + # two-container Docker venvs self-heal after this dependency was added. + _hindsight_client_requirement="hindsight-client>=0.4.22" + echo ""; echo "== Checking Hindsight memory provider dependency" + if uv pip show hindsight-client >/dev/null 2>&1; then + echo "-- hindsight-client already installed" + else + echo "-- Installing ${_hindsight_client_requirement} for Hindsight memory provider support" + uv pip install "${_hindsight_client_requirement}" --trusted-host pypi.org --trusted-host files.pythonhosted.org || error_exit "Failed to install hindsight-client" + fi +} + if [ -f /app/venv/.deps_installed ]; then echo ""; echo "== Dependencies already installed — skipping (fast restart)" else @@ -323,6 +336,8 @@ else touch /app/venv/.deps_installed fi +ensure_hindsight_client_docker_dependency + echo ""; echo "== Running hermes-webui" cd /app; python server.py || error_exit "hermes-webui failed or exited with an error" diff --git a/tests/test_issue926_hindsight_docker_dependency.py b/tests/test_issue926_hindsight_docker_dependency.py new file mode 100644 index 00000000..b9e5e68f --- /dev/null +++ b/tests/test_issue926_hindsight_docker_dependency.py @@ -0,0 +1,32 @@ +"""Regression tests for #926 Hindsight dependency in Docker WebUI venv.""" +import pathlib + + +REPO_ROOT = pathlib.Path(__file__).parent.parent +INIT_SH = (REPO_ROOT / "docker_init.bash").read_text(encoding="utf-8") +REQUIREMENTS_TXT = (REPO_ROOT / "requirements.txt").read_text(encoding="utf-8") + + +def test_926_docker_init_installs_hindsight_distribution(): + """Docker init must install the PyPI distribution named hindsight-client.""" + assert "uv pip show hindsight-client" in INIT_SH + assert '"hindsight-client>=0.4.22"' in INIT_SH + assert 'uv pip install "${_hindsight_client_requirement}"' in INIT_SH + + +def test_926_hindsight_install_runs_after_fast_restart_guard(): + """Existing Docker venvs with .deps_installed must still get hindsight-client.""" + deps_guard_pos = INIT_SH.find("if [ -f /app/venv/.deps_installed ]; then") + assert deps_guard_pos != -1, ".deps_installed fast-restart guard not found" + + expected_sequence = "\nfi\n\nensure_hindsight_client_docker_dependency\n" + call_after_guard_pos = INIT_SH.find(expected_sequence, deps_guard_pos) + assert call_after_guard_pos != -1, ( + "hindsight-client install check must run outside the .deps_installed guard " + "so old Docker venvs self-heal on fast restart" + ) + + +def test_926_hindsight_dependency_stays_docker_specific(): + """Local non-Docker bootstrap should not install optional memory clients.""" + assert "hindsight-client" not in REQUIREMENTS_TXT diff --git a/tests/test_provider_mismatch.py b/tests/test_provider_mismatch.py index eb989785..cb8bee6e 100644 --- a/tests/test_provider_mismatch.py +++ b/tests/test_provider_mismatch.py @@ -336,6 +336,123 @@ def test_prefixed_google_session_model_normalizes_to_active_provider_default(mon assert effective == "gpt-5.4-mini" +def test_legacy_at_provider_session_model_normalizes_when_provider_hidden(monkeypatch): + """Old @provider:model session values must not bypass stale-model recovery.""" + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_available_models", + lambda: { + "active_provider": "openai-codex", + "default_model": "gpt-5.5", + "groups": [ + { + "provider": "OpenAI Codex", + "provider_id": "openai-codex", + "models": [{"id": "gpt-5.5", "label": "GPT-5.5"}], + }, + ], + }, + ) + + effective, changed = routes._resolve_compatible_session_model( + "@copilot:gpt-5.5" + ) + + assert changed is True + assert effective == "gpt-5.5" + + +def test_active_at_provider_session_model_strips_redundant_hint(monkeypatch): + """@active-provider:model is an old persisted form; use the bare model now.""" + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_available_models", + lambda: { + "active_provider": "openai-codex", + "default_model": "gpt-5.5", + "groups": [ + { + "provider": "OpenAI Codex", + "provider_id": "openai-codex", + "models": [{"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"}], + }, + ], + }, + ) + + effective, changed = routes._resolve_compatible_session_model( + "@openai-codex:gpt-5.4-mini" + ) + + assert changed is True + assert effective == "gpt-5.4-mini" + + +def test_routable_non_active_at_provider_session_model_is_preserved(monkeypatch): + """Visible cross-provider dropdown selections must keep their provider hint.""" + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_available_models", + lambda: { + "active_provider": "openai-codex", + "default_model": "gpt-5.5", + "groups": [ + { + "provider": "OpenAI Codex", + "provider_id": "openai-codex", + "models": [{"id": "gpt-5.5", "label": "GPT-5.5"}], + }, + { + "provider": "GitHub Copilot", + "provider_id": "copilot", + "models": [{"id": "@copilot:gpt-5.4", "label": "GPT-5.4"}], + }, + ], + }, + ) + + effective, changed = routes._resolve_compatible_session_model( + "@copilot:gpt-5.4" + ) + + assert changed is False + assert effective == "@copilot:gpt-5.4" + + +def test_stale_at_provider_model_falls_back_when_family_mismatches(monkeypatch): + """Unroutable @provider:model should not invent a bare model for another family.""" + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_available_models", + lambda: { + "active_provider": "openai-codex", + "default_model": "gpt-5.5", + "groups": [ + { + "provider": "OpenAI Codex", + "provider_id": "openai-codex", + "models": [{"id": "gpt-5.5", "label": "GPT-5.5"}], + }, + ], + }, + ) + + effective, changed = routes._resolve_compatible_session_model( + "@copilot:claude-opus-4.6" + ) + + assert changed is True + assert effective == "gpt-5.5" + + def test_google_active_provider_keeps_valid_gemini_session_model(monkeypatch): """A Google-configured session must keep its Gemini model.""" import api.routes as routes