265 lines
9.1 KiB
Python
265 lines
9.1 KiB
Python
"""llm.pool — least-loaded fan-out across Ollama hosts."""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from llm.config import LlmConfig
|
|
from llm.pool import HostPool, PoolEmbeddings, embed_texts
|
|
|
|
H1, H2 = "http://h1:11434", "http://h2:11434"
|
|
|
|
|
|
def _resp(payload, status=200):
|
|
r = MagicMock()
|
|
r.status_code = status
|
|
r.json.return_value = payload
|
|
return r
|
|
|
|
|
|
class TestCheck:
|
|
@patch("llm.pool.httpx.Client")
|
|
def test_drops_host_missing_model(self, MockClient):
|
|
client = MockClient.return_value.__enter__.return_value
|
|
client.get.side_effect = [
|
|
_resp({"models": [{"name": "nomic-embed-text:latest"}]}),
|
|
_resp({"models": [{"name": "other:latest"}]}),
|
|
]
|
|
pool = HostPool([H1, H2])
|
|
assert pool.check("nomic-embed-text") == [H1]
|
|
|
|
@patch("llm.pool.httpx.Client")
|
|
def test_drops_unreachable_host(self, MockClient):
|
|
import httpx
|
|
|
|
client = MockClient.return_value.__enter__.return_value
|
|
client.get.side_effect = [
|
|
_resp({"models": [{"name": "m:latest"}]}),
|
|
httpx.ConnectError("down"),
|
|
]
|
|
pool = HostPool([H1, H2])
|
|
assert pool.check("m") == [H1]
|
|
|
|
@patch("llm.pool.httpx.Client")
|
|
def test_no_hosts_left_raises(self, MockClient):
|
|
client = MockClient.return_value.__enter__.return_value
|
|
client.get.return_value = _resp({"models": []})
|
|
with pytest.raises(RuntimeError, match="no Ollama host"):
|
|
HostPool([H1]).check("m")
|
|
|
|
@patch("llm.pool.httpx.Client")
|
|
def test_drops_host_with_malformed_json(self, MockClient):
|
|
"""A host that's up but returns a non-JSON/malformed body is
|
|
dropped, not treated as a fatal error for the whole run."""
|
|
client = MockClient.return_value.__enter__.return_value
|
|
bad = MagicMock()
|
|
bad.json.side_effect = ValueError("not JSON")
|
|
client.get.side_effect = [bad, _resp({"models": [{"name": "m:latest"}]})]
|
|
pool = HostPool([H1, H2])
|
|
assert pool.check("m") == [H2]
|
|
|
|
@patch("llm.pool.httpx.Client")
|
|
def test_drops_host_missing_name_key(self, MockClient):
|
|
"""A model dict missing 'name' raises KeyError inside check();
|
|
that host should be dropped, not abort the whole check()."""
|
|
client = MockClient.return_value.__enter__.return_value
|
|
client.get.side_effect = [
|
|
_resp({"models": [{"no_name": "whatever"}]}),
|
|
_resp({"models": [{"name": "m:latest"}]}),
|
|
]
|
|
pool = HostPool([H1, H2])
|
|
assert pool.check("m") == [H2]
|
|
|
|
|
|
class TestFromConfig:
|
|
def test_from_config_uses_ollama_hosts(self):
|
|
cfg = LlmConfig(
|
|
ollama_hosts=(H1, H2),
|
|
embed_model="m",
|
|
instruct_model="g",
|
|
embed_dim=768,
|
|
build_ann_index=False,
|
|
pg_host="x",
|
|
pg_port=5432,
|
|
pg_db="llm",
|
|
pg_user="llm",
|
|
)
|
|
pool = HostPool.from_config(cfg)
|
|
assert pool.hosts == list(cfg.ollama_hosts)
|
|
|
|
|
|
class TestAcquire:
|
|
def test_prefers_least_in_flight(self):
|
|
pool = HostPool([H1, H2])
|
|
with pool.acquire() as first, pool.acquire() as second:
|
|
assert {first, second} == {H1, H2}
|
|
|
|
def test_released_host_is_reused(self):
|
|
pool = HostPool([H1, H2])
|
|
with pool.acquire() as first:
|
|
pass
|
|
with pool.acquire() as a, pool.acquire() as b:
|
|
assert {a, b} == {H1, H2}
|
|
assert first in {H1, H2}
|
|
|
|
|
|
class TestEmbedTexts:
|
|
@patch("llm.pool.httpx.Client")
|
|
def test_order_preserved_across_batches(self, MockClient):
|
|
client = MockClient.return_value.__enter__.return_value
|
|
|
|
def fake_post(url, json=None, **kw):
|
|
return _resp({"embeddings": [[float(len(t))] for t in json["input"]]})
|
|
|
|
client.post.side_effect = fake_post
|
|
pool = HostPool([H1, H2])
|
|
out = embed_texts(pool, "m", ["a", "bb", "ccc"], batch_size=1)
|
|
assert out == [[1.0], [2.0], [3.0]]
|
|
|
|
@patch("llm.pool.httpx.Client")
|
|
def test_http_error_raises(self, MockClient):
|
|
client = MockClient.return_value.__enter__.return_value
|
|
resp = _resp({}, status=500)
|
|
resp.raise_for_status.side_effect = Exception("boom")
|
|
client.post.return_value = resp
|
|
with pytest.raises(Exception, match="boom"):
|
|
embed_texts(HostPool([H1]), "m", ["a"])
|
|
|
|
|
|
class TestCheckAcquireInterleave:
|
|
@patch("llm.pool.httpx.Client")
|
|
def test_check_drops_held_host_without_corruption(self, MockClient):
|
|
client = MockClient.return_value.__enter__.return_value
|
|
pool = HostPool([H1, H2])
|
|
with pool.acquire() as held:
|
|
survivor = H2 if held == H1 else H1
|
|
|
|
def fake_get(url, **kw):
|
|
if url.startswith(held):
|
|
return _resp({"models": []})
|
|
return _resp({"models": [{"name": "m:latest"}]})
|
|
|
|
client.get.side_effect = fake_get
|
|
assert pool.check("m") == [survivor]
|
|
# exiting acquire() above must not raise despite the dropped host
|
|
with pool.acquire() as again:
|
|
assert again == survivor
|
|
assert pool._in_flight[survivor] == 0
|
|
|
|
|
|
class TestPoolEmbeddings:
|
|
@patch("llm.pool.embed_texts")
|
|
def test_adapter_delegates(self, mock_embed):
|
|
mock_embed.return_value = [[0.1]]
|
|
pool = HostPool([H1])
|
|
emb = PoolEmbeddings(pool, "m")
|
|
assert emb.embed_documents(["x"]) == [[0.1]]
|
|
assert emb.embed_query("x") == [0.1]
|
|
|
|
|
|
class TestGeneration:
|
|
def _pool(self):
|
|
return HostPool([H1, H2], vram_gb={H1: 12, H2: 24})
|
|
|
|
@patch("llm.pool.httpx.Client")
|
|
def test_acquire_generation_prefers_largest_live_host(self, MockClient):
|
|
client = MockClient.return_value.__enter__.return_value
|
|
client.get.side_effect = [
|
|
_resp({"models": [{"name": "chat:latest"}]}),
|
|
_resp({"models": [{"name": "chat:latest"}, {"name": "big:latest"}]}),
|
|
]
|
|
pool = self._pool()
|
|
pool.check("chat")
|
|
with pool.acquire_generation() as host:
|
|
assert host == H2
|
|
assert pool._in_flight[H2] == 1
|
|
assert pool._in_flight[H2] == 0
|
|
|
|
@patch("llm.pool.httpx.Client")
|
|
def test_falls_back_when_largest_is_down(self, MockClient):
|
|
import httpx
|
|
|
|
client = MockClient.return_value.__enter__.return_value
|
|
client.get.side_effect = [
|
|
_resp({"models": [{"name": "chat:latest"}]}),
|
|
httpx.ConnectError("rig down"),
|
|
]
|
|
pool = self._pool()
|
|
pool.check("chat")
|
|
with pool.acquire_generation() as host:
|
|
assert host == H1
|
|
|
|
def test_tie_breaks_on_least_in_flight(self):
|
|
pool = HostPool([H1, H2], vram_gb={H1: 24, H2: 24})
|
|
pool._in_flight[H1] = 3
|
|
with pool.acquire_generation() as host:
|
|
assert host == H2
|
|
|
|
def test_vram_defaults_to_zero(self):
|
|
assert HostPool([H1]).vram(H1) == 0.0
|
|
|
|
@patch("llm.pool.httpx.Client")
|
|
def test_serves_reflects_last_check(self, MockClient):
|
|
client = MockClient.return_value.__enter__.return_value
|
|
client.get.side_effect = [
|
|
_resp({"models": [{"name": "chat:latest"}, {"name": "big:latest"}]}),
|
|
]
|
|
pool = HostPool([H1])
|
|
pool.check("chat")
|
|
assert pool.serves(H1, "big") is True
|
|
assert pool.serves(H1, "big:latest") is True
|
|
assert pool.serves(H1, "nope") is False
|
|
assert pool.reachable(H1) is True
|
|
assert pool.reachable(H2) is False
|
|
assert pool.status() == [
|
|
{"host": H1, "vram_gb": 0.0, "models": ["big:latest", "chat:latest"]}
|
|
]
|
|
|
|
|
|
class TestPickModel:
|
|
def _cfg(self, **kw):
|
|
base = dict(
|
|
ollama_hosts=(H1, H2),
|
|
embed_model="e",
|
|
instruct_model="chat",
|
|
instruct_model_large="big",
|
|
large_min_vram_gb=20,
|
|
embed_dim=768,
|
|
build_ann_index=False,
|
|
pg_host="x",
|
|
pg_port=5432,
|
|
pg_db="llm",
|
|
pg_user="llm",
|
|
)
|
|
base.update(kw)
|
|
return LlmConfig(**base)
|
|
|
|
def _pool_serving(self, models_by_host):
|
|
pool = HostPool(list(models_by_host), vram_gb={H1: 12, H2: 24})
|
|
pool._models = {h: set(ms) for h, ms in models_by_host.items()}
|
|
return pool
|
|
|
|
def test_large_on_big_host_that_serves_it(self):
|
|
from llm.pool import pick_model
|
|
|
|
pool = self._pool_serving({H1: {"chat"}, H2: {"chat", "big"}})
|
|
assert pick_model(self._cfg(), pool, H2) == "big"
|
|
|
|
def test_baseline_on_small_host(self):
|
|
from llm.pool import pick_model
|
|
|
|
pool = self._pool_serving({H1: {"chat", "big"}, H2: {"chat", "big"}})
|
|
assert pick_model(self._cfg(), pool, H1) == "chat"
|
|
|
|
def test_baseline_when_big_host_lacks_large_model(self):
|
|
from llm.pool import pick_model
|
|
|
|
pool = self._pool_serving({H1: {"chat"}, H2: {"chat"}})
|
|
assert pick_model(self._cfg(), pool, H2) == "chat"
|
|
|
|
def test_baseline_when_no_large_configured(self):
|
|
from llm.pool import pick_model
|
|
|
|
pool = self._pool_serving({H2: {"chat", "big"}})
|
|
assert pick_model(self._cfg(instruct_model_large=""), pool, H2) == "chat"
|