Spaces:
Configuration error
Configuration error
add anthropic and open_response LLM format support
Browse filesSigned-off-by: Jintao Zhang <zhangjintao9020@gmail.com>
- README.md +24 -3
- pyproject.toml +11 -8
- src/amcp/agent.py +26 -35
- src/amcp/config.py +5 -0
- src/amcp/llm.py +208 -0
- tests/test_llm.py +72 -0
- uv.lock +49 -0
README.md
CHANGED
|
@@ -124,7 +124,7 @@ Generate a starter config:
|
|
| 124 |
amcp init
|
| 125 |
```
|
| 126 |
|
| 127 |
-
Example:
|
| 128 |
|
| 129 |
```toml
|
| 130 |
[servers.exa]
|
|
@@ -136,14 +136,35 @@ args = ["-y", "@some/mcp-server"]
|
|
| 136 |
env.API_KEY = "your-key"
|
| 137 |
|
| 138 |
[chat]
|
| 139 |
-
|
| 140 |
-
|
|
|
|
| 141 |
api_key = "your-api-key"
|
| 142 |
mcp_tools_enabled = true
|
| 143 |
write_tool_enabled = true # Enable/disable built-in write_file tool
|
| 144 |
edit_tool_enabled = true # Enable/disable built-in edit_file tool
|
| 145 |
```
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
## Development
|
| 148 |
|
| 149 |
### Setup Development Environment
|
|
|
|
| 124 |
amcp init
|
| 125 |
```
|
| 126 |
|
| 127 |
+
Example (OpenAI-compatible API):
|
| 128 |
|
| 129 |
```toml
|
| 130 |
[servers.exa]
|
|
|
|
| 136 |
env.API_KEY = "your-key"
|
| 137 |
|
| 138 |
[chat]
|
| 139 |
+
api_type = "openai" # "openai" (default) or "anthropic"
|
| 140 |
+
base_url = "https://api.openai.com/v1"
|
| 141 |
+
model = "gpt-4o"
|
| 142 |
api_key = "your-api-key"
|
| 143 |
mcp_tools_enabled = true
|
| 144 |
write_tool_enabled = true # Enable/disable built-in write_file tool
|
| 145 |
edit_tool_enabled = true # Enable/disable built-in edit_file tool
|
| 146 |
```
|
| 147 |
|
| 148 |
+
Example (OpenAI Responses API):
|
| 149 |
+
|
| 150 |
+
```toml
|
| 151 |
+
[chat]
|
| 152 |
+
api_type = "openai_responses"
|
| 153 |
+
model = "gpt-4o"
|
| 154 |
+
api_key = "your-api-key"
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
Example (Anthropic Claude):
|
| 158 |
+
|
| 159 |
+
```toml
|
| 160 |
+
[chat]
|
| 161 |
+
api_type = "anthropic"
|
| 162 |
+
model = "claude-sonnet-4-20250514"
|
| 163 |
+
api_key = "your-anthropic-api-key" # or set ANTHROPIC_API_KEY env var
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
To use Anthropic, install with: `pip install amcp[anthropic]`
|
| 167 |
+
|
| 168 |
## Development
|
| 169 |
|
| 170 |
### Setup Development Environment
|
pyproject.toml
CHANGED
|
@@ -21,6 +21,17 @@ dependencies = [
|
|
| 21 |
"agent-client-protocol>=0.7.0",
|
| 22 |
]
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
[project.scripts]
|
| 25 |
amcp = "amcp.cli:app"
|
| 26 |
amcp-acp = "amcp.acp_agent:main"
|
|
@@ -36,14 +47,6 @@ line-length = 120
|
|
| 36 |
select = ["E", "F", "UP", "B", "SIM", "I"]
|
| 37 |
ignore = ["E501"]
|
| 38 |
|
| 39 |
-
[project.optional-dependencies]
|
| 40 |
-
dev = [
|
| 41 |
-
"pytest>=8.0.0",
|
| 42 |
-
"pytest-cov>=4.1.0",
|
| 43 |
-
"ruff>=0.3.0",
|
| 44 |
-
"mypy>=1.8.0",
|
| 45 |
-
]
|
| 46 |
-
|
| 47 |
[tool.pytest.ini_options]
|
| 48 |
testpaths = ["tests"]
|
| 49 |
addopts = "-v --cov=src/amcp --cov-report=term-missing"
|
|
|
|
| 21 |
"agent-client-protocol>=0.7.0",
|
| 22 |
]
|
| 23 |
|
| 24 |
+
[project.optional-dependencies]
|
| 25 |
+
anthropic = ["anthropic>=0.40.0"]
|
| 26 |
+
dev = [
|
| 27 |
+
"pytest>=8.0.0",
|
| 28 |
+
"pytest-cov>=4.1.0",
|
| 29 |
+
"pytest-asyncio>=0.23.0",
|
| 30 |
+
"ruff>=0.3.0",
|
| 31 |
+
"mypy>=1.8.0",
|
| 32 |
+
"anthropic>=0.40.0",
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
[project.scripts]
|
| 36 |
amcp = "amcp.cli:app"
|
| 37 |
amcp-acp = "amcp.acp_agent:main"
|
|
|
|
| 47 |
select = ["E", "F", "UP", "B", "SIM", "I"]
|
| 48 |
ignore = ["E501"]
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
[tool.pytest.ini_options]
|
| 51 |
testpaths = ["tests"]
|
| 52 |
addopts = "-v --cov=src/amcp --cov-report=term-missing"
|
src/amcp/agent.py
CHANGED
|
@@ -425,15 +425,18 @@ class Agent:
|
|
| 425 |
) -> str:
|
| 426 |
"""Run chat with tools and enhanced tracking."""
|
| 427 |
cfg = load_config()
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
|
| 433 |
# Override the chat function to add our tracking
|
| 434 |
return await self._enhanced_chat_with_tools(
|
| 435 |
-
|
| 436 |
-
model=model,
|
| 437 |
messages=messages,
|
| 438 |
tools=tools,
|
| 439 |
tool_registry=tool_registry,
|
|
@@ -443,8 +446,7 @@ class Agent:
|
|
| 443 |
|
| 444 |
async def _enhanced_chat_with_tools(
|
| 445 |
self,
|
| 446 |
-
|
| 447 |
-
model: str,
|
| 448 |
messages: list[dict[str, Any]],
|
| 449 |
tools: list[dict[str, Any]],
|
| 450 |
tool_registry: dict[str, Any],
|
|
@@ -463,25 +465,17 @@ class Agent:
|
|
| 463 |
self.step_count = step + 1
|
| 464 |
status.update(f"[bold]Agent {self.name}[/bold] - Step {self.step_count}/{max_steps}")
|
| 465 |
|
| 466 |
-
resp =
|
| 467 |
-
model=model,
|
| 468 |
-
messages=messages,
|
| 469 |
-
tools=tools,
|
| 470 |
-
tool_choice="auto",
|
| 471 |
-
stream=False,
|
| 472 |
-
)
|
| 473 |
-
|
| 474 |
-
msg = resp.choices[0].message
|
| 475 |
-
tool_calls = getattr(msg, "tool_calls", None)
|
| 476 |
|
| 477 |
-
if tool_calls:
|
|
|
|
| 478 |
used_tools = True
|
| 479 |
status.update(f"[bold]Agent {self.name}[/bold] - Executing {len(tool_calls)} tool(s)...")
|
| 480 |
|
| 481 |
# Check if any tool should be limited before processing
|
| 482 |
limited_tools = []
|
| 483 |
for tc in tool_calls:
|
| 484 |
-
tool_name = tc
|
| 485 |
if self._should_limit_tool_calls(tool_name):
|
| 486 |
limited_tools.append(tool_name)
|
| 487 |
|
|
@@ -499,12 +493,8 @@ class Agent:
|
|
| 499 |
)
|
| 500 |
# Get a final response from the LLM with the current messages
|
| 501 |
try:
|
| 502 |
-
final_resp =
|
| 503 |
-
|
| 504 |
-
messages=messages,
|
| 505 |
-
stream=False,
|
| 506 |
-
)
|
| 507 |
-
final_text = final_resp.choices[0].message.content or ""
|
| 508 |
status.update(f"[bold]Agent {self.name}[/bold] - ✅ Complete")
|
| 509 |
return final_text
|
| 510 |
except Exception as e:
|
|
@@ -514,14 +504,15 @@ class Agent:
|
|
| 514 |
# Process tool calls with Live UI
|
| 515 |
with LiveUI() as live_ui:
|
| 516 |
for tc in tool_calls:
|
| 517 |
-
tool_name = tc
|
| 518 |
-
|
|
|
|
| 519 |
|
| 520 |
# Record tool call
|
| 521 |
tool_call_record = {
|
| 522 |
"step": self.step_count,
|
| 523 |
"tool": tool_name,
|
| 524 |
-
"args": tc
|
| 525 |
"timestamp": datetime.now().isoformat(),
|
| 526 |
}
|
| 527 |
self.tool_calls_history.append(tool_call_record)
|
|
@@ -577,12 +568,12 @@ class Agent:
|
|
| 577 |
messages.append(
|
| 578 |
{
|
| 579 |
"role": "assistant",
|
| 580 |
-
"content":
|
| 581 |
"tool_calls": [
|
| 582 |
{
|
| 583 |
-
"id":
|
| 584 |
"type": "function",
|
| 585 |
-
"function": {"name": tool_name, "arguments": tc
|
| 586 |
}
|
| 587 |
],
|
| 588 |
}
|
|
@@ -590,7 +581,7 @@ class Agent:
|
|
| 590 |
messages.append(
|
| 591 |
{
|
| 592 |
"role": "tool",
|
| 593 |
-
"tool_call_id":
|
| 594 |
"name": tool_name,
|
| 595 |
"content": truncated_result,
|
| 596 |
}
|
|
@@ -602,7 +593,7 @@ class Agent:
|
|
| 602 |
messages.append(
|
| 603 |
{
|
| 604 |
"role": "tool",
|
| 605 |
-
"tool_call_id":
|
| 606 |
"name": tool_name,
|
| 607 |
"content": error_msg,
|
| 608 |
}
|
|
@@ -611,7 +602,7 @@ class Agent:
|
|
| 611 |
continue
|
| 612 |
else:
|
| 613 |
# No tool calls, return the response
|
| 614 |
-
final_text =
|
| 615 |
if stream and not used_tools:
|
| 616 |
# For streaming, we'll implement a simple version
|
| 617 |
pass
|
|
|
|
| 425 |
) -> str:
|
| 426 |
"""Run chat with tools and enhanced tracking."""
|
| 427 |
cfg = load_config()
|
| 428 |
+
|
| 429 |
+
# Use new LLM client abstraction
|
| 430 |
+
from .llm import create_llm_client
|
| 431 |
+
llm_client = create_llm_client(cfg.chat)
|
| 432 |
+
|
| 433 |
+
# Override model if specified in agent spec
|
| 434 |
+
if self.agent_spec.model:
|
| 435 |
+
llm_client.model = self.agent_spec.model
|
| 436 |
|
| 437 |
# Override the chat function to add our tracking
|
| 438 |
return await self._enhanced_chat_with_tools(
|
| 439 |
+
llm_client=llm_client,
|
|
|
|
| 440 |
messages=messages,
|
| 441 |
tools=tools,
|
| 442 |
tool_registry=tool_registry,
|
|
|
|
| 446 |
|
| 447 |
async def _enhanced_chat_with_tools(
|
| 448 |
self,
|
| 449 |
+
llm_client,
|
|
|
|
| 450 |
messages: list[dict[str, Any]],
|
| 451 |
tools: list[dict[str, Any]],
|
| 452 |
tool_registry: dict[str, Any],
|
|
|
|
| 465 |
self.step_count = step + 1
|
| 466 |
status.update(f"[bold]Agent {self.name}[/bold] - Step {self.step_count}/{max_steps}")
|
| 467 |
|
| 468 |
+
resp = llm_client.chat(messages=messages, tools=tools)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
|
| 470 |
+
if resp.tool_calls:
|
| 471 |
+
tool_calls = resp.tool_calls
|
| 472 |
used_tools = True
|
| 473 |
status.update(f"[bold]Agent {self.name}[/bold] - Executing {len(tool_calls)} tool(s)...")
|
| 474 |
|
| 475 |
# Check if any tool should be limited before processing
|
| 476 |
limited_tools = []
|
| 477 |
for tc in tool_calls:
|
| 478 |
+
tool_name = tc["name"]
|
| 479 |
if self._should_limit_tool_calls(tool_name):
|
| 480 |
limited_tools.append(tool_name)
|
| 481 |
|
|
|
|
| 493 |
)
|
| 494 |
# Get a final response from the LLM with the current messages
|
| 495 |
try:
|
| 496 |
+
final_resp = llm_client.chat(messages=messages)
|
| 497 |
+
final_text = final_resp.content or ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
status.update(f"[bold]Agent {self.name}[/bold] - ✅ Complete")
|
| 499 |
return final_text
|
| 500 |
except Exception as e:
|
|
|
|
| 504 |
# Process tool calls with Live UI
|
| 505 |
with LiveUI() as live_ui:
|
| 506 |
for tc in tool_calls:
|
| 507 |
+
tool_name = tc["name"]
|
| 508 |
+
tool_id = tc["id"]
|
| 509 |
+
args = json.loads(tc["arguments"] or "{}")
|
| 510 |
|
| 511 |
# Record tool call
|
| 512 |
tool_call_record = {
|
| 513 |
"step": self.step_count,
|
| 514 |
"tool": tool_name,
|
| 515 |
+
"args": tc["arguments"],
|
| 516 |
"timestamp": datetime.now().isoformat(),
|
| 517 |
}
|
| 518 |
self.tool_calls_history.append(tool_call_record)
|
|
|
|
| 568 |
messages.append(
|
| 569 |
{
|
| 570 |
"role": "assistant",
|
| 571 |
+
"content": resp.content or "",
|
| 572 |
"tool_calls": [
|
| 573 |
{
|
| 574 |
+
"id": tool_id,
|
| 575 |
"type": "function",
|
| 576 |
+
"function": {"name": tool_name, "arguments": tc["arguments"] or "{}"},
|
| 577 |
}
|
| 578 |
],
|
| 579 |
}
|
|
|
|
| 581 |
messages.append(
|
| 582 |
{
|
| 583 |
"role": "tool",
|
| 584 |
+
"tool_call_id": tool_id,
|
| 585 |
"name": tool_name,
|
| 586 |
"content": truncated_result,
|
| 587 |
}
|
|
|
|
| 593 |
messages.append(
|
| 594 |
{
|
| 595 |
"role": "tool",
|
| 596 |
+
"tool_call_id": tool_id,
|
| 597 |
"name": tool_name,
|
| 598 |
"content": error_msg,
|
| 599 |
}
|
|
|
|
| 602 |
continue
|
| 603 |
else:
|
| 604 |
# No tool calls, return the response
|
| 605 |
+
final_text = resp.content or ""
|
| 606 |
if stream and not used_tools:
|
| 607 |
# For streaming, we'll implement a simple version
|
| 608 |
pass
|
src/amcp/config.py
CHANGED
|
@@ -32,6 +32,7 @@ class ChatConfig:
|
|
| 32 |
base_url: str | None = None
|
| 33 |
model: str | None = None
|
| 34 |
api_key: str | None = None
|
|
|
|
| 35 |
# Tool calling settings
|
| 36 |
tool_loop_limit: int | None = None
|
| 37 |
default_max_lines: int | None = None
|
|
@@ -88,6 +89,7 @@ def _decode_chat(raw: Mapping[str, object] | None) -> ChatConfig | None:
|
|
| 88 |
base_url = raw.get("base_url")
|
| 89 |
model = raw.get("model")
|
| 90 |
api_key = raw.get("api_key")
|
|
|
|
| 91 |
tool_loop_limit = raw.get("tool_loop_limit")
|
| 92 |
default_max_lines = raw.get("default_max_lines")
|
| 93 |
read_roots = raw.get("read_roots")
|
|
@@ -99,6 +101,7 @@ def _decode_chat(raw: Mapping[str, object] | None) -> ChatConfig | None:
|
|
| 99 |
base_url=str(base_url) if base_url is not None else None,
|
| 100 |
model=str(model) if model is not None else None,
|
| 101 |
api_key=str(api_key) if api_key is not None else None,
|
|
|
|
| 102 |
tool_loop_limit=int(tool_loop_limit) if tool_loop_limit is not None else None,
|
| 103 |
default_max_lines=int(default_max_lines) if default_max_lines is not None else None,
|
| 104 |
read_roots=[str(p) for p in (read_roots or [])] if read_roots is not None else None,
|
|
@@ -141,6 +144,8 @@ def _encode_chat(c: ChatConfig | None) -> dict | None:
|
|
| 141 |
out["model"] = c.model
|
| 142 |
if c.api_key:
|
| 143 |
out["api_key"] = c.api_key
|
|
|
|
|
|
|
| 144 |
if c.tool_loop_limit is not None:
|
| 145 |
out["tool_loop_limit"] = int(c.tool_loop_limit)
|
| 146 |
if c.default_max_lines is not None:
|
|
|
|
| 32 |
base_url: str | None = None
|
| 33 |
model: str | None = None
|
| 34 |
api_key: str | None = None
|
| 35 |
+
api_type: str | None = None # "openai" (default) or "anthropic"
|
| 36 |
# Tool calling settings
|
| 37 |
tool_loop_limit: int | None = None
|
| 38 |
default_max_lines: int | None = None
|
|
|
|
| 89 |
base_url = raw.get("base_url")
|
| 90 |
model = raw.get("model")
|
| 91 |
api_key = raw.get("api_key")
|
| 92 |
+
api_type = raw.get("api_type")
|
| 93 |
tool_loop_limit = raw.get("tool_loop_limit")
|
| 94 |
default_max_lines = raw.get("default_max_lines")
|
| 95 |
read_roots = raw.get("read_roots")
|
|
|
|
| 101 |
base_url=str(base_url) if base_url is not None else None,
|
| 102 |
model=str(model) if model is not None else None,
|
| 103 |
api_key=str(api_key) if api_key is not None else None,
|
| 104 |
+
api_type=str(api_type) if api_type is not None else None,
|
| 105 |
tool_loop_limit=int(tool_loop_limit) if tool_loop_limit is not None else None,
|
| 106 |
default_max_lines=int(default_max_lines) if default_max_lines is not None else None,
|
| 107 |
read_roots=[str(p) for p in (read_roots or [])] if read_roots is not None else None,
|
|
|
|
| 144 |
out["model"] = c.model
|
| 145 |
if c.api_key:
|
| 146 |
out["api_key"] = c.api_key
|
| 147 |
+
if c.api_type:
|
| 148 |
+
out["api_type"] = c.api_type
|
| 149 |
if c.tool_loop_limit is not None:
|
| 150 |
out["tool_loop_limit"] = int(c.tool_loop_limit)
|
| 151 |
if c.default_max_lines is not None:
|
src/amcp/llm.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM client abstraction supporting OpenAI, OpenAI Responses, and Anthropic APIs."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from abc import ABC, abstractmethod
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from .config import ChatConfig
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class LLMResponse:
|
| 16 |
+
"""Unified response from LLM."""
|
| 17 |
+
content: str | None
|
| 18 |
+
tool_calls: list[dict[str, Any]] | None = None
|
| 19 |
+
stop_reason: str | None = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class BaseLLMClient(ABC):
|
| 23 |
+
"""Base class for LLM clients."""
|
| 24 |
+
|
| 25 |
+
@abstractmethod
|
| 26 |
+
def chat(self, messages: list[dict], tools: list[dict] | None = None, **kwargs) -> LLMResponse:
|
| 27 |
+
"""Send chat request and return response."""
|
| 28 |
+
pass
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class OpenAIClient(BaseLLMClient):
|
| 32 |
+
"""OpenAI Chat Completions API client."""
|
| 33 |
+
|
| 34 |
+
def __init__(self, base_url: str, api_key: str | None, model: str):
|
| 35 |
+
from openai import OpenAI
|
| 36 |
+
self.client = OpenAI(base_url=base_url, api_key=api_key or "")
|
| 37 |
+
self.model = model
|
| 38 |
+
|
| 39 |
+
def chat(self, messages: list[dict], tools: list[dict] | None = None, **kwargs) -> LLMResponse:
|
| 40 |
+
params = {"model": self.model, "messages": messages, "stream": False, **kwargs}
|
| 41 |
+
if tools:
|
| 42 |
+
params["tools"] = tools
|
| 43 |
+
params["tool_choice"] = "auto"
|
| 44 |
+
|
| 45 |
+
resp = self.client.chat.completions.create(**params)
|
| 46 |
+
msg = resp.choices[0].message
|
| 47 |
+
|
| 48 |
+
tool_calls = None
|
| 49 |
+
if hasattr(msg, "tool_calls") and msg.tool_calls:
|
| 50 |
+
tool_calls = [
|
| 51 |
+
{"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments}
|
| 52 |
+
for tc in msg.tool_calls
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
return LLMResponse(content=msg.content, tool_calls=tool_calls, stop_reason=resp.choices[0].finish_reason)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class OpenAIResponsesClient(BaseLLMClient):
|
| 59 |
+
"""OpenAI Responses API client."""
|
| 60 |
+
|
| 61 |
+
def __init__(self, base_url: str, api_key: str | None, model: str):
|
| 62 |
+
from openai import OpenAI
|
| 63 |
+
self.client = OpenAI(base_url=base_url, api_key=api_key or "")
|
| 64 |
+
self.model = model
|
| 65 |
+
|
| 66 |
+
def chat(self, messages: list[dict], tools: list[dict] | None = None, **kwargs) -> LLMResponse:
|
| 67 |
+
# Convert tools to Responses API format
|
| 68 |
+
resp_tools = None
|
| 69 |
+
if tools:
|
| 70 |
+
resp_tools = [
|
| 71 |
+
{"type": "function", "name": t["function"]["name"], "description": t["function"].get("description", ""), "parameters": t["function"].get("parameters", {})}
|
| 72 |
+
for t in tools
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
params = {"model": self.model, "input": messages}
|
| 76 |
+
if resp_tools:
|
| 77 |
+
params["tools"] = resp_tools
|
| 78 |
+
|
| 79 |
+
resp = self.client.responses.create(**params)
|
| 80 |
+
|
| 81 |
+
# Parse response
|
| 82 |
+
content_parts = []
|
| 83 |
+
tool_calls = []
|
| 84 |
+
|
| 85 |
+
for item in resp.output:
|
| 86 |
+
if item.type == "message":
|
| 87 |
+
for block in item.content:
|
| 88 |
+
if block.type == "output_text":
|
| 89 |
+
content_parts.append(block.text)
|
| 90 |
+
elif item.type == "function_call":
|
| 91 |
+
tool_calls.append({"id": item.call_id, "name": item.name, "arguments": item.arguments})
|
| 92 |
+
|
| 93 |
+
return LLMResponse(
|
| 94 |
+
content="\n".join(content_parts) if content_parts else None,
|
| 95 |
+
tool_calls=tool_calls if tool_calls else None,
|
| 96 |
+
stop_reason=resp.stop_reason,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class AnthropicClient(BaseLLMClient):
|
| 101 |
+
"""Anthropic Claude API client."""
|
| 102 |
+
|
| 103 |
+
def __init__(self, api_key: str | None, model: str, base_url: str | None = None):
|
| 104 |
+
try:
|
| 105 |
+
from anthropic import Anthropic
|
| 106 |
+
except ImportError:
|
| 107 |
+
raise ImportError("anthropic package not installed. Run: pip install anthropic")
|
| 108 |
+
|
| 109 |
+
kwargs = {"api_key": api_key or os.environ.get("ANTHROPIC_API_KEY", "")}
|
| 110 |
+
if base_url:
|
| 111 |
+
kwargs["base_url"] = base_url
|
| 112 |
+
self.client = Anthropic(**kwargs)
|
| 113 |
+
self.model = model
|
| 114 |
+
|
| 115 |
+
def chat(self, messages: list[dict], tools: list[dict] | None = None, **kwargs) -> LLMResponse:
|
| 116 |
+
# Convert OpenAI format to Anthropic format
|
| 117 |
+
system_prompt = None
|
| 118 |
+
anthropic_messages = []
|
| 119 |
+
|
| 120 |
+
for msg in messages:
|
| 121 |
+
role = msg["role"]
|
| 122 |
+
content = msg.get("content", "")
|
| 123 |
+
|
| 124 |
+
if role == "system":
|
| 125 |
+
system_prompt = content
|
| 126 |
+
elif role == "user":
|
| 127 |
+
anthropic_messages.append({"role": "user", "content": content})
|
| 128 |
+
elif role == "assistant":
|
| 129 |
+
if "tool_calls" in msg and msg["tool_calls"]:
|
| 130 |
+
blocks = []
|
| 131 |
+
if content:
|
| 132 |
+
blocks.append({"type": "text", "text": content})
|
| 133 |
+
for tc in msg["tool_calls"]:
|
| 134 |
+
blocks.append({
|
| 135 |
+
"type": "tool_use",
|
| 136 |
+
"id": tc["id"],
|
| 137 |
+
"name": tc["function"]["name"],
|
| 138 |
+
"input": json.loads(tc["function"]["arguments"] or "{}"),
|
| 139 |
+
})
|
| 140 |
+
anthropic_messages.append({"role": "assistant", "content": blocks})
|
| 141 |
+
else:
|
| 142 |
+
anthropic_messages.append({"role": "assistant", "content": content})
|
| 143 |
+
elif role == "tool":
|
| 144 |
+
anthropic_messages.append({
|
| 145 |
+
"role": "user",
|
| 146 |
+
"content": [{"type": "tool_result", "tool_use_id": msg.get("tool_call_id"), "content": content}],
|
| 147 |
+
})
|
| 148 |
+
|
| 149 |
+
# Convert tools to Anthropic format
|
| 150 |
+
anthropic_tools = None
|
| 151 |
+
if tools:
|
| 152 |
+
anthropic_tools = [
|
| 153 |
+
{"name": t["function"]["name"], "description": t["function"].get("description", ""), "input_schema": t["function"].get("parameters", {"type": "object", "properties": {}})}
|
| 154 |
+
for t in tools
|
| 155 |
+
]
|
| 156 |
+
|
| 157 |
+
params = {"model": self.model, "messages": anthropic_messages, "max_tokens": kwargs.get("max_tokens", 4096)}
|
| 158 |
+
if system_prompt:
|
| 159 |
+
params["system"] = system_prompt
|
| 160 |
+
if anthropic_tools:
|
| 161 |
+
params["tools"] = anthropic_tools
|
| 162 |
+
|
| 163 |
+
resp = self.client.messages.create(**params)
|
| 164 |
+
|
| 165 |
+
content_parts = []
|
| 166 |
+
tool_calls = []
|
| 167 |
+
|
| 168 |
+
for block in resp.content:
|
| 169 |
+
if block.type == "text":
|
| 170 |
+
content_parts.append(block.text)
|
| 171 |
+
elif block.type == "tool_use":
|
| 172 |
+
tool_calls.append({"id": block.id, "name": block.name, "arguments": json.dumps(block.input)})
|
| 173 |
+
|
| 174 |
+
return LLMResponse(
|
| 175 |
+
content="\n".join(content_parts) if content_parts else None,
|
| 176 |
+
tool_calls=tool_calls if tool_calls else None,
|
| 177 |
+
stop_reason=resp.stop_reason,
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def create_llm_client(cfg: ChatConfig | None) -> BaseLLMClient:
|
| 182 |
+
"""Create appropriate LLM client based on config.
|
| 183 |
+
|
| 184 |
+
api_type options:
|
| 185 |
+
- "openai" (default): OpenAI Chat Completions API
|
| 186 |
+
- "openai_responses": OpenAI Responses API
|
| 187 |
+
- "anthropic": Anthropic Claude API
|
| 188 |
+
"""
|
| 189 |
+
api_type = (cfg.api_type if cfg else None) or os.environ.get("AMCP_API_TYPE", "openai")
|
| 190 |
+
model = (cfg.model if cfg else None) or "gpt-4o"
|
| 191 |
+
|
| 192 |
+
if api_type == "anthropic":
|
| 193 |
+
api_key = (cfg.api_key if cfg else None) or os.environ.get("ANTHROPIC_API_KEY")
|
| 194 |
+
base_url = cfg.base_url if cfg else None
|
| 195 |
+
return AnthropicClient(api_key=api_key, model=model, base_url=base_url)
|
| 196 |
+
elif api_type == "openai_responses":
|
| 197 |
+
base_url = (cfg.base_url if cfg else None) or os.environ.get("AMCP_OPENAI_BASE", "https://api.openai.com/v1")
|
| 198 |
+
if not base_url.endswith("/v1"):
|
| 199 |
+
base_url = base_url.rstrip("/") + "/v1"
|
| 200 |
+
api_key = (cfg.api_key if cfg else None) or os.environ.get("OPENAI_API_KEY")
|
| 201 |
+
return OpenAIResponsesClient(base_url=base_url, api_key=api_key, model=model)
|
| 202 |
+
else:
|
| 203 |
+
# Default: OpenAI Chat Completions
|
| 204 |
+
base_url = (cfg.base_url if cfg else None) or os.environ.get("AMCP_OPENAI_BASE", "https://api.openai.com/v1")
|
| 205 |
+
if not base_url.endswith("/v1"):
|
| 206 |
+
base_url = base_url.rstrip("/") + "/v1"
|
| 207 |
+
api_key = (cfg.api_key if cfg else None) or os.environ.get("OPENAI_API_KEY")
|
| 208 |
+
return OpenAIClient(base_url=base_url, api_key=api_key, model=model)
|
tests/test_llm.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for LLM client abstraction."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from amcp.config import ChatConfig
|
| 6 |
+
from amcp.llm import LLMResponse, create_llm_client, OpenAIClient, OpenAIResponsesClient, AnthropicClient
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TestLLMResponse:
|
| 10 |
+
"""Tests for LLMResponse dataclass."""
|
| 11 |
+
|
| 12 |
+
def test_basic_response(self):
|
| 13 |
+
resp = LLMResponse(content="Hello, world!")
|
| 14 |
+
assert resp.content == "Hello, world!"
|
| 15 |
+
assert resp.tool_calls is None
|
| 16 |
+
|
| 17 |
+
def test_response_with_tool_calls(self):
|
| 18 |
+
tool_calls = [{"id": "1", "name": "test", "arguments": "{}"}]
|
| 19 |
+
resp = LLMResponse(content=None, tool_calls=tool_calls, stop_reason="tool_use")
|
| 20 |
+
assert resp.tool_calls == tool_calls
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class TestCreateLLMClient:
|
| 24 |
+
"""Tests for create_llm_client factory."""
|
| 25 |
+
|
| 26 |
+
def test_default_creates_openai_client(self):
|
| 27 |
+
cfg = ChatConfig(model="gpt-4o", api_key="test-key")
|
| 28 |
+
client = create_llm_client(cfg)
|
| 29 |
+
assert isinstance(client, OpenAIClient)
|
| 30 |
+
|
| 31 |
+
def test_openai_type_creates_openai_client(self):
|
| 32 |
+
cfg = ChatConfig(api_type="openai", model="gpt-4o", api_key="test-key")
|
| 33 |
+
client = create_llm_client(cfg)
|
| 34 |
+
assert isinstance(client, OpenAIClient)
|
| 35 |
+
|
| 36 |
+
def test_openai_responses_type(self):
|
| 37 |
+
cfg = ChatConfig(api_type="openai_responses", model="gpt-4o", api_key="test-key")
|
| 38 |
+
client = create_llm_client(cfg)
|
| 39 |
+
assert isinstance(client, OpenAIResponsesClient)
|
| 40 |
+
|
| 41 |
+
def test_anthropic_type(self):
|
| 42 |
+
try:
|
| 43 |
+
cfg = ChatConfig(api_type="anthropic", model="claude-sonnet-4-20250514", api_key="test-key")
|
| 44 |
+
client = create_llm_client(cfg)
|
| 45 |
+
assert isinstance(client, AnthropicClient)
|
| 46 |
+
except ImportError:
|
| 47 |
+
pytest.skip("anthropic package not installed")
|
| 48 |
+
|
| 49 |
+
def test_none_config_uses_defaults(self):
|
| 50 |
+
client = create_llm_client(None)
|
| 51 |
+
assert isinstance(client, OpenAIClient)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class TestOpenAIClient:
|
| 55 |
+
def test_client_creation(self):
|
| 56 |
+
client = OpenAIClient(base_url="https://api.openai.com/v1", api_key="test-key", model="gpt-4o")
|
| 57 |
+
assert client.model == "gpt-4o"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class TestOpenAIResponsesClient:
|
| 61 |
+
def test_client_creation(self):
|
| 62 |
+
client = OpenAIResponsesClient(base_url="https://api.openai.com/v1", api_key="test-key", model="gpt-4o")
|
| 63 |
+
assert client.model == "gpt-4o"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class TestAnthropicClient:
|
| 67 |
+
def test_client_creation(self):
|
| 68 |
+
try:
|
| 69 |
+
client = AnthropicClient(api_key="test-key", model="claude-sonnet-4-20250514")
|
| 70 |
+
assert client.model == "claude-sonnet-4-20250514"
|
| 71 |
+
except ImportError:
|
| 72 |
+
pytest.skip("anthropic package not installed")
|
uv.lock
CHANGED
|
@@ -30,9 +30,14 @@ dependencies = [
|
|
| 30 |
]
|
| 31 |
|
| 32 |
[package.optional-dependencies]
|
|
|
|
|
|
|
|
|
|
| 33 |
dev = [
|
|
|
|
| 34 |
{ name = "mypy" },
|
| 35 |
{ name = "pytest" },
|
|
|
|
| 36 |
{ name = "pytest-cov" },
|
| 37 |
{ name = "ruff" },
|
| 38 |
]
|
|
@@ -40,12 +45,15 @@ dev = [
|
|
| 40 |
[package.metadata]
|
| 41 |
requires-dist = [
|
| 42 |
{ name = "agent-client-protocol", specifier = ">=0.7.0" },
|
|
|
|
|
|
|
| 43 |
{ name = "mcp", extras = ["cli"] },
|
| 44 |
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" },
|
| 45 |
{ name = "openai", specifier = ">=1.52.0" },
|
| 46 |
{ name = "prompt-toolkit", specifier = ">=3.0.0" },
|
| 47 |
{ name = "pydantic", specifier = ">=2.7" },
|
| 48 |
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
|
|
|
| 49 |
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },
|
| 50 |
{ name = "pyyaml", specifier = ">=6.0" },
|
| 51 |
{ name = "rich", specifier = ">=13.7.0" },
|
|
@@ -64,6 +72,25 @@ wheels = [
|
|
| 64 |
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
|
| 65 |
]
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
[[package]]
|
| 68 |
name = "anyio"
|
| 69 |
version = "4.12.0"
|
|
@@ -349,6 +376,15 @@ wheels = [
|
|
| 349 |
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 },
|
| 350 |
]
|
| 351 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
[[package]]
|
| 353 |
name = "h11"
|
| 354 |
version = "0.16.0"
|
|
@@ -904,6 +940,19 @@ wheels = [
|
|
| 904 |
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 },
|
| 905 |
]
|
| 906 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 907 |
[[package]]
|
| 908 |
name = "pytest-cov"
|
| 909 |
version = "7.0.0"
|
|
|
|
| 30 |
]
|
| 31 |
|
| 32 |
[package.optional-dependencies]
|
| 33 |
+
anthropic = [
|
| 34 |
+
{ name = "anthropic" },
|
| 35 |
+
]
|
| 36 |
dev = [
|
| 37 |
+
{ name = "anthropic" },
|
| 38 |
{ name = "mypy" },
|
| 39 |
{ name = "pytest" },
|
| 40 |
+
{ name = "pytest-asyncio" },
|
| 41 |
{ name = "pytest-cov" },
|
| 42 |
{ name = "ruff" },
|
| 43 |
]
|
|
|
|
| 45 |
[package.metadata]
|
| 46 |
requires-dist = [
|
| 47 |
{ name = "agent-client-protocol", specifier = ">=0.7.0" },
|
| 48 |
+
{ name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.40.0" },
|
| 49 |
+
{ name = "anthropic", marker = "extra == 'dev'", specifier = ">=0.40.0" },
|
| 50 |
{ name = "mcp", extras = ["cli"] },
|
| 51 |
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" },
|
| 52 |
{ name = "openai", specifier = ">=1.52.0" },
|
| 53 |
{ name = "prompt-toolkit", specifier = ">=3.0.0" },
|
| 54 |
{ name = "pydantic", specifier = ">=2.7" },
|
| 55 |
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
| 56 |
+
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
| 57 |
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },
|
| 58 |
{ name = "pyyaml", specifier = ">=6.0" },
|
| 59 |
{ name = "rich", specifier = ">=13.7.0" },
|
|
|
|
| 72 |
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
|
| 73 |
]
|
| 74 |
|
| 75 |
+
[[package]]
|
| 76 |
+
name = "anthropic"
|
| 77 |
+
version = "0.75.0"
|
| 78 |
+
source = { registry = "https://pypi.org/simple" }
|
| 79 |
+
dependencies = [
|
| 80 |
+
{ name = "anyio" },
|
| 81 |
+
{ name = "distro" },
|
| 82 |
+
{ name = "docstring-parser" },
|
| 83 |
+
{ name = "httpx" },
|
| 84 |
+
{ name = "jiter" },
|
| 85 |
+
{ name = "pydantic" },
|
| 86 |
+
{ name = "sniffio" },
|
| 87 |
+
{ name = "typing-extensions" },
|
| 88 |
+
]
|
| 89 |
+
sdist = { url = "https://files.pythonhosted.org/packages/04/1f/08e95f4b7e2d35205ae5dcbb4ae97e7d477fc521c275c02609e2931ece2d/anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb", size = 439565 }
|
| 90 |
+
wheels = [
|
| 91 |
+
{ url = "https://files.pythonhosted.org/packages/60/1c/1cd02b7ae64302a6e06724bf80a96401d5313708651d277b1458504a1730/anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b", size = 388164 },
|
| 92 |
+
]
|
| 93 |
+
|
| 94 |
[[package]]
|
| 95 |
name = "anyio"
|
| 96 |
version = "4.12.0"
|
|
|
|
| 376 |
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 },
|
| 377 |
]
|
| 378 |
|
| 379 |
+
[[package]]
|
| 380 |
+
name = "docstring-parser"
|
| 381 |
+
version = "0.17.0"
|
| 382 |
+
source = { registry = "https://pypi.org/simple" }
|
| 383 |
+
sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442 }
|
| 384 |
+
wheels = [
|
| 385 |
+
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896 },
|
| 386 |
+
]
|
| 387 |
+
|
| 388 |
[[package]]
|
| 389 |
name = "h11"
|
| 390 |
version = "0.16.0"
|
|
|
|
| 940 |
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 },
|
| 941 |
]
|
| 942 |
|
| 943 |
+
[[package]]
|
| 944 |
+
name = "pytest-asyncio"
|
| 945 |
+
version = "1.3.0"
|
| 946 |
+
source = { registry = "https://pypi.org/simple" }
|
| 947 |
+
dependencies = [
|
| 948 |
+
{ name = "pytest" },
|
| 949 |
+
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
| 950 |
+
]
|
| 951 |
+
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087 }
|
| 952 |
+
wheels = [
|
| 953 |
+
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 },
|
| 954 |
+
]
|
| 955 |
+
|
| 956 |
[[package]]
|
| 957 |
name = "pytest-cov"
|
| 958 |
version = "7.0.0"
|