fischer-agentkit/tests/unit/test_anthropic_provider.py

831 lines
30 KiB
Python

"""Anthropic Provider 测试"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from pytest_httpx import HTTPXMock
from agentkit.core.exceptions import LLMProviderError
from agentkit.llm.protocol import LLMRequest, LLMResponse, StreamChunk, TokenUsage
from agentkit.llm.providers.anthropic import AnthropicProvider
class TestAnthropicMessageConversion:
"""消息格式转换测试"""
def setup_method(self):
self.provider = AnthropicProvider(api_key="test-key")
def test_system_message_extracted_as_top_level(self):
"""system 消息应被提取为顶层 system 参数"""
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello"},
]
system, anthropic_msgs = self.provider._convert_messages(messages)
assert system == "You are a helpful assistant."
assert len(anthropic_msgs) == 1
assert anthropic_msgs[0]["role"] == "user"
assert anthropic_msgs[0]["content"] == [{"type": "text", "text": "Hello"}]
def test_text_messages_converted_to_content_blocks(self):
"""普通文本消息应转换为 content blocks"""
messages = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello!"},
{"role": "user", "content": "How are you?"},
]
system, anthropic_msgs = self.provider._convert_messages(messages)
assert system is None
assert len(anthropic_msgs) == 3
assert anthropic_msgs[0] == {"role": "user", "content": [{"type": "text", "text": "Hi"}]}
assert anthropic_msgs[1] == {"role": "assistant", "content": [{"type": "text", "text": "Hello!"}]}
assert anthropic_msgs[2] == {"role": "user", "content": [{"type": "text", "text": "How are you?"}]}
def test_assistant_tool_calls_converted(self):
"""assistant 的 tool_calls 应转换为 tool_use content blocks"""
messages = [
{"role": "user", "content": "What's the weather?"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "Beijing"}',
},
}
],
},
]
system, anthropic_msgs = self.provider._convert_messages(messages)
assert len(anthropic_msgs) == 2
assistant_msg = anthropic_msgs[1]
assert assistant_msg["role"] == "assistant"
assert len(assistant_msg["content"]) == 1
assert assistant_msg["content"][0]["type"] == "tool_use"
assert assistant_msg["content"][0]["id"] == "call_123"
assert assistant_msg["content"][0]["name"] == "get_weather"
assert assistant_msg["content"][0]["input"] == {"city": "Beijing"}
def test_assistant_tool_calls_with_text(self):
"""assistant 同时有文本和 tool_calls"""
messages = [
{
"role": "assistant",
"content": "Let me check that.",
"tool_calls": [
{
"id": "call_456",
"type": "function",
"function": {
"name": "search",
"arguments": '{"q": "test"}',
},
}
],
},
]
_, anthropic_msgs = self.provider._convert_messages(messages)
content = anthropic_msgs[0]["content"]
assert len(content) == 2
assert content[0]["type"] == "text"
assert content[0]["text"] == "Let me check that."
assert content[1]["type"] == "tool_use"
def test_tool_result_converted(self):
"""tool 角色消息应转换为 tool_result content blocks"""
messages = [
{
"role": "tool",
"tool_call_id": "call_123",
"content": "Sunny, 25°C",
},
]
_, anthropic_msgs = self.provider._convert_messages(messages)
assert len(anthropic_msgs) == 1
msg = anthropic_msgs[0]
assert msg["role"] == "user"
assert len(msg["content"]) == 1
assert msg["content"][0]["type"] == "tool_result"
assert msg["content"][0]["tool_use_id"] == "call_123"
assert msg["content"][0]["content"] == [{"type": "text", "text": "Sunny, 25°C"}]
def test_user_with_tool_call_id_converted(self):
"""user 消息带 tool_call_id 也应转换为 tool_result"""
messages = [
{
"role": "user",
"tool_call_id": "call_789",
"content": "Result data",
},
]
_, anthropic_msgs = self.provider._convert_messages(messages)
msg = anthropic_msgs[0]
assert msg["role"] == "user"
assert msg["content"][0]["type"] == "tool_result"
assert msg["content"][0]["tool_use_id"] == "call_789"
def test_no_system_message(self):
"""没有 system 消息时返回 None"""
messages = [
{"role": "user", "content": "Hello"},
]
system, _ = self.provider._convert_messages(messages)
assert system is None
class TestAnthropicToolConversion:
"""工具格式转换测试"""
def setup_method(self):
self.provider = AnthropicProvider(api_key="test-key")
def test_convert_openai_tools_to_anthropic(self):
"""OpenAI function 格式应转换为 Anthropic tool 格式"""
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
},
},
}
]
result = self.provider._convert_tools(tools)
assert len(result) == 1
assert result[0]["name"] == "get_weather"
assert result[0]["description"] == "Get weather for a city"
assert result[0]["input_schema"] == {
"type": "object",
"properties": {"city": {"type": "string"}},
}
def test_convert_tool_choice_auto(self):
"""tool_choice=auto 应转换为 Anthropic 格式"""
result = self.provider._convert_tool_choice("auto")
assert result == {"type": "auto"}
def test_convert_tool_choice_required(self):
"""tool_choice=required 应转换为 Anthropic any 格式"""
result = self.provider._convert_tool_choice("required")
assert result == {"type": "any"}
def test_convert_tool_choice_specific_tool(self):
"""指定工具名的 tool_choice 应转换为 Anthropic tool 格式"""
result = self.provider._convert_tool_choice("get_weather")
assert result == {"type": "tool", "name": "get_weather"}
def test_convert_tool_choice_none(self):
"""tool_choice=none 应返回 None"""
result = self.provider._convert_tool_choice("none")
assert result is None
class TestAnthropicResponseParsing:
"""响应解析测试"""
def setup_method(self):
self.provider = AnthropicProvider(api_key="test-key")
def test_parse_text_response(self):
"""解析纯文本响应"""
data = {
"id": "msg_123",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-20250514",
"content": [
{"type": "text", "text": "Hello! How can I help?"}
],
"usage": {"input_tokens": 10, "output_tokens": 6},
}
response = self.provider._parse_response(data, "claude-sonnet-4-20250514")
assert isinstance(response, LLMResponse)
assert response.content == "Hello! How can I help?"
assert response.model == "claude-sonnet-4-20250514"
assert response.usage.prompt_tokens == 10
assert response.usage.completion_tokens == 6
assert not response.has_tool_calls
def test_parse_tool_use_response(self):
"""解析包含 tool_use 的响应"""
data = {
"id": "msg_456",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-20250514",
"content": [
{"type": "text", "text": "Let me check the weather."},
{
"type": "tool_use",
"id": "toolu_123",
"name": "get_weather",
"input": {"city": "Beijing"},
},
],
"usage": {"input_tokens": 20, "output_tokens": 15},
}
response = self.provider._parse_response(data, "claude-sonnet-4-20250514")
assert response.content == "Let me check the weather."
assert response.has_tool_calls
assert len(response.tool_calls) == 1
assert response.tool_calls[0].id == "toolu_123"
assert response.tool_calls[0].name == "get_weather"
assert response.tool_calls[0].arguments == {"city": "Beijing"}
def test_parse_multiple_tool_uses(self):
"""解析包含多个 tool_use 的响应"""
data = {
"id": "msg_789",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-20250514",
"content": [
{
"type": "tool_use",
"id": "toolu_1",
"name": "get_weather",
"input": {"city": "Beijing"},
},
{
"type": "tool_use",
"id": "toolu_2",
"name": "get_weather",
"input": {"city": "Shanghai"},
},
],
"usage": {"input_tokens": 25, "output_tokens": 20},
}
response = self.provider._parse_response(data, "claude-sonnet-4-20250514")
assert len(response.tool_calls) == 2
assert response.tool_calls[0].name == "get_weather"
assert response.tool_calls[0].arguments == {"city": "Beijing"}
assert response.tool_calls[1].arguments == {"city": "Shanghai"}
class TestAnthropicChat:
"""chat() 方法集成测试"""
async def test_chat_returns_llm_response(self, httpx_mock: HTTPXMock):
"""chat 应返回 LLMResponse"""
httpx_mock.add_response(
url="https://api.anthropic.com/v1/messages",
json={
"id": "msg_001",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-20250514",
"content": [{"type": "text", "text": "Hello from Claude!"}],
"usage": {"input_tokens": 10, "output_tokens": 5},
"stop_reason": "end_turn",
},
)
provider = AnthropicProvider(api_key="test-key")
request = LLMRequest(
messages=[{"role": "user", "content": "Hi"}],
model="claude-sonnet-4-20250514",
)
response = await provider.chat(request)
assert isinstance(response, LLMResponse)
assert response.content == "Hello from Claude!"
assert response.model == "claude-sonnet-4-20250514"
assert response.usage.prompt_tokens == 10
assert response.usage.completion_tokens == 5
assert response.latency_ms > 0
async def test_chat_with_system_message(self, httpx_mock: HTTPXMock):
"""system 消息应作为顶层参数发送"""
httpx_mock.add_response(
url="https://api.anthropic.com/v1/messages",
json={
"id": "msg_002",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-20250514",
"content": [{"type": "text", "text": "I am a helpful assistant."}],
"usage": {"input_tokens": 15, "output_tokens": 8},
"stop_reason": "end_turn",
},
)
provider = AnthropicProvider(api_key="test-key")
request = LLMRequest(
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Who are you?"},
],
model="claude-sonnet-4-20250514",
)
response = await provider.chat(request)
assert response.content == "I am a helpful assistant."
# Verify the request payload
request_body = json.loads(httpx_mock.get_requests()[-1].content)
assert "system" in request_body
assert request_body["system"] == "You are a helpful assistant."
# System should NOT be in messages
for msg in request_body["messages"]:
assert msg["role"] != "system"
async def test_chat_with_tools(self, httpx_mock: HTTPXMock):
"""带工具的请求应正确转换格式"""
httpx_mock.add_response(
url="https://api.anthropic.com/v1/messages",
json={
"id": "msg_003",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-20250514",
"content": [
{
"type": "tool_use",
"id": "toolu_001",
"name": "get_weather",
"input": {"city": "Tokyo"},
}
],
"usage": {"input_tokens": 30, "output_tokens": 20},
"stop_reason": "tool_use",
},
)
provider = AnthropicProvider(api_key="test-key")
request = LLMRequest(
messages=[{"role": "user", "content": "Weather in Tokyo?"}],
model="claude-sonnet-4-20250514",
tools=[
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
},
},
}
],
)
response = await provider.chat(request)
assert response.has_tool_calls
assert response.tool_calls[0].name == "get_weather"
assert response.tool_calls[0].arguments == {"city": "Tokyo"}
# Verify request format
request_body = json.loads(httpx_mock.get_requests()[-1].content)
assert "tools" in request_body
assert request_body["tools"][0]["name"] == "get_weather"
assert "input_schema" in request_body["tools"][0]
assert "tool_choice" in request_body
assert request_body["tool_choice"] == {"type": "auto"}
async def test_chat_sends_correct_headers(self, httpx_mock: HTTPXMock):
"""验证请求头包含正确的 Anthropic 认证信息"""
httpx_mock.add_response(
url="https://api.anthropic.com/v1/messages",
json={
"id": "msg_004",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-20250514",
"content": [{"type": "text", "text": "OK"}],
"usage": {"input_tokens": 5, "output_tokens": 2},
"stop_reason": "end_turn",
},
)
provider = AnthropicProvider(api_key="sk-ant-test-key")
request = LLMRequest(
messages=[{"role": "user", "content": "Hi"}],
model="claude-sonnet-4-20250514",
)
await provider.chat(request)
sent_request = httpx_mock.get_requests()[-1]
assert sent_request.headers.get("x-api-key") == "sk-ant-test-key"
assert sent_request.headers.get("anthropic-version") == "2023-06-01"
assert sent_request.headers.get("content-type") == "application/json"
async def test_chat_with_custom_base_url(self, httpx_mock: HTTPXMock):
"""自定义 base_url 应正确使用"""
httpx_mock.add_response(
url="https://custom-proxy.example.com/v1/messages",
json={
"id": "msg_005",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-20250514",
"content": [{"type": "text", "text": "Proxy response"}],
"usage": {"input_tokens": 5, "output_tokens": 3},
"stop_reason": "end_turn",
},
)
provider = AnthropicProvider(
api_key="test-key",
base_url="https://custom-proxy.example.com",
)
request = LLMRequest(
messages=[{"role": "user", "content": "Hi"}],
model="claude-sonnet-4-20250514",
)
response = await provider.chat(request)
assert response.content == "Proxy response"
class TestAnthropicStreaming:
"""chat_stream() 方法测试"""
def _make_stream_response(self, sse_lines: list[str]):
"""Create a mock httpx streaming response context manager."""
response = MagicMock()
response.status_code = 200
async def aiter_lines():
for line in sse_lines:
yield line
response.aiter_lines = aiter_lines
response.aread = AsyncMock(return_value=b"")
# Create async context manager
context = MagicMock()
context.__aenter__ = AsyncMock(return_value=response)
context.__aexit__ = AsyncMock(return_value=False)
return context
async def test_stream_text_response(self):
"""流式文本响应应正确解析"""
sse_lines = [
'event: message_start',
'data: {"type":"message_start","message":{"id":"msg_s1","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[]}}',
'',
'event: content_block_start',
'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',
'',
'event: content_block_delta',
'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}',
'',
'event: content_block_delta',
'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}}',
'',
'event: content_block_stop',
'data: {"type":"content_block_stop","index":0}',
'',
'event: message_delta',
'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":10,"output_tokens":5}}',
'',
'event: message_stop',
'data: {"type":"message_stop"}',
'',
]
mock_client = MagicMock()
mock_client.stream = MagicMock(return_value=self._make_stream_response(sse_lines))
provider = AnthropicProvider(api_key="test-key")
provider._client = mock_client
request = LLMRequest(
messages=[{"role": "user", "content": "Hi"}],
model="claude-sonnet-4-20250514",
)
chunks = []
async for chunk in provider.chat_stream(request):
chunks.append(chunk)
# Should have text chunks + final chunk
text_chunks = [c for c in chunks if c.content]
assert len(text_chunks) == 2
assert text_chunks[0].content == "Hello"
assert text_chunks[1].content == " world"
# Final chunk with usage
final_chunks = [c for c in chunks if c.is_final]
assert len(final_chunks) == 1
assert final_chunks[0].usage is not None
assert final_chunks[0].usage.prompt_tokens == 10
assert final_chunks[0].usage.completion_tokens == 5
async def test_stream_tool_use_response(self):
"""流式 tool_use 响应应正确解析"""
sse_lines = [
'event: message_start',
'data: {"type":"message_start","message":{"id":"msg_s2","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[]}}',
'',
'event: content_block_start',
'data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_s1","name":"get_weather"}}',
'',
'event: content_block_delta',
'data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\\"cit"}}',
'',
'event: content_block_delta',
'data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"y\\":\\"Paris\\"}"}}',
'',
'event: content_block_stop',
'data: {"type":"content_block_stop","index":0}',
'',
'event: message_delta',
'data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"input_tokens":20,"output_tokens":15}}',
'',
'event: message_stop',
'data: {"type":"message_stop"}',
'',
]
mock_client = MagicMock()
mock_client.stream = MagicMock(return_value=self._make_stream_response(sse_lines))
provider = AnthropicProvider(api_key="test-key")
provider._client = mock_client
request = LLMRequest(
messages=[{"role": "user", "content": "Weather in Paris?"}],
model="claude-sonnet-4-20250514",
tools=[
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
},
}
],
)
chunks = []
async for chunk in provider.chat_stream(request):
chunks.append(chunk)
# Final chunk should have tool calls
final_chunks = [c for c in chunks if c.is_final]
assert len(final_chunks) == 1
assert len(final_chunks[0].tool_calls) == 1
assert final_chunks[0].tool_calls[0].id == "toolu_s1"
assert final_chunks[0].tool_calls[0].name == "get_weather"
assert final_chunks[0].tool_calls[0].arguments == {"city": "Paris"}
async def test_stream_error_event(self):
"""流式 error 事件应抛出 LLMProviderError"""
sse_lines = [
'event: error',
'data: {"type":"error","error":{"type":"overloaded_error","message":"Server is overloaded"}}',
'',
]
mock_client = MagicMock()
mock_client.stream = MagicMock(return_value=self._make_stream_response(sse_lines))
provider = AnthropicProvider(api_key="test-key")
provider._client = mock_client
request = LLMRequest(
messages=[{"role": "user", "content": "Hi"}],
model="claude-sonnet-4-20250514",
)
with pytest.raises(LLMProviderError) as exc_info:
async for _ in provider.chat_stream(request):
pass
assert "overloaded" in str(exc_info.value).lower()
async def test_stream_non_200_status(self):
"""流式请求非 200 状态应抛出 LLMProviderError"""
response = MagicMock()
response.status_code = 429
response.aread = AsyncMock(return_value=b'{"type":"error","error":{"type":"rate_limit_error","message":"Rate limit"}}')
context = MagicMock()
context.__aenter__ = AsyncMock(return_value=response)
context.__aexit__ = AsyncMock(return_value=False)
mock_client = MagicMock()
mock_client.stream = MagicMock(return_value=context)
provider = AnthropicProvider(api_key="test-key")
provider._client = mock_client
request = LLMRequest(
messages=[{"role": "user", "content": "Hi"}],
model="claude-sonnet-4-20250514",
)
with pytest.raises(LLMProviderError) as exc_info:
async for _ in provider.chat_stream(request):
pass
assert "429" in str(exc_info.value)
class TestAnthropicErrors:
"""错误处理测试"""
async def test_401_invalid_api_key(self, httpx_mock: HTTPXMock):
"""401 错误应抛出 LLMProviderError"""
httpx_mock.add_response(
url="https://api.anthropic.com/v1/messages",
status_code=401,
json={
"type": "error",
"error": {"type": "authentication_error", "message": "invalid x-api-key"},
},
)
provider = AnthropicProvider(api_key="bad-key")
request = LLMRequest(
messages=[{"role": "user", "content": "Hi"}],
model="claude-sonnet-4-20250514",
)
with pytest.raises(LLMProviderError) as exc_info:
await provider.chat(request)
assert "anthropic" in str(exc_info.value)
assert "401" in str(exc_info.value)
async def test_429_rate_limit(self, httpx_mock: HTTPXMock):
"""429 错误应抛出 LLMProviderError"""
httpx_mock.add_response(
url="https://api.anthropic.com/v1/messages",
status_code=429,
json={
"type": "error",
"error": {"type": "rate_limit_error", "message": "Rate limit exceeded"},
},
)
provider = AnthropicProvider(api_key="test-key")
request = LLMRequest(
messages=[{"role": "user", "content": "Hi"}],
model="claude-sonnet-4-20250514",
)
with pytest.raises(LLMProviderError) as exc_info:
await provider.chat(request)
assert "429" in str(exc_info.value)
async def test_529_overloaded(self, httpx_mock: HTTPXMock):
"""529 错误应抛出 LLMProviderError"""
httpx_mock.add_response(
url="https://api.anthropic.com/v1/messages",
status_code=529,
json={
"type": "error",
"error": {"type": "overloaded_error", "message": "Overloaded"},
},
)
provider = AnthropicProvider(api_key="test-key")
request = LLMRequest(
messages=[{"role": "user", "content": "Hi"}],
model="claude-sonnet-4-20250514",
)
with pytest.raises(LLMProviderError) as exc_info:
await provider.chat(request)
assert "529" in str(exc_info.value)
async def test_500_server_error(self, httpx_mock: HTTPXMock):
"""500 错误应抛出 LLMProviderError"""
httpx_mock.add_response(
url="https://api.anthropic.com/v1/messages",
status_code=500,
json={
"type": "error",
"error": {"type": "api_error", "message": "Internal server error"},
},
)
provider = AnthropicProvider(api_key="test-key")
request = LLMRequest(
messages=[{"role": "user", "content": "Hi"}],
model="claude-sonnet-4-20250514",
)
with pytest.raises(LLMProviderError):
await provider.chat(request)
async def test_network_error(self, httpx_mock: HTTPXMock):
"""网络错误应抛出 LLMProviderError"""
httpx_mock.add_exception(httpx.ConnectError("Connection refused"))
provider = AnthropicProvider(api_key="test-key")
request = LLMRequest(
messages=[{"role": "user", "content": "Hi"}],
model="claude-sonnet-4-20250514",
)
with pytest.raises(LLMProviderError):
await provider.chat(request)
async def test_error_does_not_expose_api_key(self, httpx_mock: HTTPXMock):
"""错误消息不应暴露 API Key"""
httpx_mock.add_response(
url="https://api.anthropic.com/v1/messages",
status_code=401,
json={
"type": "error",
"error": {"type": "authentication_error", "message": "invalid x-api-key"},
},
)
provider = AnthropicProvider(api_key="sk-ant-secret-key-12345")
request = LLMRequest(
messages=[{"role": "user", "content": "Hi"}],
model="claude-sonnet-4-20250514",
)
with pytest.raises(LLMProviderError) as exc_info:
await provider.chat(request)
assert "sk-ant-secret-key-12345" not in str(exc_info.value)
class TestAnthropicGetModelInfo:
"""get_model_info() 测试"""
def test_returns_provider_and_model_info(self):
provider = AnthropicProvider(
api_key="test-key",
model="claude-sonnet-4-20250514",
max_tokens=8192,
)
info = provider.get_model_info()
assert info["provider"] == "anthropic"
assert info["model"] == "claude-sonnet-4-20250514"
assert info["max_tokens"] == 8192
assert info["thinking_enabled"] is False
def test_thinking_enabled_flag(self):
provider = AnthropicProvider(
api_key="test-key",
thinking_enabled=True,
)
info = provider.get_model_info()
assert info["thinking_enabled"] is True
class TestAnthropicLazyClient:
"""Lazy client 初始化测试"""
def test_client_not_created_on_init(self):
"""初始化时不应创建 HTTP 客户端"""
provider = AnthropicProvider(api_key="test-key")
assert provider._client is None
def test_client_created_on_first_use(self):
"""首次使用时应创建 HTTP 客户端"""
provider = AnthropicProvider(api_key="test-key")
client = provider._get_client()
assert client is not None
assert provider._client is not None
def test_client_reused(self):
"""多次调用应复用同一客户端"""
provider = AnthropicProvider(api_key="test-key")
client1 = provider._get_client()
client2 = provider._get_client()
assert client1 is client2
async def test_close_resets_client(self):
"""close 后客户端应被重置"""
provider = AnthropicProvider(api_key="test-key")
_ = provider._get_client()
assert provider._client is not None
await provider.close()
assert provider._client is None