Files
mempalace/tests/test_llm_client.py
T
MSL 4400734867 feat(privacy): warn when LLM tier sends content to external API
4 files changed, 248 insertions, 0 deletions. 7 new tests (4 unit + 3 integration), all RED-first.

Per @milla-jovovich's question to @igorls during PR #1221 review: users
running `mempalace init` with an external LLM provider (Anthropic API,
OpenAI hosted, etc.) need a clear, explicit warning that their folder
content will be sent to the provider, that MemPalace doesn't control
how the provider logs/retains/uses that data, and how to opt out.
@igorls confirmed this should be a small follow-up PR scoped to the
warning itself, before the v3.3.4 tag.

This PR adds:

- `_endpoint_is_local(url)` helper in `mempalace/llm_client.py` —
  URL-based heuristic returning True if the hostname is on the user's
  machine or private network. Covers: localhost, 127.0.0.1, ::1,
  hostnames ending in .local (mDNS/Bonjour), IPv4 RFC1918 ranges
  (10/8, 172.16-31/12, 192.168/16), and IPv6 unique-local addresses
  (fc00::/7).

- `is_external_service` property on the `LLMProvider` base class.
  Subclasses inherit; the URL determines (no provider-specific
  hardcoding). This means: Ollama on localhost = local. LM Studio on
  LAN = local. Anthropic with default `https://api.anthropic.com` =
  external. A user proxying Anthropic through localhost (advanced
  setup) = local, no false-positive warning.

- One-line warning print in `cmd_init` after successful provider
  acquisition, gated on `is_external_service`:

      ⚠ {provider_name} is an EXTERNAL API. Your folder content will be
      sent to the provider during init. MemPalace does not control how
      the provider logs, retains, or uses your data. Pass --no-llm to
      keep init fully local.

  The warning fires AFTER `LLM enabled: ...` so users see both that
  the LLM is engaged AND the privacy implications of where it lives,
  before Pass 0 / entity detection actually runs.

LOCAL providers (Ollama on localhost, LM Studio on localhost or LAN,
llama.cpp on localhost, vLLM on localhost) DO NOT trigger the warning —
nothing leaves the user's machine/network in those configurations.

TDD: 7 tests added across 2 files.

Unit tests in `tests/test_llm_client.py` (4 tests, all RED-first):

1. test_ollama_provider_default_endpoint_is_local — pins that the
   default `http://localhost:11434` is classified local.
2. test_openai_compat_provider_localhost_endpoint_is_local — covers
   the LM Studio / llama.cpp / vLLM common case (localhost,
   127.0.0.1, and 192.168.x LAN).
3. test_openai_compat_provider_cloud_endpoint_is_external — pins
   that pointing openai-compat at https://api.openai.com (or any
   non-local URL) classifies as external.
4. test_anthropic_provider_default_endpoint_is_external — pins that
   AnthropicProvider's default endpoint is external (the dominant
   user-facing case for `--llm-provider anthropic`).

Integration tests in `tests/test_corpus_origin_integration.py` (3 tests,
RED-first; 1 was the critical RED — the other 2 passed by accident
since nothing printed "EXTERNAL API" before this PR):

5. test_init_prints_privacy_warning_when_provider_is_external —
   captures stdout from cmd_init with a mocked external provider,
   asserts the warning text contains "EXTERNAL API" + "--no-llm" +
   language about MemPalace not controlling provider behavior.
6. test_init_no_privacy_warning_when_provider_is_local — same flow
   with a mocked local provider, asserts the warning text does NOT
   appear.
7. test_init_no_privacy_warning_with_no_llm_flag — pins the --no-llm
   path: no provider acquisition attempted, no warning fires.

Tests: 1382 total mempalace tests pass. 2 pre-existing environmental
failures unrelated to this change (chromadb optional dep). Ruff check +
format both clean.

Backwards compatible: `is_external_service` is a new property; existing
callers don't reference it. The warning is a new print statement that
fires only when an external endpoint is acquired. The `--no-llm` opt-out
existed before this PR and continues to work identically.

Out of scope for follow-up (deliberately not in this PR per Igor's
"small PR" guidance): Tailscale CGNAT (100.64.0.0/10) treatment,
pre-init confirmation prompt, persistent privacy-mode config flag,
explicit cloud-provider name detection. Tracked for future iteration.
2026-04-26 14:43:20 -07:00

381 lines
14 KiB
Python

"""Tests for mempalace.llm_client.
HTTP is mocked throughout — these tests do not require a running Ollama
or network access. Live-provider smoke tests live outside the unit-test
suite.
"""
import json
from unittest.mock import patch, MagicMock
import pytest
from mempalace.llm_client import (
AnthropicProvider,
LLMError,
OllamaProvider,
OpenAICompatProvider,
_http_post_json,
get_provider,
)
# ── factory ─────────────────────────────────────────────────────────────
def test_get_provider_ollama():
p = get_provider("ollama", "gemma4:e4b")
assert isinstance(p, OllamaProvider)
assert p.model == "gemma4:e4b"
assert p.endpoint == OllamaProvider.DEFAULT_ENDPOINT
def test_get_provider_openai_compat():
p = get_provider("openai-compat", "foo", endpoint="http://localhost:1234")
assert isinstance(p, OpenAICompatProvider)
def test_get_provider_anthropic():
p = get_provider("anthropic", "claude-haiku", api_key="sk-xxx")
assert isinstance(p, AnthropicProvider)
assert p.api_key == "sk-xxx"
def test_get_provider_unknown_raises():
with pytest.raises(LLMError, match="Unknown provider"):
get_provider("nonsense", "x")
# ── _http_post_json ─────────────────────────────────────────────────────
def test_http_post_json_success():
mock_resp = MagicMock()
mock_resp.read.return_value = b'{"ok": true}'
mock_resp.__enter__.return_value = mock_resp
mock_resp.__exit__.return_value = False
with patch("mempalace.llm_client.urlopen", return_value=mock_resp):
result = _http_post_json("http://x/y", {"a": 1}, {}, timeout=5)
assert result == {"ok": True}
def test_http_post_json_http_error_wraps_as_llm_error():
from urllib.error import HTTPError
import io
err = HTTPError("http://x", 404, "Not Found", {}, io.BytesIO(b"model missing"))
with patch("mempalace.llm_client.urlopen", side_effect=err):
with pytest.raises(LLMError, match="HTTP 404"):
_http_post_json("http://x", {}, {}, timeout=5)
def test_http_post_json_url_error_wraps_as_llm_error():
from urllib.error import URLError
with patch("mempalace.llm_client.urlopen", side_effect=URLError("conn refused")):
with pytest.raises(LLMError, match="Cannot reach"):
_http_post_json("http://x", {}, {}, timeout=5)
def test_http_post_json_malformed_response():
mock_resp = MagicMock()
mock_resp.read.return_value = b"not json"
mock_resp.__enter__.return_value = mock_resp
mock_resp.__exit__.return_value = False
with patch("mempalace.llm_client.urlopen", return_value=mock_resp):
with pytest.raises(LLMError, match="Malformed"):
_http_post_json("http://x", {}, {}, timeout=5)
# ── OllamaProvider ──────────────────────────────────────────────────────
def _mock_ollama_chat_response(content: str):
mock = MagicMock()
mock.read.return_value = json.dumps({"message": {"content": content}}).encode()
mock.__enter__.return_value = mock
mock.__exit__.return_value = False
return mock
def test_ollama_check_available_finds_model():
tags = {"models": [{"name": "gemma4:e4b"}, {"name": "other:latest"}]}
mock = MagicMock()
mock.read.return_value = json.dumps(tags).encode()
mock.__enter__.return_value = mock
mock.__exit__.return_value = False
with patch("mempalace.llm_client.urlopen", return_value=mock):
p = OllamaProvider(model="gemma4:e4b")
ok, msg = p.check_available()
assert ok
assert msg == "ok"
def test_ollama_check_available_accepts_latest_suffix():
tags = {"models": [{"name": "mymodel:latest"}]}
mock = MagicMock()
mock.read.return_value = json.dumps(tags).encode()
mock.__enter__.return_value = mock
mock.__exit__.return_value = False
with patch("mempalace.llm_client.urlopen", return_value=mock):
p = OllamaProvider(model="mymodel")
ok, _ = p.check_available()
assert ok
def test_ollama_check_available_missing_model():
tags = {"models": [{"name": "other:latest"}]}
mock = MagicMock()
mock.read.return_value = json.dumps(tags).encode()
mock.__enter__.return_value = mock
mock.__exit__.return_value = False
with patch("mempalace.llm_client.urlopen", return_value=mock):
p = OllamaProvider(model="absent")
ok, msg = p.check_available()
assert not ok
assert "ollama pull absent" in msg
def test_ollama_check_available_unreachable():
from urllib.error import URLError
with patch("mempalace.llm_client.urlopen", side_effect=URLError("refused")):
p = OllamaProvider(model="gemma4:e4b")
ok, msg = p.check_available()
assert not ok
assert "Cannot reach Ollama" in msg
def test_ollama_classify_sends_json_format():
captured = {}
def fake_urlopen(req, *, timeout):
captured["url"] = req.full_url
captured["body"] = json.loads(req.data.decode())
return _mock_ollama_chat_response('{"classifications": []}')
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
p = OllamaProvider(model="gemma4:e4b")
resp = p.classify("sys", "user", json_mode=True)
assert captured["body"]["format"] == "json"
assert captured["body"]["model"] == "gemma4:e4b"
assert captured["url"].endswith("/api/chat")
assert resp.provider == "ollama"
assert resp.text == '{"classifications": []}'
def test_ollama_classify_empty_content_raises():
with patch("mempalace.llm_client.urlopen", return_value=_mock_ollama_chat_response("")):
p = OllamaProvider(model="x")
with pytest.raises(LLMError, match="Empty response"):
p.classify("s", "u")
# ── OpenAICompatProvider ────────────────────────────────────────────────
def _mock_openai_response(content: str):
mock = MagicMock()
payload = {"choices": [{"message": {"content": content}}]}
mock.read.return_value = json.dumps(payload).encode()
mock.__enter__.return_value = mock
mock.__exit__.return_value = False
return mock
def test_openai_compat_resolves_url_with_v1_suffix():
captured = {}
def fake_urlopen(req, *, timeout):
captured["url"] = req.full_url
return _mock_openai_response('{"ok": true}')
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
p = OpenAICompatProvider(model="x", endpoint="http://h:1234")
p.classify("s", "u")
assert captured["url"] == "http://h:1234/v1/chat/completions"
def test_openai_compat_resolves_url_with_existing_v1():
captured = {}
def fake_urlopen(req, *, timeout):
captured["url"] = req.full_url
return _mock_openai_response('{"ok": true}')
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
p = OpenAICompatProvider(model="x", endpoint="http://h:1234/v1")
p.classify("s", "u")
assert captured["url"] == "http://h:1234/v1/chat/completions"
def test_openai_compat_requires_endpoint():
p = OpenAICompatProvider(model="x")
with pytest.raises(LLMError, match="requires --llm-endpoint"):
p.classify("s", "u")
def test_openai_compat_sends_authorization_when_key_present():
captured = {}
def fake_urlopen(req, *, timeout):
captured["auth"] = req.get_header("Authorization")
return _mock_openai_response('{"ok": true}')
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
p = OpenAICompatProvider(model="x", endpoint="http://h", api_key="sk-aaa")
p.classify("s", "u")
assert captured["auth"] == "Bearer sk-aaa"
def test_openai_compat_uses_env_var_fallback(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env")
p = OpenAICompatProvider(model="x", endpoint="http://h")
assert p.api_key == "sk-from-env"
def test_openai_compat_sends_response_format_json():
captured = {}
def fake_urlopen(req, *, timeout):
captured["body"] = json.loads(req.data.decode())
return _mock_openai_response('{"ok": true}')
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
p = OpenAICompatProvider(model="x", endpoint="http://h")
p.classify("s", "u", json_mode=True)
assert captured["body"]["response_format"] == {"type": "json_object"}
def test_openai_compat_unexpected_shape_raises():
mock = MagicMock()
mock.read.return_value = b'{"nothing": "here"}'
mock.__enter__.return_value = mock
mock.__exit__.return_value = False
with patch("mempalace.llm_client.urlopen", return_value=mock):
p = OpenAICompatProvider(model="x", endpoint="http://h")
with pytest.raises(LLMError, match="Unexpected response shape"):
p.classify("s", "u")
# ── AnthropicProvider ───────────────────────────────────────────────────
def _mock_anthropic_response(text: str):
mock = MagicMock()
payload = {"content": [{"type": "text", "text": text}]}
mock.read.return_value = json.dumps(payload).encode()
mock.__enter__.return_value = mock
mock.__exit__.return_value = False
return mock
def test_anthropic_requires_api_key(monkeypatch):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
p = AnthropicProvider(model="claude-haiku")
ok, msg = p.check_available()
assert not ok
assert "ANTHROPIC_API_KEY" in msg
def test_anthropic_reads_env_key(monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-env")
p = AnthropicProvider(model="claude-haiku")
assert p.api_key == "sk-ant-env"
ok, _ = p.check_available()
assert ok
def test_anthropic_classify_sends_version_and_key():
captured = {}
def fake_urlopen(req, *, timeout):
captured["api_key"] = req.get_header("X-api-key")
captured["version"] = req.get_header("Anthropic-version")
return _mock_anthropic_response('{"ok": true}')
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
p = AnthropicProvider(model="claude-haiku", api_key="sk-ant-abc")
resp = p.classify("s", "u")
assert captured["api_key"] == "sk-ant-abc"
assert captured["version"] == AnthropicProvider.API_VERSION
assert resp.text == '{"ok": true}'
def test_anthropic_joins_multiple_text_blocks():
mock = MagicMock()
payload = {
"content": [
{"type": "text", "text": "part one. "},
{"type": "text", "text": "part two."},
]
}
mock.read.return_value = json.dumps(payload).encode()
mock.__enter__.return_value = mock
mock.__exit__.return_value = False
with patch("mempalace.llm_client.urlopen", return_value=mock):
p = AnthropicProvider(model="claude-haiku", api_key="sk-ant")
resp = p.classify("s", "u")
assert resp.text == "part one. part two."
def test_anthropic_no_key_raises_on_classify(monkeypatch):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
p = AnthropicProvider(model="claude-haiku")
with pytest.raises(LLMError, match="requires ANTHROPIC_API_KEY"):
p.classify("s", "u")
# ── is_external_service property (issue #24 — privacy warning support) ──
#
# `is_external_service` is True when this provider's endpoint sends data
# off the user's machine/network. Used by mempalace init to print a
# privacy warning before first run when an external API will receive
# folder content. URL-based heuristic: localhost, 127.x, ::1, .local,
# RFC1918 (10/8, 192.168/16, 172.16-31/12), and IPv6 ULA (fc/fd::) are
# all treated as local. Everything else is treated as external.
def test_ollama_provider_default_endpoint_is_local():
"""OllamaProvider's default endpoint is http://localhost:11434, which
must be classified as local — no privacy warning fires for the
typical user running Ollama on their own machine."""
p = OllamaProvider(model="gemma4:e4b")
assert p.is_external_service is False, (
f"Default OllamaProvider endpoint must be local; got "
f"is_external_service={p.is_external_service} for endpoint={p.endpoint}"
)
def test_openai_compat_provider_localhost_endpoint_is_local():
"""LM Studio / llama.cpp server / vLLM commonly bind to localhost.
Those setups must NOT trigger the external-API warning."""
p = OpenAICompatProvider(model="any", endpoint="http://localhost:1234")
assert p.is_external_service is False
p_127 = OpenAICompatProvider(model="any", endpoint="http://127.0.0.1:8000")
assert p_127.is_external_service is False
p_lan = OpenAICompatProvider(model="any", endpoint="http://192.168.1.50:1234")
assert p_lan.is_external_service is False, "LAN (RFC1918) endpoints must be local"
def test_openai_compat_provider_cloud_endpoint_is_external():
"""A user pointing openai-compat at OpenAI's hosted API or any other
non-local endpoint MUST trigger the external warning."""
p = OpenAICompatProvider(model="gpt-4o", endpoint="https://api.openai.com")
assert p.is_external_service is True, (
f"https://api.openai.com must be classified external; got "
f"is_external_service={p.is_external_service}"
)
def test_anthropic_provider_default_endpoint_is_external():
"""AnthropicProvider's default endpoint is https://api.anthropic.com,
which is always external by definition. The privacy warning MUST
fire by default for users who pass --llm-provider anthropic."""
p = AnthropicProvider(model="claude-haiku-4-5", api_key="sk-test")
assert p.is_external_service is True, (
f"Default AnthropicProvider endpoint must be external; got "
f"is_external_service={p.is_external_service} for endpoint={p.endpoint}"
)