fix(memory): read endpoint.baseUrl from Honcho config; accept HONCHO_URL
HonchoClientConfig.from_global_config() only consulted top-level baseUrl / base_url / HONCHO_BASE_URL in ~/.honcho/config.json. The Honcho SDK's native config format — and what Claude Desktop writes — nests the URL at endpoint.baseUrl. Users with that config format had their self-hosted Honcho container silently ignored: every honcho_* call routed to https://api.honcho.dev with a workspace_id that does not exist there, so tools returned empty data with no error anywhere. Resolution order in from_global_config(), highest first: 1. endpoint.baseUrl (SDK-native, what Claude Desktop writes) 2. baseUrl / base_url (root-level, existing behavior) 3. HONCHO_BASE_URL (existing env var) 4. HONCHO_URL (the SDK's own env var, honcho/client.py:234) HONCHO_URL is also read in from_env(). from_global_config() delegates to from_env() whenever the config file is missing or unreadable, so an env fallback wired into only one of the two would silently do nothing for users with no config file. A non-dict endpoint value falls through cleanly rather than raising. Existing users are unaffected — the new sources are consulted only when the existing ones resolve to None. The INFO log for the base_url-unset case now says so explicitly instead of printing only the host. The SDK resolves that case from its own ENVIRONMENTS map (honcho/client.py:36-39), which for environment= production means the public cloud; a self-hosted user whose config was not picked up otherwise sees a healthy-looking startup line. Closes #43800.
This commit is contained in:
parent
5118692c25
commit
ad588542ea
|
|
@ -489,7 +489,16 @@ class HonchoClientConfig:
|
|||
"""Create config from environment variables (fallback)."""
|
||||
resolved_host = host or resolve_active_host()
|
||||
api_key = get_secret("HONCHO_API_KEY")
|
||||
base_url = os.environ.get("HONCHO_BASE_URL", "").strip() or None
|
||||
# HONCHO_URL is the SDK's own env var (honcho.client resolves it when
|
||||
# no environment is passed); accept it here so the fallback path
|
||||
# behaves the same as from_global_config() when no config file exists.
|
||||
# Read straight from os.environ, matching HONCHO_BASE_URL: a base URL
|
||||
# is a deployment setting, not a profile-scoped credential.
|
||||
base_url = (
|
||||
os.environ.get("HONCHO_BASE_URL", "").strip()
|
||||
or os.environ.get("HONCHO_URL", "").strip()
|
||||
or None
|
||||
)
|
||||
timeout = _resolve_optional_float(os.environ.get("HONCHO_TIMEOUT"))
|
||||
_resolved_path = resolve_config_path()
|
||||
return cls(
|
||||
|
|
@ -555,10 +564,22 @@ class HonchoClientConfig:
|
|||
or raw.get("environment", "production")
|
||||
)
|
||||
|
||||
# The Honcho SDK's native config format — and what Claude Desktop
|
||||
# writes — nests the URL at endpoint.baseUrl. Read it first: a user
|
||||
# who has that block set almost certainly means it, and the flat
|
||||
# baseUrl / base_url keys below are the Hermes-specific spelling.
|
||||
endpoint_block = raw.get("endpoint")
|
||||
native_base_url = (
|
||||
endpoint_block.get("baseUrl")
|
||||
if isinstance(endpoint_block, dict)
|
||||
else None
|
||||
)
|
||||
base_url = (
|
||||
raw.get("baseUrl")
|
||||
native_base_url
|
||||
or raw.get("baseUrl")
|
||||
or raw.get("base_url")
|
||||
or os.environ.get("HONCHO_BASE_URL", "").strip()
|
||||
or os.environ.get("HONCHO_URL", "").strip()
|
||||
or None
|
||||
)
|
||||
# Host config wins over flat/global config and environment.
|
||||
|
|
@ -1243,7 +1264,16 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
|||
if resolved_base_url:
|
||||
logger.info("Initializing Honcho client (base_url: %s, workspace: %s)", resolved_base_url, config.workspace_id)
|
||||
else:
|
||||
logger.info("Initializing Honcho client (host: %s, workspace: %s)", config.host, config.workspace_id)
|
||||
# No base_url resolved, so the SDK falls back to its own
|
||||
# ENVIRONMENTS map (honcho.client: local -> http://localhost:8000,
|
||||
# production -> https://api.honcho.dev). Name the target at INFO:
|
||||
# a self-hosted user whose config wasn't picked up otherwise sees
|
||||
# a healthy-looking startup and silently talks to the public cloud.
|
||||
logger.info(
|
||||
"Initializing Honcho client (host: %s, workspace: %s, "
|
||||
"base_url unset — SDK will resolve from environment=%s)",
|
||||
config.host, config.workspace_id, config.environment,
|
||||
)
|
||||
|
||||
# Local Honcho instances don't require an API key, but the SDK
|
||||
# expects a non-empty string. Use a placeholder for local URLs.
|
||||
|
|
|
|||
|
|
@ -67,6 +67,29 @@ class TestFromEnv:
|
|||
assert config.enabled is True
|
||||
|
||||
|
||||
def test_honcho_url_env_var_is_honored(self):
|
||||
"""HONCHO_URL is the SDK's own env var; from_env() accepts it too."""
|
||||
with patch.dict(os.environ, {"HONCHO_URL": "http://localhost:8000"}, clear=False):
|
||||
os.environ.pop("HONCHO_API_KEY", None)
|
||||
os.environ.pop("HONCHO_BASE_URL", None)
|
||||
config = HonchoClientConfig.from_env()
|
||||
assert config.base_url == "http://localhost:8000"
|
||||
assert config.enabled is True
|
||||
|
||||
|
||||
def test_honcho_base_url_wins_over_honcho_url(self):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"HONCHO_BASE_URL": "http://localhost:8000",
|
||||
"HONCHO_URL": "http://localhost:9999",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
config = HonchoClientConfig.from_env()
|
||||
assert config.base_url == "http://localhost:8000"
|
||||
|
||||
|
||||
class TestFromGlobalConfig:
|
||||
def test_missing_config_falls_back_to_env(self, tmp_path):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
|
|
@ -78,6 +101,60 @@ class TestFromGlobalConfig:
|
|||
assert config.api_key is None
|
||||
|
||||
|
||||
def test_missing_config_still_reads_honcho_url(self, tmp_path):
|
||||
"""The env fallback path must honor HONCHO_URL, not just HONCHO_BASE_URL.
|
||||
|
||||
from_global_config() returns from_env() when the config file is
|
||||
absent, so a fallback that only from_global_config() understood
|
||||
would silently do nothing for users with no ~/.honcho/config.json.
|
||||
"""
|
||||
with patch.dict(os.environ, {"HONCHO_URL": "http://localhost:8000"}, clear=True):
|
||||
config = HonchoClientConfig.from_global_config(
|
||||
config_path=tmp_path / "nonexistent.json"
|
||||
)
|
||||
assert config.base_url == "http://localhost:8000"
|
||||
assert config.enabled is True
|
||||
|
||||
|
||||
def test_base_url_from_sdk_native_endpoint_block(self, tmp_path):
|
||||
"""endpoint.baseUrl is the SDK-native spelling Claude Desktop writes."""
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({
|
||||
"apiKey": "key",
|
||||
"endpoint": {"baseUrl": "http://localhost:8000"},
|
||||
}))
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||
assert config.base_url == "http://localhost:8000"
|
||||
|
||||
|
||||
def test_endpoint_base_url_wins_over_top_level_and_env(self, tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({
|
||||
"endpoint": {"baseUrl": "http://localhost:8000"},
|
||||
"baseUrl": "http://localhost:9001",
|
||||
"base_url": "http://localhost:9002",
|
||||
}))
|
||||
|
||||
with patch.dict(os.environ, {"HONCHO_BASE_URL": "http://localhost:9003"}, clear=True):
|
||||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||
assert config.base_url == "http://localhost:8000"
|
||||
|
||||
|
||||
def test_endpoint_block_non_dict_is_ignored(self, tmp_path):
|
||||
"""A malformed endpoint value falls through instead of crashing."""
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({
|
||||
"endpoint": "http://localhost:8000",
|
||||
"baseUrl": "http://localhost:9001",
|
||||
}))
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||
assert config.base_url == "http://localhost:9001"
|
||||
|
||||
|
||||
def test_host_block_overrides_root(self, tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({
|
||||
|
|
|
|||
Loading…
Reference in New Issue