fix: allow local providers (Ollama, LM Studio) without API keys in delegation

Local model servers running on localhost, 127.0.0.1, .local mDNS hostnames,
or RFC 1918 private networks don't require authentication. Previously,
_resolve_delegation_credentials() hard-required an API key for any base_url,
making it impossible to use Ollama or similar local servers for subagent
delegation without setting a dummy key.

Changes:
- Add _is_local_base_url() helper detecting localhost, loopback, .local,
  and RFC 1918 private network addresses
- base_url path: skip API key requirement for local endpoints, use
  'ollama' placeholder key (accepted by local servers)
- provider path: same logic — if resolve_runtime_provider returns an
  empty API key but the resolved base_url is local, use 'ollama' placeholder
- Remote endpoints still require a real API key (no security regression)
- Update existing test to use remote URL (was testing localhost which is
  now allowed)
- Add 19 new tests covering local provider credential resolution
This commit is contained in:
AJ 2026-04-22 22:51:50 -04:00
parent 94f1758742
commit 0e4bc9474d
3 changed files with 280 additions and 5 deletions

View file

@ -2098,6 +2098,37 @@ def _resolve_child_credential_pool(effective_provider: Optional[str], parent_age
return None
def _is_local_base_url(base_url: Optional[str]) -> bool:
"""Return True if base_url points to a local/private network address.
Local providers (Ollama, LM Studio, llama.cpp server, etc.) typically
don't require authentication. This check covers:
- localhost / loopback (127.0.0.1, ::1)
- .local mDNS hostnames (e.g. studio.local)
- RFC 1918 private networks (10/8, 172.16/12, 192.168/16)
"""
if not base_url:
return False
hostname = base_url_hostname(base_url)
if not hostname:
return False
# localhost variants
if hostname in ("localhost", "127.0.0.1", "::1"):
return True
# mDNS .local hostnames
if hostname.endswith(".local"):
return True
# RFC 1918 private subnets
import ipaddress
try:
ip = ipaddress.ip_address(hostname)
return ip.is_private or ip.is_loopback
except ValueError:
pass # not an IP address, that's fine
return False
def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
"""Resolve credentials for subagent delegation.
@ -2111,6 +2142,10 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
If neither base_url nor provider is configured, returns None values so the
child inherits everything from the parent agent.
Local endpoints (localhost, 127.0.0.1, .local, RFC 1918 private nets)
don't require API keys — a placeholder "ollama" key is used when none
is provided, since these servers accept any or no authentication.
Raises ValueError with a user-friendly message on credential failure.
"""
configured_model = str(cfg.get("model") or "").strip() or None
@ -2120,6 +2155,10 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
if configured_base_url:
api_key = configured_api_key or os.getenv("OPENAI_API_KEY", "").strip()
# Local endpoints (Ollama, LM Studio, etc.) don't require auth.
# Use a dummy key so the OpenAI client doesn't reject the request.
if not api_key and _is_local_base_url(configured_base_url):
api_key = "ollama"
if not api_key:
raise ValueError(
"Delegation base_url is configured but no API key was found. "
@ -2175,10 +2214,15 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
api_key = runtime.get("api_key", "")
if not api_key:
raise ValueError(
f"Delegation provider '{configured_provider}' resolved but has no API key. "
f"Set the appropriate environment variable or run 'hermes auth'."
)
# Local providers don't require real API keys.
resolved_base = runtime.get("base_url", "")
if _is_local_base_url(resolved_base):
api_key = "ollama"
else:
raise ValueError(
f"Delegation provider '{configured_provider}' resolved but has no API key. "
f"Set the appropriate environment variable or run 'hermes auth'."
)
return {
"model": configured_model,