a0b7ba005d
2 files changed, 60 insertions, 0 deletions. 2 new tests (RED-first). Follow-up to #1224's privacy warning. The URL-based heuristic in ``mempalace.llm_client._endpoint_is_local`` shipped without recognizing Tailscale's CGNAT range (100.64.0.0/10), so a user running LM Studio, Ollama, or any local LLM accessible via a Tailscale-assigned 100.x.x.x address would currently get a wrong privacy warning — Tailscale addresses are network-private (only reachable inside the user's Tailnet) but they're not RFC1918, so the heuristic was treating them as external. This PR adds CGNAT recognition: when the hostname starts with ``100.`` AND the second octet is between 64 and 127 inclusive, it's classified as local. Addresses in 100.x.x.x outside that range (i.e. second octet < 64 or > 127) are regular allocated public space and remain external, so a user pointing at a public 100.0.0.1 still gets the warning. Concrete user impact: Before: ``mempalace init --llm-provider openai-compat --llm-endpoint http://100.100.50.50:1234`` (LM Studio on Tailnet) → triggers privacy warning incorrectly. After: same command → no warning. data stays inside the user's Tailnet, which is what the warning is supposed to protect against. TDD: 2 tests added in ``tests/test_llm_client.py``, both RED-first. 1. ``test_openai_compat_provider_tailscale_cgnat_endpoint_is_local`` — covers three Tailscale CGNAT addresses (start, middle, near-end of the range) and pins they're all classified local. This was the RED that drove the implementation. 2. ``test_openai_compat_provider_outside_tailscale_cgnat_is_external`` — pins the boundary on both sides: addresses with second octet 0-63 and 128-255 stay external. Prevents future "treat all 100.x.x.x as local" overcorrection. Tests: 1388 total mempalace tests pass. 2 pre-existing environmental failures unrelated to this change (chromadb optional dep). Ruff check + format both clean. Backwards compatible: only widens the local-recognition set. Anything classified local before is still classified local; anything classified external before remains so unless it's specifically in the CGNAT range. Out of scope (tracked for future iteration based on real user feedback, not built speculatively): pre-init confirmation prompt before sending to external API, persistent ``private-only`` config flag that refuses external endpoints entirely, explicit cloud-provider name detection ("Using Anthropic's hosted API at ..." vs the current generic warning).
429 lines
16 KiB
Python
429 lines
16 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}"
|
|
)
|
|
|
|
|
|
# ── Tailscale CGNAT range (issue #25 follow-up to #24) ──────────────────
|
|
#
|
|
# Tailscale assigns addresses in 100.64.0.0/10 (CGNAT range): first octet
|
|
# always 100, second octet 64-127 inclusive. Users running LM Studio /
|
|
# Ollama / any local LLM accessible via Tailscale would currently
|
|
# (post-#24, pre-#25) get a wrong privacy warning because the heuristic
|
|
# doesn't recognize CGNAT as private. These tests pin the fix.
|
|
|
|
|
|
def test_openai_compat_provider_tailscale_cgnat_endpoint_is_local():
|
|
"""Tailscale CGNAT range (100.64.0.0/10) — IPs where the first octet
|
|
is 100 AND the second octet is 64-127 inclusive — must be classified
|
|
as local. Tailscale users running LM Studio on their Tailnet should
|
|
not trigger the external-API warning.
|
|
"""
|
|
cases = [
|
|
("http://100.64.0.1:1234", "start of CGNAT"),
|
|
("http://100.100.50.50:1234", "middle of CGNAT (typical Tailscale assignment)"),
|
|
("http://100.127.255.254:1234", "near end of CGNAT"),
|
|
]
|
|
for endpoint, label in cases:
|
|
p = OpenAICompatProvider(model="any", endpoint=endpoint)
|
|
assert p.is_external_service is False, (
|
|
f"Tailscale CGNAT address {endpoint} ({label}) must be classified "
|
|
f"local; got is_external_service={p.is_external_service}"
|
|
)
|
|
|
|
|
|
def test_openai_compat_provider_outside_tailscale_cgnat_is_external():
|
|
"""Addresses in 100.x.x.x that fall OUTSIDE the CGNAT range
|
|
(100.64.0.0 - 100.127.255.255) are public IPs in regular allocated
|
|
space and must remain classified as external. Specifically: anything
|
|
where the second octet is < 64 or > 127.
|
|
"""
|
|
cases = [
|
|
("http://100.0.0.1:1234", "below CGNAT (public)"),
|
|
("http://100.63.255.255:1234", "just below CGNAT (boundary)"),
|
|
("http://100.128.0.0:1234", "just above CGNAT (boundary)"),
|
|
("http://100.255.255.255:1234", "well above CGNAT"),
|
|
]
|
|
for endpoint, label in cases:
|
|
p = OpenAICompatProvider(model="any", endpoint=endpoint)
|
|
assert p.is_external_service is True, (
|
|
f"Address {endpoint} ({label}) is OUTSIDE Tailscale CGNAT and "
|
|
f"should remain external; got is_external_service={p.is_external_service}"
|
|
)
|