moelove commited on
Commit
fb6b08c
·
unverified ·
1 Parent(s): a9455d5

add anthropic and open_response LLM format support

Browse files

Signed-off-by: Jintao Zhang <zhangjintao9020@gmail.com>

Files changed (7) hide show
  1. README.md +24 -3
  2. pyproject.toml +11 -8
  3. src/amcp/agent.py +26 -35
  4. src/amcp/config.py +5 -0
  5. src/amcp/llm.py +208 -0
  6. tests/test_llm.py +72 -0
  7. 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
- base_url = "https://inference.baseten.co/v1"
140
- model = "DeepSeek-V3.1-Terminus"
 
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
- base = _resolve_base_url(self.agent_spec.base_url or None, cfg.chat)
429
- model = self.agent_spec.model or (cfg.chat.model if cfg.chat else "") or "DeepSeek-V3.1-Terminus"
430
- key = _resolve_api_key(None, cfg.chat)
431
- client = _make_client(base, key)
 
 
 
 
432
 
433
  # Override the chat function to add our tracking
434
  return await self._enhanced_chat_with_tools(
435
- client=client,
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
- client,
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 = client.chat.completions.create(
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.function.name
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 = client.chat.completions.create(
503
- model=model,
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.function.name
518
- args = json.loads(tc.function.arguments or "{}")
 
519
 
520
  # Record tool call
521
  tool_call_record = {
522
  "step": self.step_count,
523
  "tool": tool_name,
524
- "args": tc.function.arguments,
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": msg.content or "",
581
  "tool_calls": [
582
  {
583
- "id": tc.id,
584
  "type": "function",
585
- "function": {"name": tool_name, "arguments": tc.function.arguments or "{}"},
586
  }
587
  ],
588
  }
@@ -590,7 +581,7 @@ class Agent:
590
  messages.append(
591
  {
592
  "role": "tool",
593
- "tool_call_id": tc.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": tc.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 = msg.content or ""
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"