Files
Edu/services/ai/tests/test_providers.py
2026-07-10 18:57:39 +08:00

584 lines
22 KiB
Python

"""LLM Provider 适配器测试.
使用 httpx.MockTransport mock HTTP 响应,验证 4 个 Provider 的
chat / embed / 流式解析行为,以及 create_failover_chain 工厂与
ProviderFailoverChain.embed 故障切换。
"""
import contextlib
from collections.abc import AsyncGenerator, Callable, Iterator
from typing import Any
from unittest.mock import patch
import httpx
import pytest
from src.ai.config import Settings
from src.ai.errors import AILLMUnavailableError
from src.ai.providers import (
LLMProvider,
LLMResponse,
LLMStreamChunk,
ProviderFailoverChain,
create_failover_chain,
)
from src.ai.providers.anthropic_provider import AnthropicProvider
from src.ai.providers.baichuan_provider import BaichuanProvider
from src.ai.providers.circuit_breaker import CircuitBreaker
from src.ai.providers.ollama_provider import LocalOllamaProvider
from src.ai.providers.openai_provider import OpenAIProvider
from .conftest import MockProvider
# Capture the real AsyncClient before any patching to avoid recursion
# (patch replaces httpx.AsyncClient globally via the shared module object).
_REAL_ASYNC_CLIENT = httpx.AsyncClient
# ---------------------------------------------------------------------------
# Mock transport handlers
# ---------------------------------------------------------------------------
def _openai_chat_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"choices": [{"message": {"content": "hello world"}}],
"usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15},
"model": "gpt-4o",
},
)
def _openai_embed_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"data": [{"embedding": [0.1, 0.2, 0.3]}]})
def _empty_embed_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"data": []})
def _anthropic_chat_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"content": [{"type": "text", "text": "hello from claude"}],
"usage": {"input_tokens": 5, "output_tokens": 10},
"model": "claude-3-5-sonnet-20241022",
},
)
def _baichuan_chat_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"choices": [{"message": {"content": "baichuan response"}}],
"usage": {"prompt_tokens": 3, "completion_tokens": 7, "total_tokens": 10},
"model": "Baichuan2-53B",
},
)
def _ollama_chat_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"message": {"role": "assistant", "content": "ollama response"},
"prompt_eval_count": 4,
"eval_count": 8,
"model": "llama3",
},
)
def _ollama_embed_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"embedding": [0.5, 0.6]})
def _server_error_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="internal server error")
def _network_error_handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("connection refused", request=request)
@contextlib.contextmanager
def _mock_http(
module_path: str,
handler: Callable[[httpx.Request], httpx.Response],
) -> Iterator[None]:
"""Patch httpx.AsyncClient in a provider module to use a MockTransport.
Providers create ``httpx.AsyncClient`` internally; patching the shared
``httpx`` module attribute makes the mock transport take effect. The real
class is captured up-front to avoid infinite recursion.
"""
transport = httpx.MockTransport(handler)
with patch(
module_path,
lambda **kwargs: _REAL_ASYNC_CLIENT(transport=transport, **kwargs),
):
yield
# ---------------------------------------------------------------------------
# Helper provider for failover-chain embed tests
# ---------------------------------------------------------------------------
class _EmbeddableProvider(LLMProvider):
"""Provider with embed support for failover chain tests."""
def __init__(
self,
name: str,
embedding: list[float],
fail: bool = False,
) -> None:
self._name = name
self._embedding = embedding
self._fail = fail
@property
def name(self) -> str:
return self._name
def is_available(self) -> bool:
return True
async def chat(
self,
messages: list[dict[str, str]],
model: str,
temperature: float = 0.7,
**kwargs: Any,
) -> LLMResponse:
return LLMResponse(content="", model=model, provider=self._name)
async def stream_chat(
self,
messages: list[dict[str, str]],
model: str,
temperature: float = 0.7,
**kwargs: Any,
) -> AsyncGenerator[LLMStreamChunk, None]:
yield LLMStreamChunk(delta="", model=model, provider=self._name)
async def embed(self, text: str, model: str) -> list[float]:
if self._fail:
raise AILLMUnavailableError(f"{self._name} embed failed")
return self._embedding
# ---------------------------------------------------------------------------
# OpenAIProvider
# ---------------------------------------------------------------------------
class TestOpenAIProvider:
_MODULE = "src.ai.providers.openai_provider.httpx.AsyncClient"
def test_name(self) -> None:
assert OpenAIProvider(api_key="test-key").name == "openai"
def test_is_available_true(self) -> None:
assert OpenAIProvider(api_key="test-key").is_available() is True
def test_is_available_false(self) -> None:
assert OpenAIProvider(api_key="").is_available() is False
async def test_chat_success(self) -> None:
with _mock_http(self._MODULE, _openai_chat_handler):
provider = OpenAIProvider(api_key="test-key")
result = await provider.chat(
[{"role": "user", "content": "hi"}], "gpt-4o",
)
assert result.content == "hello world"
assert result.provider == "openai"
assert result.model == "gpt-4o"
assert result.usage == {
"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15,
}
async def test_chat_not_configured_raises(self) -> None:
provider = OpenAIProvider(api_key="")
with pytest.raises(AILLMUnavailableError):
await provider.chat([{"role": "user", "content": "hi"}], "gpt-4o")
async def test_chat_http_error_raises(self) -> None:
with _mock_http(self._MODULE, _server_error_handler):
provider = OpenAIProvider(api_key="test-key")
with pytest.raises(AILLMUnavailableError):
await provider.chat([{"role": "user", "content": "hi"}], "gpt-4o")
async def test_chat_network_error_raises(self) -> None:
with _mock_http(self._MODULE, _network_error_handler):
provider = OpenAIProvider(api_key="test-key")
with pytest.raises(AILLMUnavailableError):
await provider.chat([{"role": "user", "content": "hi"}], "gpt-4o")
def test_parse_sse_line_empty(self) -> None:
assert OpenAIProvider._parse_sse_line("", "gpt-4o") is None
def test_parse_sse_line_non_data(self) -> None:
assert OpenAIProvider._parse_sse_line("event: ping", "gpt-4o") is None
def test_parse_sse_line_done(self) -> None:
chunk = OpenAIProvider._parse_sse_line("data: [DONE]", "gpt-4o")
assert chunk is not None
assert chunk.finish_reason == "stop"
assert chunk.delta == ""
def test_parse_sse_line_content(self) -> None:
line = 'data: {"choices":[{"delta":{"content":"hello"}}]}'
chunk = OpenAIProvider._parse_sse_line(line, "gpt-4o")
assert chunk is not None
assert chunk.delta == "hello"
assert chunk.finish_reason is None
assert chunk.provider == "openai"
def test_parse_sse_line_finish_reason(self) -> None:
line = 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}'
chunk = OpenAIProvider._parse_sse_line(line, "gpt-4o")
assert chunk is not None
assert chunk.finish_reason == "stop"
def test_parse_sse_line_invalid_json(self) -> None:
assert OpenAIProvider._parse_sse_line("data: {invalid}", "gpt-4o") is None
def test_parse_sse_line_no_choices(self) -> None:
assert OpenAIProvider._parse_sse_line('data: {"choices":[]}', "gpt-4o") is None
async def test_embed_success(self) -> None:
with _mock_http(self._MODULE, _openai_embed_handler):
provider = OpenAIProvider(api_key="test-key")
result = await provider.embed("hello", "text-embedding-3-small")
assert result == [0.1, 0.2, 0.3]
async def test_embed_not_configured_raises(self) -> None:
provider = OpenAIProvider(api_key="")
with pytest.raises(AILLMUnavailableError):
await provider.embed("hello", "text-embedding-3-small")
async def test_embed_empty_response(self) -> None:
with _mock_http(self._MODULE, _empty_embed_handler):
provider = OpenAIProvider(api_key="test-key")
result = await provider.embed("hello", "text-embedding-3-small")
assert result == []
async def test_embed_http_error_raises(self) -> None:
with _mock_http(self._MODULE, _server_error_handler):
provider = OpenAIProvider(api_key="test-key")
with pytest.raises(AILLMUnavailableError):
await provider.embed("hello", "text-embedding-3-small")
# ---------------------------------------------------------------------------
# AnthropicProvider
# ---------------------------------------------------------------------------
class TestAnthropicProvider:
_MODULE = "src.ai.providers.anthropic_provider.httpx.AsyncClient"
def test_name(self) -> None:
assert AnthropicProvider(api_key="test-key").name == "anthropic"
def test_is_available_true(self) -> None:
assert AnthropicProvider(api_key="test-key").is_available() is True
def test_is_available_false(self) -> None:
assert AnthropicProvider(api_key="").is_available() is False
async def test_chat_success(self) -> None:
with _mock_http(self._MODULE, _anthropic_chat_handler):
provider = AnthropicProvider(api_key="test-key")
result = await provider.chat(
[{"role": "user", "content": "hi"}], "claude-3-5-sonnet-20241022",
)
assert result.content == "hello from claude"
assert result.provider == "anthropic"
assert result.model == "claude-3-5-sonnet-20241022"
assert result.usage == {
"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15,
}
async def test_chat_not_configured_raises(self) -> None:
provider = AnthropicProvider(api_key="")
with pytest.raises(AILLMUnavailableError):
await provider.chat([{"role": "user", "content": "hi"}], "claude-3")
async def test_chat_http_error_raises(self) -> None:
with _mock_http(self._MODULE, _server_error_handler):
provider = AnthropicProvider(api_key="test-key")
with pytest.raises(AILLMUnavailableError):
await provider.chat([{"role": "user", "content": "hi"}], "claude-3")
async def test_chat_network_error_raises(self) -> None:
with _mock_http(self._MODULE, _network_error_handler):
provider = AnthropicProvider(api_key="test-key")
with pytest.raises(AILLMUnavailableError):
await provider.chat([{"role": "user", "content": "hi"}], "claude-3")
def test_parse_sse_line_empty(self) -> None:
assert AnthropicProvider._parse_sse_line("", "claude") is None
def test_parse_sse_line_non_data(self) -> None:
assert AnthropicProvider._parse_sse_line(
"event: content_block_delta", "claude",
) is None
def test_parse_sse_line_content_block_delta(self) -> None:
line = (
'data: {"type":"content_block_delta",'
'"delta":{"type":"text_delta","text":"hello"}}'
)
chunk = AnthropicProvider._parse_sse_line(line, "claude")
assert chunk is not None
assert chunk.delta == "hello"
assert chunk.provider == "anthropic"
def test_parse_sse_line_message_stop(self) -> None:
chunk = AnthropicProvider._parse_sse_line(
'data: {"type":"message_stop"}', "claude",
)
assert chunk is not None
assert chunk.finish_reason == "end_turn"
def test_parse_sse_line_invalid_json(self) -> None:
assert AnthropicProvider._parse_sse_line("data: {bad}", "claude") is None
def test_convert_messages(self) -> None:
provider = AnthropicProvider(api_key="test-key")
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "system", "content": "Be concise."},
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello!"},
]
system_prompt, converted = provider._convert_messages(messages)
assert system_prompt == "You are helpful.\n\nBe concise."
assert len(converted) == 2
assert converted[0] == {"role": "user", "content": "Hi"}
assert converted[1] == {"role": "assistant", "content": "Hello!"}
def test_convert_messages_no_system(self) -> None:
provider = AnthropicProvider(api_key="test-key")
system_prompt, converted = provider._convert_messages(
[{"role": "user", "content": "Hi"}],
)
assert system_prompt == ""
assert len(converted) == 1
assert converted[0] == {"role": "user", "content": "Hi"}
# ---------------------------------------------------------------------------
# BaichuanProvider
# ---------------------------------------------------------------------------
class TestBaichuanProvider:
_MODULE = "src.ai.providers.baichuan_provider.httpx.AsyncClient"
def test_name(self) -> None:
assert BaichuanProvider(api_key="test-key").name == "baichuan"
def test_is_available_true(self) -> None:
assert BaichuanProvider(api_key="test-key").is_available() is True
def test_is_available_false(self) -> None:
assert BaichuanProvider(api_key="").is_available() is False
async def test_chat_success(self) -> None:
with _mock_http(self._MODULE, _baichuan_chat_handler):
provider = BaichuanProvider(api_key="test-key")
result = await provider.chat(
[{"role": "user", "content": "hi"}], "Baichuan2-53B",
)
assert result.content == "baichuan response"
assert result.provider == "baichuan"
assert result.usage == {
"prompt_tokens": 3, "completion_tokens": 7, "total_tokens": 10,
}
async def test_chat_not_configured_raises(self) -> None:
provider = BaichuanProvider(api_key="")
with pytest.raises(AILLMUnavailableError):
await provider.chat([{"role": "user", "content": "hi"}], "Baichuan2")
async def test_chat_http_error_raises(self) -> None:
with _mock_http(self._MODULE, _server_error_handler):
provider = BaichuanProvider(api_key="test-key")
with pytest.raises(AILLMUnavailableError):
await provider.chat([{"role": "user", "content": "hi"}], "Baichuan2")
async def test_chat_network_error_raises(self) -> None:
with _mock_http(self._MODULE, _network_error_handler):
provider = BaichuanProvider(api_key="test-key")
with pytest.raises(AILLMUnavailableError):
await provider.chat([{"role": "user", "content": "hi"}], "Baichuan2")
def test_parse_sse_line_done(self) -> None:
# Baichuan reuses OpenAI SSE parser (compatible format)
chunk = OpenAIProvider._parse_sse_line("data: [DONE]", "Baichuan2")
assert chunk is not None
assert chunk.finish_reason == "stop"
def test_parse_sse_line_content(self) -> None:
line = 'data: {"choices":[{"delta":{"content":"hi"}}]}'
chunk = OpenAIProvider._parse_sse_line(line, "Baichuan2")
assert chunk is not None
assert chunk.delta == "hi"
async def test_embed_not_implemented(self) -> None:
provider = BaichuanProvider(api_key="test-key")
with pytest.raises(NotImplementedError):
await provider.embed("hello", "any-model")
# ---------------------------------------------------------------------------
# LocalOllamaProvider
# ---------------------------------------------------------------------------
class TestOllamaProvider:
_MODULE = "src.ai.providers.ollama_provider.httpx.AsyncClient"
def test_name(self) -> None:
assert LocalOllamaProvider().name == "local_ollama"
def test_is_available_true(self) -> None:
provider = LocalOllamaProvider(base_url="http://localhost:11434")
assert provider.is_available() is True
def test_is_available_false(self) -> None:
assert LocalOllamaProvider(base_url="").is_available() is False
async def test_chat_success(self) -> None:
with _mock_http(self._MODULE, _ollama_chat_handler):
provider = LocalOllamaProvider()
result = await provider.chat(
[{"role": "user", "content": "hi"}], "llama3",
)
assert result.content == "ollama response"
assert result.provider == "local_ollama"
assert result.model == "llama3"
assert result.usage == {
"prompt_tokens": 4, "completion_tokens": 8, "total_tokens": 12,
}
async def test_chat_not_configured_raises(self) -> None:
provider = LocalOllamaProvider(base_url="")
with pytest.raises(AILLMUnavailableError):
await provider.chat([{"role": "user", "content": "hi"}], "llama3")
async def test_chat_http_error_raises(self) -> None:
with _mock_http(self._MODULE, _server_error_handler):
provider = LocalOllamaProvider()
with pytest.raises(AILLMUnavailableError):
await provider.chat([{"role": "user", "content": "hi"}], "llama3")
async def test_chat_network_error_raises(self) -> None:
with _mock_http(self._MODULE, _network_error_handler):
provider = LocalOllamaProvider()
with pytest.raises(AILLMUnavailableError):
await provider.chat([{"role": "user", "content": "hi"}], "llama3")
def test_parse_ndjson_line_empty(self) -> None:
assert LocalOllamaProvider._parse_ndjson_line("", "llama3") is None
def test_parse_ndjson_line_done_true(self) -> None:
line = (
'{"model":"llama3","message":{"role":"assistant","content":""},'
'"done":true}'
)
chunk = LocalOllamaProvider._parse_ndjson_line(line, "llama3")
assert chunk is not None
assert chunk.finish_reason == "stop"
def test_parse_ndjson_line_done_false(self) -> None:
line = (
'{"model":"llama3","message":{"role":"assistant","content":"hi"},'
'"done":false}'
)
chunk = LocalOllamaProvider._parse_ndjson_line(line, "llama3")
assert chunk is not None
assert chunk.delta == "hi"
assert chunk.finish_reason is None
assert chunk.provider == "local_ollama"
def test_parse_ndjson_line_invalid_json(self) -> None:
assert LocalOllamaProvider._parse_ndjson_line("{invalid}", "llama3") is None
async def test_embed_success(self) -> None:
with _mock_http(self._MODULE, _ollama_embed_handler):
provider = LocalOllamaProvider()
result = await provider.embed("hello", "nomic-embed-text")
assert result == [0.5, 0.6]
async def test_embed_not_configured_raises(self) -> None:
provider = LocalOllamaProvider(base_url="")
with pytest.raises(AILLMUnavailableError):
await provider.embed("hello", "nomic-embed-text")
# ---------------------------------------------------------------------------
# create_failover_chain factory
# ---------------------------------------------------------------------------
class TestCreateFailoverChain:
def test_creates_chain_with_configured_providers(self) -> None:
settings = Settings(
_env_file=None,
openai_api_key="sk-openai",
anthropic_api_key="sk-anthropic",
baichuan_api_key="sk-baichuan",
ollama_base_url="http://localhost:11434",
llm_provider_priority="openai,anthropic,baichuan,local_ollama",
)
chain = create_failover_chain(settings)
names = [p.name for p in chain.providers]
assert names == ["openai", "anthropic", "baichuan", "local_ollama"]
def test_falls_back_to_openai_when_no_providers(self) -> None:
settings = Settings(_env_file=None, llm_provider_priority="")
chain = create_failover_chain(settings)
assert len(chain.providers) == 1
assert chain.providers[0].name == "openai"
def test_priority_order_respected(self) -> None:
settings = Settings(
_env_file=None,
openai_api_key="sk-openai",
anthropic_api_key="sk-anthropic",
llm_provider_priority="anthropic,openai",
)
chain = create_failover_chain(settings)
names = [p.name for p in chain.providers]
assert names == ["anthropic", "openai"]
# ---------------------------------------------------------------------------
# ProviderFailoverChain.embed
# ---------------------------------------------------------------------------
class TestFailoverChainEmbed:
async def test_embed_success(self) -> None:
provider = _EmbeddableProvider(name="p1", embedding=[0.5, 0.6])
chain = ProviderFailoverChain([provider], CircuitBreaker())
embedding, provider_name = await chain.embed("hello", "model")
assert embedding == [0.5, 0.6]
assert provider_name == "p1"
async def test_embed_no_provider_raises(self) -> None:
# MockProvider does not implement embed → NotImplementedError, skipped
provider = MockProvider(name="p1")
chain = ProviderFailoverChain([provider], CircuitBreaker())
with pytest.raises(AILLMUnavailableError):
await chain.embed("hello", "model")