fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
205 lines
6.4 KiB
Python
205 lines
6.4 KiB
Python
"""Unit tests for application/llm/base.py — BaseLLM.
|
|
|
|
Covers initialisation, static helpers, supports_* introspection,
|
|
structured-output defaults, and attachment-type defaults.
|
|
Fallback behaviour is covered separately in test_fallback.py.
|
|
"""
|
|
|
|
from unittest.mock import MagicMock, Mock
|
|
|
|
import pytest
|
|
|
|
from application.llm.base import BaseLLM
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Concrete stub so we can instantiate the abstract base
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class StubLLM(BaseLLM):
|
|
"""Minimal concrete BaseLLM for unit-testing non-abstract members."""
|
|
|
|
def _raw_gen(self, baseself, model, messages, stream, tools=None, **kw):
|
|
return "raw_gen_result"
|
|
|
|
def _raw_gen_stream(self, baseself, model, messages, stream, tools=None, **kw):
|
|
yield "chunk"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# __init__
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestBaseLLMInit:
|
|
|
|
def test_defaults(self):
|
|
llm = StubLLM()
|
|
assert llm.decoded_token is None
|
|
assert llm.agent_id is None
|
|
assert llm.model_id is None
|
|
assert llm.base_url is None
|
|
assert llm.token_usage == {"prompt_tokens": 0, "generated_tokens": 0}
|
|
assert llm._backup_models == []
|
|
assert llm._fallback_llm is None
|
|
|
|
def test_agent_id_cast_to_str(self):
|
|
llm = StubLLM(agent_id=42)
|
|
assert llm.agent_id == "42"
|
|
|
|
def test_agent_id_none_stays_none(self):
|
|
llm = StubLLM(agent_id=None)
|
|
assert llm.agent_id is None
|
|
|
|
def test_custom_params(self):
|
|
token = {"sub": "u1"}
|
|
llm = StubLLM(
|
|
decoded_token=token,
|
|
agent_id="abc",
|
|
model_id="gpt-4",
|
|
base_url="http://x",
|
|
backup_models=["m1", "m2"],
|
|
)
|
|
assert llm.decoded_token is token
|
|
assert llm.agent_id == "abc"
|
|
assert llm.model_id == "gpt-4"
|
|
assert llm.base_url == "http://x"
|
|
assert llm._backup_models == ["m1", "m2"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _remove_null_values
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestRemoveNullValues:
|
|
|
|
def test_removes_none_values(self):
|
|
result = BaseLLM._remove_null_values({"a": 1, "b": None, "c": "x"})
|
|
assert result == {"a": 1, "c": "x"}
|
|
|
|
def test_keeps_falsy_non_none(self):
|
|
result = BaseLLM._remove_null_values({"a": 0, "b": "", "c": False, "d": []})
|
|
assert result == {"a": 0, "b": "", "c": False, "d": []}
|
|
|
|
def test_non_dict_passthrough(self):
|
|
assert BaseLLM._remove_null_values("hello") == "hello"
|
|
assert BaseLLM._remove_null_values(42) == 42
|
|
assert BaseLLM._remove_null_values([1, 2]) == [1, 2]
|
|
|
|
def test_empty_dict(self):
|
|
assert BaseLLM._remove_null_values({}) == {}
|
|
|
|
def test_all_none(self):
|
|
assert BaseLLM._remove_null_values({"a": None, "b": None}) == {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# supports_tools / _supports_tools
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSupportsTools:
|
|
|
|
def test_supports_tools_true_when_callable(self):
|
|
llm = StubLLM()
|
|
assert llm.supports_tools() is True
|
|
|
|
def test_supports_tools_false_when_not_callable(self):
|
|
llm = StubLLM()
|
|
llm._supports_tools = "not_callable"
|
|
assert llm.supports_tools() is False
|
|
|
|
def test_default_supports_tools_raises(self):
|
|
llm = StubLLM()
|
|
with pytest.raises(NotImplementedError):
|
|
llm._supports_tools()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# supports_structured_output / _supports_structured_output
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSupportsStructuredOutput:
|
|
|
|
def test_supports_structured_output_true(self):
|
|
llm = StubLLM()
|
|
assert llm.supports_structured_output() is True
|
|
|
|
def test_default_supports_structured_output_returns_false(self):
|
|
llm = StubLLM()
|
|
assert llm._supports_structured_output() is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# prepare_structured_output_format
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPrepareStructuredOutputFormat:
|
|
|
|
def test_returns_none_by_default(self):
|
|
llm = StubLLM()
|
|
assert llm.prepare_structured_output_format({"type": "object"}) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_supported_attachment_types
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestGetSupportedAttachmentTypes:
|
|
|
|
def test_returns_empty_list(self):
|
|
llm = StubLLM()
|
|
assert llm.get_supported_attachment_types() == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fallback_llm — caching
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestFallbackLLMCaching:
|
|
|
|
def test_returns_cached_instance(self, monkeypatch):
|
|
"""Once resolved, the same fallback instance is returned."""
|
|
sentinel = StubLLM()
|
|
llm = StubLLM()
|
|
llm._fallback_llm = sentinel
|
|
assert llm.fallback_llm is sentinel
|
|
|
|
def test_none_when_no_backup_and_no_global(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"application.llm.base.settings",
|
|
MagicMock(FALLBACK_LLM_PROVIDER=None),
|
|
)
|
|
llm = StubLLM(backup_models=[])
|
|
assert llm.fallback_llm is None
|
|
|
|
def test_global_fallback_init_failure_returns_none(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"application.llm.base.settings",
|
|
MagicMock(
|
|
FALLBACK_LLM_PROVIDER="openai",
|
|
FALLBACK_LLM_NAME="gpt-4",
|
|
FALLBACK_LLM_API_KEY="k",
|
|
API_KEY="k",
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
"application.llm.llm_creator.LLMCreator.create_llm",
|
|
Mock(side_effect=RuntimeError("boom")),
|
|
)
|
|
llm = StubLLM(backup_models=[])
|
|
assert llm.fallback_llm is None
|