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 <franksong2702@gmail.com>
This commit is contained in:
nesquena-hermesandFrank Song authored and GitHub committed 2026-04-26 18:47:38 -07:00
1 parent fc0152b2fc
commit 69bf2878bc
5 files changed
+243

No files matched your search

+10
View File
@@ -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
+69
View File
@@ -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()
+15
View File
@@ -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"
@@ -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
+117
View File
@@ -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