From 48969321b53b8b9ce7de731e9975c5c7af77f743 Mon Sep 17 00:00:00 2001 From: Brandon Zarnitz Date: Wed, 6 May 2026 08:54:18 -0400 Subject: [PATCH] fix(plugins/honcho): strip version prefix from base_url for self-hosted instances (#20688) The Honcho SDK's route builders (e.g. routes.workspaces()) return paths that already include the API version prefix (e.g. "/v3/workspaces"). When a user configures a self-hosted instance with base_url = "http://localhost:38000/v3", the SDK concatenates them into "/v3/v3/workspaces", causing 404 errors on every peer and session initialization call. Strip any trailing version path segment (/vN) from base_url before passing it to the Honcho constructor for local instances (localhost, 127.0.0.1, ::1). Cloud base_urls are not local and remain unchanged. Adds four regression tests in tests/honcho_plugin/test_client.py covering: - /v3 suffix stripped for localhost URL - no-version URL left unchanged - cloud URL not modified - /v3/ with trailing slash also cleaned Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 9 +++ plugins/memory/honcho/client.py | 11 +++ tests/honcho_plugin/test_client.py | 106 +++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000000..11d29969b0ade --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### Changes + +- plugins/honcho: Strip version path suffix (e.g. `/v3`) from `base_url` before passing to Honcho SDK constructor for local/self-hosted instances to prevent double-prefixing (e.g. `/v3/v3/workspaces`) that causes 404 errors (#20688) diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 7210c6071e8ce..82de86ebf3a0a 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -742,6 +742,17 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: _host_block = (_raw.get("hosts") or {}).get(config.host, {}) _host_has_key = bool(_host_block.get("apiKey")) effective_api_key = config.api_key if _host_has_key else "local" + + # The Honcho SDK's route builders (e.g. routes.workspaces()) already + # include the version prefix (e.g. "/v3/workspaces"). When the user + # configures base_url as "http://localhost:38000/v3", concatenating the + # two produces "/v3/v3/workspaces" → 404 on every call. + # Strip any trailing API-version path segment from the base_url so the + # SDK can append its own versioned paths correctly. Cloud base_urls do + # not end with a version path and are left unchanged. + if resolved_base_url: + import re as _re + resolved_base_url = _re.sub(r"/v\d+/*$", "", resolved_base_url).rstrip("/") else: effective_api_key = config.api_key diff --git a/tests/honcho_plugin/test_client.py b/tests/honcho_plugin/test_client.py index 95180b2dce383..6b408d70b39d6 100644 --- a/tests/honcho_plugin/test_client.py +++ b/tests/honcho_plugin/test_client.py @@ -860,3 +860,109 @@ def test_depth_levels_invalid_values_default_to_low(self, tmp_path): })) config = HonchoClientConfig.from_global_config(config_path=config_file) assert config.dialectic_depth_levels == ["low", "high"] + + +class TestGetHonchoClientBaseUrlDoublePrefixFix: + """Regression tests for #20688 — Honcho SDK double-prefixing of /v3 for + self-hosted instances where base_url already contains a version path.""" + + def teardown_method(self): + reset_honcho_client() + + @pytest.mark.skipif( + not importlib.util.find_spec("honcho"), + reason="honcho SDK not installed" + ) + def test_local_base_url_with_v3_suffix_stripped(self): + """base_url 'http://localhost:38000/v3' must become 'http://localhost:38000' + before passing to the Honcho SDK to avoid double '/v3/v3' prefixing.""" + fake_honcho = MagicMock(name="Honcho") + cfg = HonchoClientConfig( + api_key=None, + base_url="http://localhost:38000/v3", + workspace_id="hermes", + environment="production", + ) + + with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \ + patch("hermes_cli.config.load_config", return_value={}): + get_honcho_client(cfg) + + mock_honcho.assert_called_once() + passed_base_url = mock_honcho.call_args.kwargs.get("base_url") + assert passed_base_url == "http://localhost:38000", ( + f"Expected 'http://localhost:38000', got {passed_base_url!r}" + ) + + @pytest.mark.skipif( + not importlib.util.find_spec("honcho"), + reason="honcho SDK not installed" + ) + def test_local_base_url_without_version_unchanged(self): + """base_url 'http://localhost:38000' (no version) must be passed unchanged.""" + fake_honcho = MagicMock(name="Honcho") + cfg = HonchoClientConfig( + api_key=None, + base_url="http://localhost:38000", + workspace_id="hermes", + environment="production", + ) + + with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \ + patch("hermes_cli.config.load_config", return_value={}): + get_honcho_client(cfg) + + mock_honcho.assert_called_once() + passed_base_url = mock_honcho.call_args.kwargs.get("base_url") + assert passed_base_url == "http://localhost:38000", ( + f"Expected 'http://localhost:38000', got {passed_base_url!r}" + ) + + @pytest.mark.skipif( + not importlib.util.find_spec("honcho"), + reason="honcho SDK not installed" + ) + def test_cloud_base_url_not_modified(self): + """A non-local base_url must never have its path stripped.""" + fake_honcho = MagicMock(name="Honcho") + cfg = HonchoClientConfig( + api_key="cloud-key", + base_url="https://api.honcho.dev/v3", + workspace_id="hermes", + environment="production", + ) + + with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \ + patch("hermes_cli.config.load_config", return_value={}): + get_honcho_client(cfg) + + mock_honcho.assert_called_once() + passed_base_url = mock_honcho.call_args.kwargs.get("base_url") + # Cloud URLs are not local — no stripping should occur + assert passed_base_url == "https://api.honcho.dev/v3", ( + f"Expected 'https://api.honcho.dev/v3', got {passed_base_url!r}" + ) + + @pytest.mark.skipif( + not importlib.util.find_spec("honcho"), + reason="honcho SDK not installed" + ) + def test_local_base_url_with_trailing_slash_stripped(self): + """base_url 'http://127.0.0.1:38000/v3/' must also be cleaned up.""" + fake_honcho = MagicMock(name="Honcho") + cfg = HonchoClientConfig( + api_key=None, + base_url="http://127.0.0.1:38000/v3/", + workspace_id="hermes", + environment="production", + ) + + with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \ + patch("hermes_cli.config.load_config", return_value={}): + get_honcho_client(cfg) + + mock_honcho.assert_called_once() + passed_base_url = mock_honcho.call_args.kwargs.get("base_url") + assert passed_base_url == "http://127.0.0.1:38000", ( + f"Expected 'http://127.0.0.1:38000', got {passed_base_url!r}" + )