qa296 commited on
Commit
f6e9077
·
1 Parent(s): 8e28e08

refactor(agent): decouple agent from Anthropic SDK dependency

Browse files

Replace direct anthropic.AsyncAnthropic usage with a provider-agnostic
BaseLLMClient interface. This architectural change removes the tight
coupling between the agent loop and Anthropic's SDK, allowing the
system to work with different LLM providers through a unified API.

The agent loop now receives an abstract client instead of a concrete
implementation, enabling configuration-driven provider selection at
runtime without code changes.

Files changed (6) hide show
  1. .env.example +7 -2
  2. agent/agent_loop.py +13 -27
  3. agent/llm_client.py +264 -0
  4. config.yaml +3 -1
  5. main.py +6 -5
  6. requirements.txt +1 -0
.env.example CHANGED
@@ -1,5 +1,10 @@
1
- # Anthropic API Key (required)
2
- ANTHROPIC_API_KEY=your-api-key-here
 
 
 
 
 
3
 
4
  # Environment overrides (optional)
5
  # DOCKER_CONTAINER=1
 
1
+ # API Configuration
2
+ # Option 1: Anthropic API (default)
3
+ # ANTHROPIC_API_KEY=your-api-key-here
4
+
5
+ # Option 2: OpenAI-compatible API
6
+ OPENAI_API_KEY=your-api-key-here
7
+ OPENAI_BASE_URL=https://api.openai.com/v1
8
 
9
  # Environment overrides (optional)
10
  # DOCKER_CONTAINER=1
agent/agent_loop.py CHANGED
@@ -4,10 +4,10 @@ from __future__ import annotations
4
 
5
  from pathlib import Path
6
 
7
- import anthropic
8
  from loguru import logger
9
 
10
  from agent.context_manager import ContextManager
 
11
  from tools.bash_tool import run_bash
12
  from tools.file_tools import run_read, run_write, run_edit
13
  from tools.memory_tools import run_remember, run_recall, run_journal
@@ -153,7 +153,7 @@ class AgentLoop:
153
 
154
  def __init__(
155
  self,
156
- client: anthropic.AsyncAnthropic,
157
  system_prompt: str,
158
  model: str = "claude-sonnet-4-5-20250929",
159
  max_tokens: int = 8000,
@@ -190,14 +190,14 @@ class AgentLoop:
190
 
191
  # Call LLM
192
  try:
193
- response = await self.client.messages.create(
194
  model=self.model,
195
- system=self.system_prompt,
196
  messages=messages,
197
  tools=all_tools,
198
  max_tokens=self.max_tokens,
199
  )
200
- except anthropic.APIError as e:
201
  logger.error(f"API error: {e}")
202
  messages.append({
203
  "role": "assistant",
@@ -206,24 +206,23 @@ class AgentLoop:
206
  break
207
 
208
  # Extract text and tool calls
209
- tool_calls = []
210
- for block in response.content:
211
- if hasattr(block, "text") and block.text:
212
- logger.info(f"Assistant: {block.text[:200]}")
213
- if block.type == "tool_use":
214
- tool_calls.append(block)
215
 
216
  # If no tool calls, conversation turn is done
217
  if response.stop_reason != "tool_use":
218
  messages.append({
219
  "role": "assistant",
220
- "content": self._serialize_content(response.content),
221
  })
222
  break
223
 
224
  # Execute tools and collect results
225
  results = []
226
- for tc in tool_calls:
227
  logger.info(f"Tool: {tc.name}({_preview_args(tc.input)})")
228
  output = await self._execute_tool(tc.name, tc.input)
229
  logger.debug(f"Result: {output[:200]}")
@@ -236,7 +235,7 @@ class AgentLoop:
236
  # Append assistant message and tool results
237
  messages.append({
238
  "role": "assistant",
239
- "content": self._serialize_content(response.content),
240
  })
241
  messages.append({
242
  "role": "user",
@@ -292,19 +291,6 @@ class AgentLoop:
292
  logger.error(f"Tool execution error ({name}): {e}")
293
  return f"Error executing {name}: {e}"
294
 
295
- @staticmethod
296
- def _serialize_content(content) -> list[dict]:
297
- """Convert Anthropic SDK content blocks to serializable dicts."""
298
- result = []
299
- for block in content:
300
- if hasattr(block, "model_dump"):
301
- result.append(block.model_dump())
302
- elif isinstance(block, dict):
303
- result.append(block)
304
- else:
305
- result.append({"type": "text", "text": str(block)})
306
- return result
307
-
308
 
309
  def _preview_args(args: dict, max_len: int = 100) -> str:
310
  """Create a short preview of tool arguments for logging."""
 
4
 
5
  from pathlib import Path
6
 
 
7
  from loguru import logger
8
 
9
  from agent.context_manager import ContextManager
10
+ from agent.llm_client import BaseLLMClient, create_client
11
  from tools.bash_tool import run_bash
12
  from tools.file_tools import run_read, run_write, run_edit
13
  from tools.memory_tools import run_remember, run_recall, run_journal
 
153
 
154
  def __init__(
155
  self,
156
+ client: BaseLLMClient,
157
  system_prompt: str,
158
  model: str = "claude-sonnet-4-5-20250929",
159
  max_tokens: int = 8000,
 
190
 
191
  # Call LLM
192
  try:
193
+ response = await self.client.create_message(
194
  model=self.model,
195
+ system_prompt=self.system_prompt,
196
  messages=messages,
197
  tools=all_tools,
198
  max_tokens=self.max_tokens,
199
  )
200
+ except Exception as e:
201
  logger.error(f"API error: {e}")
202
  messages.append({
203
  "role": "assistant",
 
206
  break
207
 
208
  # Extract text and tool calls
209
+ for block in response.content_blocks:
210
+ if isinstance(block, dict) and block.get("type") == "text":
211
+ text = block.get("text", "")
212
+ if text:
213
+ logger.info(f"Assistant: {text[:200]}")
 
214
 
215
  # If no tool calls, conversation turn is done
216
  if response.stop_reason != "tool_use":
217
  messages.append({
218
  "role": "assistant",
219
+ "content": response.content_blocks,
220
  })
221
  break
222
 
223
  # Execute tools and collect results
224
  results = []
225
+ for tc in response.tool_calls:
226
  logger.info(f"Tool: {tc.name}({_preview_args(tc.input)})")
227
  output = await self._execute_tool(tc.name, tc.input)
228
  logger.debug(f"Result: {output[:200]}")
 
235
  # Append assistant message and tool results
236
  messages.append({
237
  "role": "assistant",
238
+ "content": response.content_blocks,
239
  })
240
  messages.append({
241
  "role": "user",
 
291
  logger.error(f"Tool execution error ({name}): {e}")
292
  return f"Error executing {name}: {e}"
293
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
 
295
  def _preview_args(args: dict, max_len: int = 100) -> str:
296
  """Create a short preview of tool arguments for logging."""
agent/llm_client.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM Client abstraction layer - supports Anthropic and OpenAI-compatible APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from abc import ABC, abstractmethod
7
+ from typing import Any
8
+
9
+ from loguru import logger
10
+
11
+
12
+ class BaseLLMClient(ABC):
13
+ """Abstract base class for LLM clients."""
14
+
15
+ @abstractmethod
16
+ async def create_message(
17
+ self,
18
+ model: str,
19
+ system_prompt: str,
20
+ messages: list[dict],
21
+ tools: list[dict],
22
+ max_tokens: int,
23
+ ) -> LLMResponse:
24
+ """Create a message and return standardized response."""
25
+ pass
26
+
27
+
28
+ class LLMResponse:
29
+ """Standardized LLM response."""
30
+
31
+ def __init__(
32
+ self,
33
+ content_blocks: list[dict],
34
+ stop_reason: str,
35
+ tool_calls: list[ToolCall] | None = None,
36
+ ):
37
+ self.content_blocks = content_blocks
38
+ self.stop_reason = stop_reason
39
+ self.tool_calls = tool_calls or []
40
+
41
+
42
+ class ToolCall:
43
+ """Standardized tool call."""
44
+
45
+ def __init__(self, id: str, name: str, input: dict):
46
+ self.id = id
47
+ self.name = name
48
+ self.input = input
49
+
50
+
51
+ class AnthropicClient(BaseLLMClient):
52
+ """Anthropic API client."""
53
+
54
+ def __init__(self):
55
+ import anthropic
56
+ self._client = anthropic.AsyncAnthropic()
57
+
58
+ async def create_message(
59
+ self,
60
+ model: str,
61
+ system_prompt: str,
62
+ messages: list[dict],
63
+ tools: list[dict],
64
+ max_tokens: int,
65
+ ) -> LLMResponse:
66
+ """Create message using Anthropic API."""
67
+ response = await self._client.messages.create(
68
+ model=model,
69
+ system=system_prompt,
70
+ messages=messages,
71
+ tools=tools,
72
+ max_tokens=max_tokens,
73
+ )
74
+
75
+ # Extract content blocks
76
+ content_blocks = []
77
+ tool_calls = []
78
+ for block in response.content:
79
+ if hasattr(block, "model_dump"):
80
+ content_blocks.append(block.model_dump())
81
+ elif isinstance(block, dict):
82
+ content_blocks.append(block)
83
+ else:
84
+ content_blocks.append({"type": "text", "text": str(block)})
85
+
86
+ if hasattr(block, "type") and block.type == "tool_use":
87
+ tool_calls.append(ToolCall(
88
+ id=block.id,
89
+ name=block.name,
90
+ input=block.input,
91
+ ))
92
+
93
+ return LLMResponse(
94
+ content_blocks=content_blocks,
95
+ stop_reason=response.stop_reason,
96
+ tool_calls=tool_calls,
97
+ )
98
+
99
+
100
+ class OpenAIClient(BaseLLMClient):
101
+ """OpenAI-compatible API client."""
102
+
103
+ def __init__(self, base_url: str | None = None):
104
+ from openai import AsyncOpenAI
105
+ self._client = AsyncOpenAI(
106
+ api_key=os.environ.get("OPENAI_API_KEY"),
107
+ base_url=base_url or os.environ.get("OPENAI_BASE_URL"),
108
+ )
109
+
110
+ async def create_message(
111
+ self,
112
+ model: str,
113
+ system_prompt: str,
114
+ messages: list[dict],
115
+ tools: list[dict],
116
+ max_tokens: int,
117
+ ) -> LLMResponse:
118
+ """Create message using OpenAI-compatible API."""
119
+ # Convert Anthropic-style tools to OpenAI format
120
+ openai_tools = self._convert_tools(tools)
121
+
122
+ # Build message list with system prompt
123
+ all_messages = [{"role": "system", "content": system_prompt}]
124
+
125
+ # Convert messages
126
+ for msg in messages:
127
+ converted = self._convert_message(msg)
128
+ if converted:
129
+ all_messages.append(converted)
130
+
131
+ response = await self._client.chat.completions.create(
132
+ model=model,
133
+ messages=all_messages,
134
+ tools=openai_tools if openai_tools else None,
135
+ max_tokens=max_tokens,
136
+ )
137
+
138
+ choice = response.choices[0]
139
+
140
+ # Extract content
141
+ content_blocks = []
142
+ tool_calls = []
143
+
144
+ if choice.message.content:
145
+ content_blocks.append({"type": "text", "text": choice.message.content})
146
+
147
+ if choice.message.tool_calls:
148
+ for tc in choice.message.tool_calls:
149
+ import json
150
+ tool_calls.append(ToolCall(
151
+ id=tc.id,
152
+ name=tc.function.name,
153
+ input=json.loads(tc.function.arguments) if tc.function.arguments else {},
154
+ ))
155
+ content_blocks.append({
156
+ "type": "tool_use",
157
+ "id": tc.id,
158
+ "name": tc.function.name,
159
+ "input": json.loads(tc.function.arguments) if tc.function.arguments else {},
160
+ })
161
+
162
+ stop_reason = "tool_use" if tool_calls else "end_turn"
163
+ if choice.finish_reason == "length":
164
+ stop_reason = "max_tokens"
165
+
166
+ return LLMResponse(
167
+ content_blocks=content_blocks,
168
+ stop_reason=stop_reason,
169
+ tool_calls=tool_calls,
170
+ )
171
+
172
+ def _convert_tools(self, anthropic_tools: list[dict]) -> list[dict]:
173
+ """Convert Anthropic-style tools to OpenAI format."""
174
+ openai_tools = []
175
+ for tool in anthropic_tools:
176
+ openai_tools.append({
177
+ "type": "function",
178
+ "function": {
179
+ "name": tool["name"],
180
+ "description": tool.get("description", ""),
181
+ "parameters": tool.get("input_schema", {}),
182
+ }
183
+ })
184
+ return openai_tools
185
+
186
+ def _convert_message(self, msg: dict) -> dict | None:
187
+ """Convert Anthropic-style message to OpenAI format."""
188
+ role = msg.get("role")
189
+ content = msg.get("content")
190
+
191
+ if role == "user":
192
+ if isinstance(content, str):
193
+ return {"role": "user", "content": content}
194
+ elif isinstance(content, list):
195
+ # Handle tool results
196
+ texts = []
197
+ tool_results = []
198
+ for block in content:
199
+ if isinstance(block, dict):
200
+ if block.get("type") == "tool_result":
201
+ tool_results.append(block)
202
+ elif block.get("type") == "text":
203
+ texts.append(block.get("text", ""))
204
+
205
+ if tool_results:
206
+ # Convert tool results to OpenAI format
207
+ result_msg = {"role": "tool", "content": ""}
208
+ for tr in tool_results:
209
+ result_msg["tool_call_id"] = tr.get("tool_use_id", "")
210
+ result_msg["content"] = tr.get("content", "")
211
+ return result_msg
212
+ elif texts:
213
+ return {"role": "user", "content": "\n".join(texts)}
214
+
215
+ elif role == "assistant":
216
+ if isinstance(content, str):
217
+ return {"role": "assistant", "content": content}
218
+ elif isinstance(content, list):
219
+ texts = []
220
+ tool_uses = []
221
+ for block in content:
222
+ if isinstance(block, dict):
223
+ if block.get("type") == "text":
224
+ texts.append(block.get("text", ""))
225
+ elif block.get("type") == "tool_use":
226
+ tool_uses.append(block)
227
+
228
+ result = {"role": "assistant"}
229
+ if texts:
230
+ result["content"] = "\n".join(texts)
231
+ if tool_uses:
232
+ import json
233
+ result["tool_calls"] = [
234
+ {
235
+ "id": tu.get("id", ""),
236
+ "type": "function",
237
+ "function": {
238
+ "name": tu.get("name", ""),
239
+ "arguments": json.dumps(tu.get("input", {})),
240
+ }
241
+ }
242
+ for tu in tool_uses
243
+ ]
244
+ return result
245
+
246
+ return None
247
+
248
+
249
+ def create_client(provider: str = "anthropic", base_url: str | None = None) -> BaseLLMClient:
250
+ """Factory function to create LLM client based on provider.
251
+
252
+ Args:
253
+ provider: "anthropic" or "openai"
254
+ base_url: Base URL for OpenAI-compatible API (optional)
255
+
256
+ Returns:
257
+ LLM client instance.
258
+ """
259
+ if provider == "openai":
260
+ logger.info("Using OpenAI-compatible API")
261
+ return OpenAIClient(base_url=base_url)
262
+ else:
263
+ logger.info("Using Anthropic API")
264
+ return AnthropicClient()
config.yaml CHANGED
@@ -1,6 +1,8 @@
1
  # Agent配置
2
  agent:
3
- model: "claude-sonnet-4-5-20250929"
 
 
4
  max_tokens: 8000
5
  temperature: 0.7
6
 
 
1
  # Agent配置
2
  agent:
3
+ # API provider: "anthropic" or "openai"
4
+ provider: "openai"
5
+ model: "z-ai/glm-4.5-air:free"
6
  max_tokens: 8000
7
  temperature: 0.7
8
 
main.py CHANGED
@@ -10,13 +10,13 @@ import sys
10
  from datetime import datetime
11
  from pathlib import Path
12
 
13
- import anthropic
14
  import yaml
15
  from dotenv import load_dotenv
16
  from loguru import logger
17
 
18
  from agent.agent_loop import AgentLoop
19
  from agent.context_manager import ContextManager
 
20
  from agent.message_history import MessageHistory
21
  from agent.system_prompt import build_system_prompt
22
  from browser.client import BrowserClient
@@ -52,7 +52,7 @@ class DigitalLife:
52
  self.log_path = env.get_log_path()
53
 
54
  # Components (initialized in _initialize)
55
- self.client: anthropic.AsyncAnthropic | None = None
56
  self.memory_manager: MemoryManager | None = None
57
  self.plugin_manager: PluginManager | None = None
58
  self.browser_client: BrowserClient | None = None
@@ -83,11 +83,12 @@ class DigitalLife:
83
  logger.info(f"Environment: {env.env.value}")
84
  logger.info(f"Data directory: {env.data_dir}")
85
 
86
- # Anthropic client
87
- self.client = anthropic.AsyncAnthropic()
 
 
88
 
89
  # Model config
90
- agent_config = self.config.get("agent", {})
91
  model = agent_config.get("model", "claude-sonnet-4-5-20250929")
92
  max_tokens = agent_config.get("max_tokens", 8000)
93
 
 
10
  from datetime import datetime
11
  from pathlib import Path
12
 
 
13
  import yaml
14
  from dotenv import load_dotenv
15
  from loguru import logger
16
 
17
  from agent.agent_loop import AgentLoop
18
  from agent.context_manager import ContextManager
19
+ from agent.llm_client import BaseLLMClient, create_client
20
  from agent.message_history import MessageHistory
21
  from agent.system_prompt import build_system_prompt
22
  from browser.client import BrowserClient
 
52
  self.log_path = env.get_log_path()
53
 
54
  # Components (initialized in _initialize)
55
+ self.client: BaseLLMClient | None = None
56
  self.memory_manager: MemoryManager | None = None
57
  self.plugin_manager: PluginManager | None = None
58
  self.browser_client: BrowserClient | None = None
 
83
  logger.info(f"Environment: {env.env.value}")
84
  logger.info(f"Data directory: {env.data_dir}")
85
 
86
+ # LLM client
87
+ agent_config = self.config.get("agent", {})
88
+ provider = agent_config.get("provider", "anthropic")
89
+ self.client = create_client(provider)
90
 
91
  # Model config
 
92
  model = agent_config.get("model", "claude-sonnet-4-5-20250929")
93
  max_tokens = agent_config.get("max_tokens", 8000)
94
 
requirements.txt CHANGED
@@ -1,5 +1,6 @@
1
  # Core
2
  anthropic>=0.40.0
 
3
  pyyaml>=6.0
4
  python-dotenv>=1.0.0
5
 
 
1
  # Core
2
  anthropic>=0.40.0
3
+ openai>=1.0.0
4
  pyyaml>=6.0
5
  python-dotenv>=1.0.0
6