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:
1 parent
fc0152b2fc
commit
69bf2878bc
5 files changed
+243
No files matched your search
@@ -3,6 +3,16 @@
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Fixed
|
### 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
|
## v0.50.223 — 2026-04-26
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,44 @@ def _normalize_provider_id(value: str | None) -> str:
|
|||||||
return ""
|
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]:
|
def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]:
|
||||||
"""Return (effective_model, was_normalized) for persisted session models.
|
"""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:
|
if not active_provider and not raw_active_provider:
|
||||||
return model, False
|
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("/")
|
slash = model.find("/")
|
||||||
if slash < 0:
|
if slash < 0:
|
||||||
model_lower = model.lower()
|
model_lower = model.lower()
|
||||||
|
|||||||
@@ -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"
|
source /app/venv/bin/activate || error_exit "Failed to activate hermeswebui virtual environment"
|
||||||
test -x /app/venv/bin/python3
|
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
|
if [ -f /app/venv/.deps_installed ]; then
|
||||||
echo ""; echo "== Dependencies already installed — skipping (fast restart)"
|
echo ""; echo "== Dependencies already installed — skipping (fast restart)"
|
||||||
else
|
else
|
||||||
@@ -323,6 +336,8 @@ else
|
|||||||
touch /app/venv/.deps_installed
|
touch /app/venv/.deps_installed
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
ensure_hindsight_client_docker_dependency
|
||||||
|
|
||||||
echo ""; echo "== Running hermes-webui"
|
echo ""; echo "== Running hermes-webui"
|
||||||
cd /app; python server.py || error_exit "hermes-webui failed or exited with an error"
|
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
|
||||||
@@ -336,6 +336,123 @@ def test_prefixed_google_session_model_normalizes_to_active_provider_default(mon
|
|||||||
assert effective == "gpt-5.4-mini"
|
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):
|
def test_google_active_provider_keeps_valid_gemini_session_model(monkeypatch):
|
||||||
"""A Google-configured session must keep its Gemini model."""
|
"""A Google-configured session must keep its Gemini model."""
|
||||||
import api.routes as routes
|
import api.routes as routes
|
||||||
|
|||||||
Reference in new issue
Block a user