From 29b6aa7211c84d7dbd5a9a5b61acd2fe20e5250c Mon Sep 17 00:00:00 2001 From: Yingliang Zhang Date: Sun, 5 Jul 2026 23:01:59 +0800 Subject: [PATCH 1/2] fix(desktop): show Hindsight local_embedded mode + LLM config in settings The desktop memory-provider schema excluded local_embedded from the mode select, causing the UI to coerce it to cloud (default) and display "API unset" for users with a valid local_embedded config. Saving from the UI would silently overwrite the mode to cloud, breaking the setup. Additionally, the schema only declared HINDSIGHT_API_KEY (cloud) but not HINDSIGHT_LLM_API_KEY (local embedded), so the LLM API key status was invisible. The llm_provider, llm_base_url, and llm_model fields were also missing, making local_embedded configs unviewable and uneditable from the desktop settings panel. Changes: - Add local_embedded to Hindsight mode select options - Add llm_api_key secret field (env_key=HINDSIGHT_LLM_API_KEY) - Add llm_provider, llm_base_url, llm_model text fields - Rename api_key label to "Cloud API key" for disambiguation - Update test_memory_providers.py: expand field set, add llm_api_key test - Update test_web_server.py: accept local_embedded mode, assert new fields Supersedes #57126 (which adds local_embedded mode only). This PR also adds the LLM config fields needed for local_embedded to be fully configurable from the desktop UI. Closes #7718 --- plugins/memory/hindsight/config_schema.py | 55 ++++++++++++++++++- .../memory/test_hindsight_config_schema.py | 34 +++++++++++- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/plugins/memory/hindsight/config_schema.py b/plugins/memory/hindsight/config_schema.py index a6e7ef9ee76e3..1ca7fb6bdd201 100644 --- a/plugins/memory/hindsight/config_schema.py +++ b/plugins/memory/hindsight/config_schema.py @@ -25,6 +25,11 @@ "Cloud", "Hindsight Cloud API (lightweight, just needs an API key)", ), + ProviderFieldOption( + "local_embedded", + "Local Embedded", + "Run a managed local Hindsight daemon with embedded PostgreSQL (needs LLM API key)", + ), ProviderFieldOption( "local_external", "Local External", @@ -35,11 +40,55 @@ ), ProviderField( key="api_key", - label="API key", + label="Cloud API key", kind=KIND_SECRET, env_key="HINDSIGHT_API_KEY", - description="Used to authenticate with the Hindsight API.", - placeholder="Enter Hindsight API key", + description="Hindsight Cloud API key (cloud mode only).", + placeholder="Enter Hindsight Cloud API key", + inline=True, + ), + ProviderField( + key="llm_api_key", + label="LLM API key", + kind=KIND_SECRET, + env_key="HINDSIGHT_LLM_API_KEY", + description="LLM API key for memory extraction (local embedded mode).", + placeholder="Enter LLM API key", + inline=True, + ), + ProviderField( + key="llm_provider", + label="LLM provider", + kind=KIND_SELECT, + default="openai", + description="LLM provider for local embedded mode.", + options=( + ProviderFieldOption("openai", "openai"), + ProviderFieldOption("anthropic", "anthropic"), + ProviderFieldOption("gemini", "gemini"), + ProviderFieldOption("groq", "groq"), + ProviderFieldOption("openrouter", "openrouter"), + ProviderFieldOption("minimax", "minimax"), + ProviderFieldOption("ollama", "ollama"), + ProviderFieldOption("lmstudio", "lmstudio"), + ProviderFieldOption("openai_compatible", "openai_compatible"), + ), + inline=True, + ), + ProviderField( + key="llm_base_url", + label="LLM base URL", + kind=KIND_TEXT, + description="OpenAI-compatible endpoint URL (for openai_compatible provider).", + env_fallbacks=("HINDSIGHT_API_LLM_BASE_URL",), + inline=True, + ), + ProviderField( + key="llm_model", + label="LLM model", + kind=KIND_TEXT, + default="gpt-4o-mini", + description="Model name for LLM calls (e.g. gpt-4o-mini, glm-5.2-heavy).", inline=True, ), ProviderField( diff --git a/tests/plugins/memory/test_hindsight_config_schema.py b/tests/plugins/memory/test_hindsight_config_schema.py index a521ee5d2d33a..22a2a4d096045 100644 --- a/tests/plugins/memory/test_hindsight_config_schema.py +++ b/tests/plugins/memory/test_hindsight_config_schema.py @@ -15,6 +15,10 @@ def test_hindsight_is_declared(): assert {field.key for field in provider.fields} == { "mode", "api_key", + "llm_api_key", + "llm_provider", + "llm_base_url", + "llm_model", "api_url", "bank_id", "recall_budget", @@ -36,9 +40,7 @@ def test_mode_gating_is_expressed_as_select_options(): mode = next(field for field in provider.fields if field.key == "mode") assert mode.kind == KIND_SELECT - assert mode.allowed_values() == {"cloud", "local_external"} - # local_embedded is intentionally unsupported on desktop. - assert "local_embedded" not in mode.allowed_values() + assert mode.allowed_values() == {"cloud", "local_embedded", "local_external"} def test_api_key_is_a_secret_bound_to_env(): @@ -49,3 +51,29 @@ def test_api_key_is_a_secret_bound_to_env(): assert api_key.kind == KIND_SECRET assert api_key.is_secret is True assert api_key.env_key == "HINDSIGHT_API_KEY" + + +def test_local_embedded_llm_fields_are_declared(): + provider = get_provider_config_schema("hindsight") + assert provider is not None + + fields = {field.key: field for field in provider.fields} + llm_api_key = fields["llm_api_key"] + assert llm_api_key.kind == KIND_SECRET + assert llm_api_key.is_secret is True + assert llm_api_key.env_key == "HINDSIGHT_LLM_API_KEY" + + llm_provider = fields["llm_provider"] + assert llm_provider.kind == KIND_SELECT + assert llm_provider.default == "openai" + assert llm_provider.allowed_values() == { + "openai", + "anthropic", + "gemini", + "groq", + "openrouter", + "minimax", + "ollama", + "lmstudio", + "openai_compatible", + } From 1b7d7ace2645938938a9de1240f9b3bbce8aaa42 Mon Sep 17 00:00:00 2001 From: Yingliang Zhang Date: Thu, 16 Jul 2026 12:17:03 +0800 Subject: [PATCH 2/2] fix(desktop): validate Hindsight LLM provider --- plugins/memory/hindsight/__init__.py | 2 +- tests/hermes_cli/test_web_server.py | 44 +++++++++++++++---- .../plugins/memory/test_hindsight_provider.py | 7 +++ 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 03632b98da710..dc0c6331646be 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -1172,7 +1172,7 @@ def get_config_schema(self): {"key": "api_url", "description": "Hindsight API URL", "default": _DEFAULT_LOCAL_URL, "when": {"mode": "local_external"}}, {"key": "api_key", "description": "API key (optional)", "secret": True, "env_var": "HINDSIGHT_API_KEY", "when": {"mode": "local_external"}}, # Local embedded mode - {"key": "llm_provider", "description": "LLM provider", "default": "openai", "choices": ["openai", "anthropic", "gemini", "groq", "openrouter", "minimax", "ollama", "lmstudio", "openai_compatible"], "when": {"mode": "local_embedded"}}, + {"key": "llm_provider", "description": "LLM provider", "default": "openai", "choices": list(_PROVIDER_DEFAULT_MODELS), "when": {"mode": "local_embedded"}}, {"key": "llm_base_url", "description": "Endpoint URL (e.g. http://192.168.1.10:8080/v1)", "default": "", "when": {"mode": "local_embedded", "llm_provider": "openai_compatible"}}, {"key": "llm_api_key", "description": "LLM API key (optional for openai_compatible)", "secret": True, "env_var": "HINDSIGHT_LLM_API_KEY", "when": {"mode": "local_embedded"}}, {"key": "llm_model", "description": "LLM model", "default": "gpt-4o-mini", "default_from": {"field": "llm_provider", "map": _PROVIDER_DEFAULT_MODELS}, "when": {"mode": "local_embedded"}}, diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index e8487de5fb695..03290495af0ca 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -771,7 +771,7 @@ def test_openviking_dashboard_rejects_blocked_endpoint_before_saving(self): - def test_declared_surface_put_writes_config_and_secret(self): + def test_declared_surface_put_round_trips_supported_llm_provider(self): from hermes_constants import get_hermes_home from hermes_cli.config import load_env @@ -779,22 +779,50 @@ def test_declared_surface_put_writes_config_and_secret(self): "/api/memory/providers/hindsight/config?surface=declared", json={ "values": { - "mode": "local_external", - "api_url": "http://localhost:8888", - "api_key": "hs-declared-key", + "mode": "local_embedded", + "llm_provider": "groq", + "llm_model": "openai/gpt-oss-120b", + "llm_api_key": "llm-declared-key", } }, ) assert resp.status_code == 200 assert resp.json() == {"ok": True} - assert load_env()["HINDSIGHT_API_KEY"] == "hs-declared-key" + assert load_env()["HINDSIGHT_LLM_API_KEY"] == "llm-declared-key" config_path = get_hermes_home() / "hindsight" / "config.json" provider_config = json.loads(config_path.read_text(encoding="utf-8")) - assert provider_config["mode"] == "local_external" - assert provider_config["api_url"] == "http://localhost:8888" - assert "api_key" not in provider_config + assert provider_config["mode"] == "local_embedded" + assert provider_config["llm_provider"] == "groq" + assert provider_config["llm_model"] == "openai/gpt-oss-120b" + assert "llm_api_key" not in provider_config + + get_resp = self.client.get( + "/api/memory/providers/hindsight/config?surface=declared" + ) + fields = self._provider_field_map(get_resp.json()) + assert fields["llm_provider"]["value"] == "groq" + assert fields["llm_api_key"]["value"] == "" + assert fields["llm_api_key"]["is_set"] is True + + def test_declared_surface_put_rejects_unsupported_llm_provider(self): + from hermes_constants import get_hermes_home + + resp = self.client.put( + "/api/memory/providers/hindsight/config?surface=declared", + json={ + "values": { + "mode": "local_embedded", + "llm_provider": "unsupported", + } + }, + ) + + assert resp.status_code == 400 + assert resp.json()["detail"] == "Invalid value for 'llm_provider'" + assert not (get_hermes_home() / "hindsight" / "config.json").exists() + diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index d7114e00394eb..c55beeaab9983 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -1247,6 +1247,13 @@ def test_schema_has_all_new_fields(self, provider): } assert expected_keys.issubset(keys), f"Missing: {expected_keys - keys}" + def test_llm_provider_choices_match_default_model_providers(self, provider): + fields = {field["key"]: field for field in provider.get_config_schema()} + + assert fields["llm_provider"]["choices"] == list( + fields["llm_model"]["default_from"]["map"] + ) + # --------------------------------------------------------------------------- # bank_id_template tests