headroom/headroom/memory/qdrant_env.py
ipapapa d3c37d7098 feat(memory): resolve Qdrant connection from HEADROOM_QDRANT_* env vars (#31)
Adds `HEADROOM_QDRANT_URL`, `_HOST`, `_PORT`, `_API_KEY`, `_HTTPS`,
`_PREFER_GRPC`, `_GRPC_PORT` support across the memory stack:

- `headroom/memory/qdrant_env.py`: shared resolver helper with
  explicit-arg > env > default precedence (URL wins over host/port;
  booleans parsed via standard truthy set).
- `memory/easy.py`, `backends/{mem0,direct_mem0}.py`,
  `proxy/memory_handler.py`: call the resolver so
  `Memory(backend="qdrant-neo4j")`, `Mem0Config`, and the proxy's
  `MemoryConfig` all honor the same env keys.
- `proxy/models.py` + `proxy/server.py`: `ProxyConfig` picks up the
  same keys so hosted Qdrant (e.g. Qdrant Cloud) works without code
  changes.
- `cli/proxy.py`: adds `--memory-qdrant-{url,host,port,api-key}`
  flags that override the env when present.
- `tests/test_memory/test_qdrant_env.py`: unit coverage for
  precedence, URL-vs-host/port, boolean parsing, and unset defaults.
- `CHANGELOG.md`: documented under [Unreleased] / Added.

Explicit constructor arguments still win; unset env keeps the existing
localhost:6333 defaults, so this is backwards-compatible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 22:16:16 -07:00

151 lines
5.1 KiB
Python

"""Resolve Qdrant connection settings from environment variables.
Provides a single source of truth for ``HEADROOM_QDRANT_*`` env vars so that
``Memory``, ``Mem0Config``, ``DirectMem0Config``, and the proxy all pick up
the same defaults when the caller does not pass an explicit value.
Supported environment variables:
- ``HEADROOM_QDRANT_URL`` Full URL (e.g. ``https://xyz.cloud.qdrant.io:6333``).
When set, takes precedence over host/port.
- ``HEADROOM_QDRANT_HOST`` Hostname. Default: ``localhost``.
- ``HEADROOM_QDRANT_PORT`` HTTP port. Default: ``6333``.
- ``HEADROOM_QDRANT_API_KEY`` API key for hosted Qdrant (e.g. Qdrant Cloud).
- ``HEADROOM_QDRANT_HTTPS`` ``true``/``false``. Forces HTTPS on/off.
- ``HEADROOM_QDRANT_PREFER_GRPC````true``/``false``. Use gRPC instead of HTTP.
- ``HEADROOM_QDRANT_GRPC_PORT`` gRPC port. Default: ``6334``.
Explicit constructor arguments always win over environment values; the env
vars only fill in defaults when the caller passes ``None`` (or omits the
argument on a dataclass that uses ``field(default_factory=...)``).
"""
from __future__ import annotations
import os
DEFAULT_QDRANT_HOST = "localhost"
DEFAULT_QDRANT_PORT = 6333
DEFAULT_QDRANT_GRPC_PORT = 6334
_TRUTHY = frozenset({"1", "true", "yes", "y", "on"})
_FALSY = frozenset({"0", "false", "no", "n", "off"})
def _strip_env(name: str) -> str | None:
"""Return the trimmed env var value, or ``None`` if unset/empty."""
raw = os.environ.get(name)
if raw is None:
return None
stripped = raw.strip()
return stripped or None
def _parse_bool(raw: str | None) -> bool | None:
"""Parse a bool env value. Returns ``None`` if unset, else True/False.
Unknown strings raise ``ValueError`` so misconfiguration is visible.
"""
if raw is None:
return None
lowered = raw.lower()
if lowered in _TRUTHY:
return True
if lowered in _FALSY:
return False
raise ValueError(f"Invalid boolean value {raw!r}; expected one of {sorted(_TRUTHY | _FALSY)}")
def _parse_port(raw: str | None, var_name: str) -> int | None:
"""Parse a port env value. Returns ``None`` if unset."""
if raw is None:
return None
try:
port = int(raw)
except ValueError as exc:
raise ValueError(f"{var_name}={raw!r} is not a valid integer port") from exc
if not 1 <= port <= 65535:
raise ValueError(f"{var_name}={port} is outside the valid port range 1-65535")
return port
def qdrant_env_url() -> str | None:
"""Return ``HEADROOM_QDRANT_URL`` or ``None`` if unset."""
return _strip_env("HEADROOM_QDRANT_URL")
def qdrant_env_host() -> str:
"""Return ``HEADROOM_QDRANT_HOST`` or the ``localhost`` default."""
return _strip_env("HEADROOM_QDRANT_HOST") or DEFAULT_QDRANT_HOST
def qdrant_env_port() -> int:
"""Return ``HEADROOM_QDRANT_PORT`` or the ``6333`` default."""
return (
_parse_port(_strip_env("HEADROOM_QDRANT_PORT"), "HEADROOM_QDRANT_PORT")
or DEFAULT_QDRANT_PORT
)
def qdrant_env_api_key() -> str | None:
"""Return ``HEADROOM_QDRANT_API_KEY`` or ``None`` if unset."""
return _strip_env("HEADROOM_QDRANT_API_KEY")
def qdrant_env_https() -> bool | None:
"""Return ``HEADROOM_QDRANT_HTTPS`` parsed as bool, or ``None`` if unset."""
return _parse_bool(_strip_env("HEADROOM_QDRANT_HTTPS"))
def qdrant_env_prefer_grpc() -> bool:
"""Return ``HEADROOM_QDRANT_PREFER_GRPC`` parsed as bool. Default: ``False``."""
return _parse_bool(_strip_env("HEADROOM_QDRANT_PREFER_GRPC")) or False
def qdrant_env_grpc_port() -> int:
"""Return ``HEADROOM_QDRANT_GRPC_PORT`` or the ``6334`` default."""
return (
_parse_port(_strip_env("HEADROOM_QDRANT_GRPC_PORT"), "HEADROOM_QDRANT_GRPC_PORT")
or DEFAULT_QDRANT_GRPC_PORT
)
def build_qdrant_client_kwargs(
*,
url: str | None = None,
host: str | None = None,
port: int | None = None,
api_key: str | None = None,
https: bool | None = None,
prefer_grpc: bool | None = None,
grpc_port: int | None = None,
) -> dict[str, object]:
"""Build a kwargs dict suitable for ``qdrant_client.QdrantClient(**kwargs)``.
URL takes precedence over host/port: if ``url`` is a non-empty string the
returned dict contains ``url`` and omits ``host``/``port``. Otherwise
``host``/``port`` are populated (falling back to ``localhost:6333``).
Optional fields (``api_key``, ``https``, ``prefer_grpc``, ``grpc_port``)
are only included when they have a value, so callers that don't need them
don't accidentally pass ``None`` into Qdrant client options that would
override sensible library defaults.
"""
kwargs: dict[str, object] = {}
effective_url = url if url else None
if effective_url:
kwargs["url"] = effective_url
else:
kwargs["host"] = host or DEFAULT_QDRANT_HOST
kwargs["port"] = port or DEFAULT_QDRANT_PORT
if api_key:
kwargs["api_key"] = api_key
if https is not None:
kwargs["https"] = https
if prefer_grpc:
kwargs["prefer_grpc"] = True
kwargs["grpc_port"] = grpc_port or DEFAULT_QDRANT_GRPC_PORT
return kwargs