584 lines
22 KiB
Python
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")
|