Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 11 additions & 0 deletions plugins/memory/honcho/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
106 changes: 106 additions & 0 deletions tests/honcho_plugin/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
)
Loading