Spaces:
Running on Zero
Running on Zero
File size: 7,844 Bytes
ad4bf51 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | """Backend selection and the local GPU client.
Real model inference is not exercised here -- downloading weights in CI would
be absurd. What is pinned down is the decision logic, the prompt/JSON handling
around generation, and that every failure path degrades instead of raising into
the request handler.
"""
import pytest
from finbot import local_llm
from finbot.backend import NullLLM, make_llm, resolve
from finbot.config import Settings
from finbot.llm import LLMUnavailable
def settings(**overrides):
base = dict(
telegram_bot_token="",
telegram_webhook_secret="",
allowed_telegram_user_ids="",
hf_token="",
hf_dataset_repo="",
llm_backend="auto",
)
base.update(overrides)
return Settings(**base)
class TestResolve:
def test_auto_prefers_local_when_a_gpu_is_present(self):
assert resolve(settings(), gpu_present=True) == "local"
def test_auto_falls_back_to_remote_with_a_token(self):
assert resolve(settings(hf_token="hf_x"), gpu_present=False) == "remote"
def test_auto_is_off_with_neither(self):
assert resolve(settings(), gpu_present=False) == "off"
def test_explicit_choices_are_honoured(self):
assert resolve(settings(llm_backend="local"), gpu_present=False) == "local"
assert resolve(settings(llm_backend="remote"), gpu_present=True) == "remote"
assert resolve(settings(llm_backend="off"), gpu_present=True) == "off"
def test_unknown_value_falls_back_to_auto(self):
assert resolve(settings(llm_backend="banana"), gpu_present=True) == "local"
def test_case_and_whitespace_tolerant(self):
assert resolve(settings(llm_backend=" LOCAL "), gpu_present=False) == "local"
class TestNullLLM:
def test_not_enabled(self):
assert NullLLM().enabled is False
def test_describes_itself(self):
assert "no GPU" in NullLLM().describe()
@pytest.mark.parametrize("method", ["chat", "chat_json", "vision_json"])
def test_every_call_raises_the_documented_error(self, method):
with pytest.raises(LLMUnavailable):
getattr(NullLLM(), method)("a", "b")
def test_close_is_safe(self):
assert NullLLM().close() is None
class TestMakeLLM:
def test_nothing_configured_yields_null(self):
assert isinstance(make_llm(settings(llm_backend="off")), NullLLM)
def test_remote_without_a_token_degrades_to_null(self):
assert isinstance(make_llm(settings(llm_backend="remote")), NullLLM)
def test_remote_with_a_token(self):
from finbot.llm import LLMClient
client = make_llm(settings(llm_backend="remote", hf_token="hf_x"))
assert isinstance(client, LLMClient)
assert client.enabled
def test_local_without_torch_degrades_rather_than_crashing(self, monkeypatch):
# A Space misconfigured to "local" on a CPU box must still boot.
monkeypatch.setattr(local_llm, "_TORCH_AVAILABLE", False)
client = make_llm(settings(llm_backend="local"))
assert isinstance(client, NullLLM)
def test_local_without_torch_falls_back_to_remote_when_possible(self, monkeypatch):
from finbot.llm import LLMClient
monkeypatch.setattr(local_llm, "_TORCH_AVAILABLE", False)
client = make_llm(settings(llm_backend="local", hf_token="hf_x"))
assert isinstance(client, LLMClient)
class TestLocalClient:
def _client(self, monkeypatch, output):
monkeypatch.setattr(local_llm, "_TORCH_AVAILABLE", True)
calls = []
def fake_generate(model_id, messages, max_new_tokens):
calls.append((model_id, messages, max_new_tokens))
return output.pop(0) if isinstance(output, list) else output
monkeypatch.setattr(local_llm, "generate_text", fake_generate)
client = local_llm.LocalLLMClient(settings(llm_backend="local"))
return client, calls
def test_chat_returns_the_completion(self, monkeypatch):
client, calls = self._client(monkeypatch, "hello there")
assert client.chat([{"role": "user", "content": "hi"}]) == "hello there"
assert calls[0][0] == "Qwen/Qwen2.5-3B-Instruct"
def test_chat_json_parses(self, monkeypatch):
client, _ = self._client(monkeypatch, '{"amount": 250}')
assert client.chat_json("sys", "user") == {"amount": 250}
def test_chat_json_strips_code_fences(self, monkeypatch):
client, _ = self._client(monkeypatch, '```json\n{"amount": 250}\n```')
assert client.chat_json("sys", "user") == {"amount": 250}
def test_chat_json_pulls_json_out_of_prose(self, monkeypatch):
client, _ = self._client(
monkeypatch, 'Sure! Here you go: {"amount": 7} Hope that helps.'
)
assert client.chat_json("sys", "user") == {"amount": 7}
def test_chat_json_repairs_on_a_second_pass(self, monkeypatch):
# Small models drift out of JSON; one repair attempt is made.
client, calls = self._client(monkeypatch, ["not json at all", '{"ok": true}'])
assert client.chat_json("sys", "user") == {"ok": True}
assert len(calls) == 2
def test_repeats_the_json_instruction_in_the_user_turn(self, monkeypatch):
client, calls = self._client(monkeypatch, "{}")
client.chat_json("sys", "extract this")
assert "valid JSON only" in calls[0][1][-1]["content"]
def test_generation_failure_becomes_llm_unavailable(self, monkeypatch):
monkeypatch.setattr(local_llm, "_TORCH_AVAILABLE", True)
def boom(*_a, **_k):
raise RuntimeError("CUDA out of memory")
monkeypatch.setattr(local_llm, "generate_text", boom)
client = local_llm.LocalLLMClient(settings())
with pytest.raises(LLMUnavailable, match="local generation failed"):
client.chat([{"role": "user", "content": "x"}])
def test_disabled_client_raises_cleanly(self, monkeypatch):
monkeypatch.setattr(local_llm, "_TORCH_AVAILABLE", False)
client = local_llm.LocalLLMClient(settings())
assert client.enabled is False
with pytest.raises(LLMUnavailable):
client.chat([{"role": "user", "content": "x"}])
def test_vision_requires_a_configured_model(self, monkeypatch):
monkeypatch.setattr(local_llm, "_TORCH_AVAILABLE", True)
client = local_llm.LocalLLMClient(settings(local_vision_model=""))
with pytest.raises(LLMUnavailable, match="no local vision model"):
client.vision_json("s", "u", b"jpeg")
def test_vision_parses_json(self, monkeypatch):
monkeypatch.setattr(local_llm, "_TORCH_AVAILABLE", True)
monkeypatch.setattr(
local_llm,
"generate_vision",
lambda *a, **k: '{"amount": 480, "currency": "INR"}',
)
client = local_llm.LocalLLMClient(settings())
assert client.vision_json("s", "u", b"jpeg")["amount"] == 480
def test_describe_mentions_the_model(self, monkeypatch):
monkeypatch.setattr(local_llm, "_TORCH_AVAILABLE", True)
assert "Qwen2.5-3B" in local_llm.LocalLLMClient(settings()).describe()
class TestGpuDecorator:
def test_decorator_is_a_noop_without_the_spaces_package(self):
# Off ZeroGPU the wrapper must return the function untouched, so the
# same code path runs locally.
if local_llm._SPACES_AVAILABLE:
pytest.skip("spaces installed; no-op path not applicable")
def fn(x):
return x * 2
assert local_llm._gpu(5)(fn)(3) == 6
def test_gpu_entrypoints_exist_at_module_level(self):
# ZeroGPU scans for these at startup; if they stop being module-level
# functions the Space silently fails to boot.
assert callable(local_llm.generate_text)
assert callable(local_llm.generate_vision)
|