Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

324 lines
11 KiB
Python

"""Unit tests for application/llm/anthropic.py — AnthropicLLM.
Extends coverage beyond test_anthropic_llm.py:
- Constructor: api_key priority, base_url support
- get_supported_attachment_types
- prepare_messages_with_attachments: various scenarios
- _get_base64_image: error paths
- _raw_gen_stream: close called on response
"""
import sys
import types
import pytest
# ---------------------------------------------------------------------------
# Fake anthropic module
# ---------------------------------------------------------------------------
class _FakeCompletion:
def __init__(self, text):
self.completion = text
class _FakeCompletions:
def __init__(self):
self.last_kwargs = None
self._stream_items = [_FakeCompletion("s1"), _FakeCompletion("s2")]
def create(self, **kwargs):
self.last_kwargs = kwargs
if kwargs.get("stream"):
return self._stream_items
return _FakeCompletion("final")
class _FakeAnthropic:
def __init__(self, api_key=None, base_url=None):
self.api_key = api_key
self.base_url = base_url
self.completions = _FakeCompletions()
@pytest.fixture(autouse=True)
def patch_anthropic(monkeypatch):
fake = types.ModuleType("anthropic")
fake.Anthropic = _FakeAnthropic
fake.HUMAN_PROMPT = "<HUMAN>"
fake.AI_PROMPT = "<AI>"
modules_to_remove = [key for key in sys.modules if key.startswith("anthropic")]
for key in modules_to_remove:
sys.modules.pop(key, None)
sys.modules["anthropic"] = fake
if "application.llm.anthropic" in sys.modules:
del sys.modules["application.llm.anthropic"]
yield
sys.modules.pop("anthropic", None)
if "application.llm.anthropic" in sys.modules:
del sys.modules["application.llm.anthropic"]
@pytest.fixture
def llm():
from application.llm.anthropic import AnthropicLLM
instance = AnthropicLLM(api_key="test-key")
instance.storage = types.SimpleNamespace(
get_file=lambda path: _ctx_manager(b"img_bytes"),
)
return instance
def _ctx_manager(data):
"""Create a simple context manager returning an object with .read()."""
import contextlib
@contextlib.contextmanager
def cm():
yield types.SimpleNamespace(read=lambda: data)
return cm()
# ---------------------------------------------------------------------------
# Constructor
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestAnthropicConstructor:
def test_api_key_set(self):
from application.llm.anthropic import AnthropicLLM
instance = AnthropicLLM(api_key="custom-key")
assert instance.api_key == "custom-key"
def test_base_url_passed(self):
from application.llm.anthropic import AnthropicLLM
instance = AnthropicLLM(api_key="k", base_url="https://custom.api")
assert instance.anthropic.base_url == "https://custom.api"
def test_no_base_url(self):
from application.llm.anthropic import AnthropicLLM
instance = AnthropicLLM(api_key="k")
assert instance.anthropic.base_url is None
def test_human_and_ai_prompts_set(self):
from application.llm.anthropic import AnthropicLLM
instance = AnthropicLLM(api_key="k")
assert instance.HUMAN_PROMPT == "<HUMAN>"
assert instance.AI_PROMPT == "<AI>"
# ---------------------------------------------------------------------------
# _raw_gen
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestRawGen:
def test_returns_completion(self, llm):
msgs = [{"content": "context"}, {"content": "question"}]
result = llm._raw_gen(llm, model="claude-2", messages=msgs)
assert result == "final"
def test_prompt_contains_context_and_question(self, llm):
msgs = [{"content": "my context"}, {"content": "my question"}]
llm._raw_gen(llm, model="claude-2", messages=msgs)
prompt = llm.anthropic.completions.last_kwargs["prompt"]
assert "my context" in prompt
assert "my question" in prompt
def test_max_tokens_passed(self, llm):
msgs = [{"content": "c"}, {"content": "q"}]
llm._raw_gen(llm, model="claude-2", messages=msgs, max_tokens=200)
assert llm.anthropic.completions.last_kwargs["max_tokens_to_sample"] == 200
# ---------------------------------------------------------------------------
# _raw_gen_stream
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestRawGenStream:
def test_yields_all_completions(self, llm):
msgs = [{"content": "c"}, {"content": "q"}]
chunks = list(
llm._raw_gen_stream(llm, model="claude", messages=msgs, max_tokens=10)
)
assert chunks == ["s1", "s2"]
def test_calls_close_on_response(self, llm):
closed = {"called": False}
original = llm.anthropic.completions._stream_items
class ClosableList(list):
def close(self):
closed["called"] = True
closable = ClosableList(original)
llm.anthropic.completions._stream_items = closable
llm.anthropic.completions.create = lambda **kw: closable
msgs = [{"content": "c"}, {"content": "q"}]
list(llm._raw_gen_stream(llm, model="claude", messages=msgs))
assert closed["called"]
def test_prompt_format(self, llm):
msgs = [{"content": "ctx"}, {"content": "q"}]
list(llm._raw_gen_stream(llm, model="claude", messages=msgs))
prompt = llm.anthropic.completions.last_kwargs["prompt"]
assert prompt.startswith("<HUMAN>")
assert prompt.endswith("<AI>")
# ---------------------------------------------------------------------------
# get_supported_attachment_types
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetSupportedAttachmentTypes:
def test_returns_image_types(self, llm):
result = llm.get_supported_attachment_types()
assert "image/png" in result
assert "image/jpeg" in result
assert "image/webp" in result
assert "image/gif" in result
def test_no_pdf_support(self, llm):
result = llm.get_supported_attachment_types()
assert "application/pdf" not in result
# ---------------------------------------------------------------------------
# prepare_messages_with_attachments
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestPrepareMessagesWithAttachments:
def test_no_attachments_returns_same(self, llm):
msgs = [{"role": "user", "content": "hi"}]
result = llm.prepare_messages_with_attachments(msgs)
assert result == msgs
def test_empty_attachments_returns_same(self, llm):
msgs = [{"role": "user", "content": "hi"}]
result = llm.prepare_messages_with_attachments(msgs, [])
assert result == msgs
def test_image_with_preconverted_data(self, llm):
msgs = [{"role": "user", "content": "look"}]
attachments = [{"mime_type": "image/png", "data": "AABBCC"}]
result = llm.prepare_messages_with_attachments(msgs, attachments)
user_msg = next(m for m in result if m["role"] == "user")
img_part = next(
p for p in user_msg["content"] if p.get("type") == "image"
)
assert img_part["source"]["data"] == "AABBCC"
assert img_part["source"]["type"] == "base64"
assert img_part["source"]["media_type"] == "image/png"
def test_image_from_storage(self, llm):
llm.storage = types.SimpleNamespace(
get_file=lambda p: _ctx_manager(b"raw_image_bytes"),
)
msgs = [{"role": "user", "content": "look"}]
attachments = [{"mime_type": "image/jpeg", "path": "/tmp/img.jpg"}]
result = llm.prepare_messages_with_attachments(msgs, attachments)
user_msg = next(m for m in result if m["role"] == "user")
img_part = next(
p for p in user_msg["content"] if p.get("type") == "image"
)
assert img_part["source"]["media_type"] == "image/jpeg"
assert len(img_part["source"]["data"]) > 0
def test_no_user_message_creates_one(self, llm):
msgs = [{"role": "system", "content": "sys"}]
attachments = [{"mime_type": "image/png", "data": "AAA"}]
result = llm.prepare_messages_with_attachments(msgs, attachments)
user_msgs = [m for m in result if m["role"] == "user"]
assert len(user_msgs) == 1
def test_image_error_adds_text_fallback(self, llm):
def bad_storage(path):
raise Exception("storage error")
llm.storage = types.SimpleNamespace(get_file=bad_storage)
msgs = [{"role": "user", "content": "look"}]
attachments = [
{"mime_type": "image/png", "path": "/bad.png", "content": "fb"},
]
result = llm.prepare_messages_with_attachments(msgs, attachments)
user_msg = next(m for m in result if m["role"] == "user")
text_parts = [
p for p in user_msg["content"]
if p.get("type") == "text" and "could not" in p.get("text", "").lower()
]
assert len(text_parts) == 1
def test_non_image_attachment_ignored(self, llm):
msgs = [{"role": "user", "content": "look"}]
attachments = [{"mime_type": "application/pdf"}]
result = llm.prepare_messages_with_attachments(msgs, attachments)
user_msg = next(m for m in result if m["role"] == "user")
# content becomes list with just original text
assert isinstance(user_msg["content"], list)
assert len(user_msg["content"]) == 1
def test_content_not_list_becomes_empty(self, llm):
msgs = [{"role": "user", "content": 999}]
attachments = [{"mime_type": "image/png", "data": "AAA"}]
result = llm.prepare_messages_with_attachments(msgs, attachments)
user_msg = next(m for m in result if m["role"] == "user")
assert isinstance(user_msg["content"], list)
# ---------------------------------------------------------------------------
# _get_base64_image
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetBase64Image:
def test_raises_for_no_path(self, llm):
with pytest.raises(ValueError, match="No file path"):
llm._get_base64_image({})
def test_raises_for_file_not_found(self, llm):
import contextlib
@contextlib.contextmanager
def bad_file(path):
raise FileNotFoundError("not found")
llm.storage = types.SimpleNamespace(get_file=bad_file)
with pytest.raises(FileNotFoundError):
llm._get_base64_image({"path": "/nonexistent"})
def test_returns_base64_encoded(self, llm):
import base64
llm.storage = types.SimpleNamespace(
get_file=lambda p: _ctx_manager(b"test_data"),
)
result = llm._get_base64_image({"path": "/tmp/img.png"})
decoded = base64.b64decode(result)
assert decoded == b"test_data"