agent-runtime-core 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,237 @@
1
+ """
2
+ Anthropic API client implementation.
3
+ """
4
+
5
+ import os
6
+ from typing import AsyncIterator, Optional
7
+
8
+ from agent_runtime.interfaces import (
9
+ LLMClient,
10
+ LLMResponse,
11
+ LLMStreamChunk,
12
+ Message,
13
+ )
14
+
15
+ try:
16
+ from anthropic import AsyncAnthropic, APIError
17
+ except ImportError:
18
+ AsyncAnthropic = None
19
+ APIError = Exception
20
+
21
+
22
+ class AnthropicConfigurationError(Exception):
23
+ """Raised when Anthropic API key is not configured."""
24
+ pass
25
+
26
+
27
+ class AnthropicClient(LLMClient):
28
+ """
29
+ Anthropic API client.
30
+
31
+ Supports Claude models.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ api_key: Optional[str] = None,
37
+ default_model: Optional[str] = None,
38
+ **kwargs,
39
+ ):
40
+ if AsyncAnthropic is None:
41
+ raise ImportError(
42
+ "anthropic package is required for AnthropicClient. "
43
+ "Install it with: pip install agent_runtime[anthropic]"
44
+ )
45
+
46
+ from agent_runtime.config import get_config
47
+ config = get_config()
48
+
49
+ self.default_model = default_model or config.default_model or "claude-sonnet-4-20250514"
50
+
51
+ # Resolve API key with clear priority
52
+ resolved_api_key = self._resolve_api_key(api_key)
53
+
54
+ if not resolved_api_key:
55
+ raise AnthropicConfigurationError(
56
+ "Anthropic API key is not configured.\n\n"
57
+ "Configure it using one of these methods:\n"
58
+ " 1. Use configure():\n"
59
+ " from agent_runtime.config import configure\n"
60
+ " configure(anthropic_api_key='sk-ant-...')\n\n"
61
+ " 2. Set the ANTHROPIC_API_KEY environment variable:\n"
62
+ " export ANTHROPIC_API_KEY='sk-ant-...'\n\n"
63
+ " 3. Pass api_key directly to get_llm_client():\n"
64
+ " llm = get_llm_client(api_key='sk-ant-...')"
65
+ )
66
+
67
+ self._client = AsyncAnthropic(
68
+ api_key=resolved_api_key,
69
+ **kwargs,
70
+ )
71
+
72
+ def _resolve_api_key(self, explicit_key: Optional[str]) -> Optional[str]:
73
+ """
74
+ Resolve API key with clear priority order.
75
+
76
+ Priority:
77
+ 1. Explicit api_key parameter passed to __init__
78
+ 2. anthropic_api_key in config
79
+ 3. ANTHROPIC_API_KEY environment variable
80
+ """
81
+ if explicit_key:
82
+ return explicit_key
83
+
84
+ from agent_runtime.config import get_config
85
+ config = get_config()
86
+ settings_key = config.get_anthropic_api_key()
87
+ if settings_key:
88
+ return settings_key
89
+
90
+ return os.environ.get("ANTHROPIC_API_KEY")
91
+
92
+ async def generate(
93
+ self,
94
+ messages: list[Message],
95
+ *,
96
+ model: Optional[str] = None,
97
+ stream: bool = False,
98
+ tools: Optional[list[dict]] = None,
99
+ temperature: Optional[float] = None,
100
+ max_tokens: Optional[int] = None,
101
+ **kwargs,
102
+ ) -> LLMResponse:
103
+ """Generate a completion from Anthropic."""
104
+ model = model or self.default_model
105
+
106
+ # Extract system message
107
+ system_message = None
108
+ chat_messages = []
109
+ for msg in messages:
110
+ if msg.get("role") == "system":
111
+ system_message = msg.get("content", "")
112
+ else:
113
+ chat_messages.append(self._convert_message(msg))
114
+
115
+ request_kwargs = {
116
+ "model": model,
117
+ "messages": chat_messages,
118
+ "max_tokens": max_tokens or 4096,
119
+ }
120
+
121
+ if system_message:
122
+ request_kwargs["system"] = system_message
123
+ if tools:
124
+ request_kwargs["tools"] = self._convert_tools(tools)
125
+ if temperature is not None:
126
+ request_kwargs["temperature"] = temperature
127
+
128
+ request_kwargs.update(kwargs)
129
+
130
+ response = await self._client.messages.create(**request_kwargs)
131
+
132
+ return LLMResponse(
133
+ message=self._convert_response(response),
134
+ usage={
135
+ "prompt_tokens": response.usage.input_tokens,
136
+ "completion_tokens": response.usage.output_tokens,
137
+ "total_tokens": response.usage.input_tokens + response.usage.output_tokens,
138
+ },
139
+ model=response.model,
140
+ finish_reason=response.stop_reason or "",
141
+ raw_response=response,
142
+ )
143
+
144
+ async def stream(
145
+ self,
146
+ messages: list[Message],
147
+ *,
148
+ model: Optional[str] = None,
149
+ tools: Optional[list[dict]] = None,
150
+ **kwargs,
151
+ ) -> AsyncIterator[LLMStreamChunk]:
152
+ """Stream a completion from Anthropic."""
153
+ model = model or self.default_model
154
+
155
+ # Extract system message
156
+ system_message = None
157
+ chat_messages = []
158
+ for msg in messages:
159
+ if msg.get("role") == "system":
160
+ system_message = msg.get("content", "")
161
+ else:
162
+ chat_messages.append(self._convert_message(msg))
163
+
164
+ request_kwargs = {
165
+ "model": model,
166
+ "messages": chat_messages,
167
+ "max_tokens": kwargs.pop("max_tokens", 4096),
168
+ }
169
+
170
+ if system_message:
171
+ request_kwargs["system"] = system_message
172
+ if tools:
173
+ request_kwargs["tools"] = self._convert_tools(tools)
174
+
175
+ request_kwargs.update(kwargs)
176
+
177
+ async with self._client.messages.stream(**request_kwargs) as stream:
178
+ async for event in stream:
179
+ if event.type == "content_block_delta":
180
+ if hasattr(event.delta, "text"):
181
+ yield LLMStreamChunk(delta=event.delta.text)
182
+ elif event.type == "message_stop":
183
+ yield LLMStreamChunk(finish_reason="stop")
184
+
185
+ def _convert_message(self, msg: Message) -> dict:
186
+ """Convert our message format to Anthropic format."""
187
+ role = msg.get("role", "user")
188
+ if role == "assistant":
189
+ role = "assistant"
190
+ elif role == "tool":
191
+ role = "user" # Tool results go as user messages in Anthropic
192
+
193
+ return {
194
+ "role": role,
195
+ "content": msg.get("content", ""),
196
+ }
197
+
198
+ def _convert_tools(self, tools: list[dict]) -> list[dict]:
199
+ """Convert OpenAI tool format to Anthropic format."""
200
+ result = []
201
+ for tool in tools:
202
+ if tool.get("type") == "function":
203
+ func = tool["function"]
204
+ result.append({
205
+ "name": func["name"],
206
+ "description": func.get("description", ""),
207
+ "input_schema": func.get("parameters", {}),
208
+ })
209
+ return result
210
+
211
+ def _convert_response(self, response) -> Message:
212
+ """Convert Anthropic response to our format."""
213
+ content = ""
214
+ tool_calls = []
215
+
216
+ for block in response.content:
217
+ if block.type == "text":
218
+ content += block.text
219
+ elif block.type == "tool_use":
220
+ tool_calls.append({
221
+ "id": block.id,
222
+ "type": "function",
223
+ "function": {
224
+ "name": block.name,
225
+ "arguments": str(block.input),
226
+ },
227
+ })
228
+
229
+ result: Message = {
230
+ "role": "assistant",
231
+ "content": content,
232
+ }
233
+
234
+ if tool_calls:
235
+ result["tool_calls"] = tool_calls
236
+
237
+ return result
@@ -0,0 +1,175 @@
1
+ """
2
+ LiteLLM adapter for unified LLM access.
3
+
4
+ LiteLLM provides a unified interface to 100+ LLM providers.
5
+ This is an OPTIONAL adapter - the core runtime doesn't depend on it.
6
+ """
7
+
8
+ from typing import AsyncIterator, Optional
9
+
10
+ from agent_runtime.interfaces import (
11
+ LLMClient,
12
+ LLMResponse,
13
+ LLMStreamChunk,
14
+ Message,
15
+ )
16
+
17
+ try:
18
+ import litellm
19
+ from litellm import acompletion
20
+ except ImportError:
21
+ litellm = None
22
+ acompletion = None
23
+
24
+
25
+ class LiteLLMClient(LLMClient):
26
+ """
27
+ LiteLLM adapter for unified LLM access.
28
+
29
+ Supports 100+ providers through LiteLLM's unified interface.
30
+ See: https://docs.litellm.ai/docs/providers
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ default_model: Optional[str] = None,
36
+ **kwargs,
37
+ ):
38
+ if litellm is None:
39
+ raise ImportError(
40
+ "litellm package is required for LiteLLMClient. "
41
+ "Install it with: pip install agent_runtime[litellm]"
42
+ )
43
+
44
+ from agent_runtime.config import get_config
45
+ config = get_config()
46
+
47
+ self.default_model = default_model or config.default_model or "gpt-4o"
48
+ self._kwargs = kwargs
49
+
50
+ async def generate(
51
+ self,
52
+ messages: list[Message],
53
+ *,
54
+ model: Optional[str] = None,
55
+ stream: bool = False,
56
+ tools: Optional[list[dict]] = None,
57
+ temperature: Optional[float] = None,
58
+ max_tokens: Optional[int] = None,
59
+ **kwargs,
60
+ ) -> LLMResponse:
61
+ """Generate a completion using LiteLLM."""
62
+ model = model or self.default_model
63
+
64
+ request_kwargs = {
65
+ "model": model,
66
+ "messages": self._convert_messages(messages),
67
+ }
68
+
69
+ if tools:
70
+ request_kwargs["tools"] = tools
71
+ if temperature is not None:
72
+ request_kwargs["temperature"] = temperature
73
+ if max_tokens is not None:
74
+ request_kwargs["max_tokens"] = max_tokens
75
+
76
+ request_kwargs.update(self._kwargs)
77
+ request_kwargs.update(kwargs)
78
+
79
+ response = await acompletion(**request_kwargs)
80
+
81
+ choice = response.choices[0]
82
+ message = choice.message
83
+
84
+ return LLMResponse(
85
+ message=self._convert_response_message(message),
86
+ usage={
87
+ "prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
88
+ "completion_tokens": response.usage.completion_tokens if response.usage else 0,
89
+ "total_tokens": response.usage.total_tokens if response.usage else 0,
90
+ },
91
+ model=response.model or model,
92
+ finish_reason=choice.finish_reason or "",
93
+ raw_response=response,
94
+ )
95
+
96
+ async def stream(
97
+ self,
98
+ messages: list[Message],
99
+ *,
100
+ model: Optional[str] = None,
101
+ tools: Optional[list[dict]] = None,
102
+ **kwargs,
103
+ ) -> AsyncIterator[LLMStreamChunk]:
104
+ """Stream a completion using LiteLLM."""
105
+ model = model or self.default_model
106
+
107
+ request_kwargs = {
108
+ "model": model,
109
+ "messages": self._convert_messages(messages),
110
+ "stream": True,
111
+ }
112
+
113
+ if tools:
114
+ request_kwargs["tools"] = tools
115
+
116
+ request_kwargs.update(self._kwargs)
117
+ request_kwargs.update(kwargs)
118
+
119
+ response = await acompletion(**request_kwargs)
120
+
121
+ async for chunk in response:
122
+ if not chunk.choices:
123
+ continue
124
+
125
+ choice = chunk.choices[0]
126
+ delta = choice.delta
127
+
128
+ yield LLMStreamChunk(
129
+ delta=getattr(delta, "content", "") or "",
130
+ tool_calls=getattr(delta, "tool_calls", None),
131
+ finish_reason=choice.finish_reason,
132
+ usage=None,
133
+ )
134
+
135
+ def _convert_messages(self, messages: list[Message]) -> list[dict]:
136
+ """Convert our message format to LiteLLM format (OpenAI-compatible)."""
137
+ result = []
138
+ for msg in messages:
139
+ converted = {
140
+ "role": msg.get("role", "user"),
141
+ "content": msg.get("content", ""),
142
+ }
143
+
144
+ if msg.get("name"):
145
+ converted["name"] = msg["name"]
146
+ if msg.get("tool_call_id"):
147
+ converted["tool_call_id"] = msg["tool_call_id"]
148
+ if msg.get("tool_calls"):
149
+ converted["tool_calls"] = msg["tool_calls"]
150
+
151
+ result.append(converted)
152
+
153
+ return result
154
+
155
+ def _convert_response_message(self, message) -> Message:
156
+ """Convert LiteLLM response message to our format."""
157
+ result: Message = {
158
+ "role": message.role,
159
+ "content": message.content or "",
160
+ }
161
+
162
+ if hasattr(message, "tool_calls") and message.tool_calls:
163
+ result["tool_calls"] = [
164
+ {
165
+ "id": tc.id,
166
+ "type": tc.type,
167
+ "function": {
168
+ "name": tc.function.name,
169
+ "arguments": tc.function.arguments,
170
+ },
171
+ }
172
+ for tc in message.tool_calls
173
+ ]
174
+
175
+ return result
@@ -0,0 +1,220 @@
1
+ """
2
+ OpenAI API client implementation.
3
+ """
4
+
5
+ import os
6
+ from typing import AsyncIterator, Optional
7
+
8
+ from agent_runtime.interfaces import (
9
+ LLMClient,
10
+ LLMResponse,
11
+ LLMStreamChunk,
12
+ Message,
13
+ )
14
+
15
+ try:
16
+ from openai import AsyncOpenAI, OpenAIError
17
+ except ImportError:
18
+ AsyncOpenAI = None
19
+ OpenAIError = Exception
20
+
21
+
22
+ class OpenAIConfigurationError(Exception):
23
+ """Raised when OpenAI API key is not configured."""
24
+ pass
25
+
26
+
27
+ class OpenAIClient(LLMClient):
28
+ """
29
+ OpenAI API client.
30
+
31
+ Supports GPT-4, GPT-3.5, and other OpenAI models.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ api_key: Optional[str] = None,
37
+ default_model: Optional[str] = None,
38
+ organization: Optional[str] = None,
39
+ base_url: Optional[str] = None,
40
+ **kwargs,
41
+ ):
42
+ if AsyncOpenAI is None:
43
+ raise ImportError(
44
+ "openai package is required for OpenAIClient. "
45
+ "Install it with: pip install agent_runtime[openai]"
46
+ )
47
+
48
+ from agent_runtime.config import get_config
49
+ config = get_config()
50
+
51
+ self.default_model = default_model or config.default_model
52
+
53
+ # Resolve API key with clear priority
54
+ resolved_api_key = self._resolve_api_key(api_key)
55
+
56
+ try:
57
+ self._client = AsyncOpenAI(
58
+ api_key=resolved_api_key,
59
+ organization=organization,
60
+ base_url=base_url,
61
+ **kwargs,
62
+ )
63
+ except OpenAIError as e:
64
+ if "api_key" in str(e).lower():
65
+ raise OpenAIConfigurationError(
66
+ "OpenAI API key is not configured.\n\n"
67
+ "Configure it using one of these methods:\n"
68
+ " 1. Use configure():\n"
69
+ " from agent_runtime.config import configure\n"
70
+ " configure(openai_api_key='sk-...')\n\n"
71
+ " 2. Set the OPENAI_API_KEY environment variable:\n"
72
+ " export OPENAI_API_KEY='sk-...'\n\n"
73
+ " 3. Pass api_key directly to get_llm_client():\n"
74
+ " llm = get_llm_client(api_key='sk-...')"
75
+ ) from e
76
+ raise
77
+
78
+ def _resolve_api_key(self, explicit_key: Optional[str]) -> Optional[str]:
79
+ """
80
+ Resolve API key with clear priority order.
81
+
82
+ Priority:
83
+ 1. Explicit api_key parameter passed to __init__
84
+ 2. openai_api_key in config
85
+ 3. OPENAI_API_KEY environment variable
86
+ """
87
+ if explicit_key:
88
+ return explicit_key
89
+
90
+ from agent_runtime.config import get_config
91
+ config = get_config()
92
+ settings_key = config.get_openai_api_key()
93
+ if settings_key:
94
+ return settings_key
95
+
96
+ return os.environ.get("OPENAI_API_KEY")
97
+
98
+ async def generate(
99
+ self,
100
+ messages: list[Message],
101
+ *,
102
+ model: Optional[str] = None,
103
+ stream: bool = False,
104
+ tools: Optional[list[dict]] = None,
105
+ temperature: Optional[float] = None,
106
+ max_tokens: Optional[int] = None,
107
+ **kwargs,
108
+ ) -> LLMResponse:
109
+ """Generate a completion from OpenAI."""
110
+ model = model or self.default_model
111
+
112
+ request_kwargs = {
113
+ "model": model,
114
+ "messages": self._convert_messages(messages),
115
+ }
116
+
117
+ if tools:
118
+ request_kwargs["tools"] = tools
119
+ if temperature is not None:
120
+ request_kwargs["temperature"] = temperature
121
+ if max_tokens is not None:
122
+ request_kwargs["max_tokens"] = max_tokens
123
+
124
+ request_kwargs.update(kwargs)
125
+
126
+ response = await self._client.chat.completions.create(**request_kwargs)
127
+
128
+ choice = response.choices[0]
129
+ message = choice.message
130
+
131
+ return LLMResponse(
132
+ message=self._convert_response_message(message),
133
+ usage={
134
+ "prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
135
+ "completion_tokens": response.usage.completion_tokens if response.usage else 0,
136
+ "total_tokens": response.usage.total_tokens if response.usage else 0,
137
+ },
138
+ model=response.model,
139
+ finish_reason=choice.finish_reason or "",
140
+ raw_response=response,
141
+ )
142
+
143
+ async def stream(
144
+ self,
145
+ messages: list[Message],
146
+ *,
147
+ model: Optional[str] = None,
148
+ tools: Optional[list[dict]] = None,
149
+ **kwargs,
150
+ ) -> AsyncIterator[LLMStreamChunk]:
151
+ """Stream a completion from OpenAI."""
152
+ model = model or self.default_model
153
+
154
+ request_kwargs = {
155
+ "model": model,
156
+ "messages": self._convert_messages(messages),
157
+ "stream": True,
158
+ }
159
+
160
+ if tools:
161
+ request_kwargs["tools"] = tools
162
+
163
+ request_kwargs.update(kwargs)
164
+
165
+ async with await self._client.chat.completions.create(**request_kwargs) as stream:
166
+ async for chunk in stream:
167
+ if not chunk.choices:
168
+ continue
169
+
170
+ choice = chunk.choices[0]
171
+ delta = choice.delta
172
+
173
+ yield LLMStreamChunk(
174
+ delta=delta.content or "",
175
+ tool_calls=delta.tool_calls if hasattr(delta, "tool_calls") else None,
176
+ finish_reason=choice.finish_reason,
177
+ usage=None,
178
+ )
179
+
180
+ def _convert_messages(self, messages: list[Message]) -> list[dict]:
181
+ """Convert our message format to OpenAI format."""
182
+ result = []
183
+ for msg in messages:
184
+ converted = {
185
+ "role": msg.get("role", "user"),
186
+ "content": msg.get("content", ""),
187
+ }
188
+
189
+ if msg.get("name"):
190
+ converted["name"] = msg["name"]
191
+ if msg.get("tool_call_id"):
192
+ converted["tool_call_id"] = msg["tool_call_id"]
193
+ if msg.get("tool_calls"):
194
+ converted["tool_calls"] = msg["tool_calls"]
195
+
196
+ result.append(converted)
197
+
198
+ return result
199
+
200
+ def _convert_response_message(self, message) -> Message:
201
+ """Convert OpenAI response message to our format."""
202
+ result: Message = {
203
+ "role": message.role,
204
+ "content": message.content or "",
205
+ }
206
+
207
+ if message.tool_calls:
208
+ result["tool_calls"] = [
209
+ {
210
+ "id": tc.id,
211
+ "type": tc.type,
212
+ "function": {
213
+ "name": tc.function.name,
214
+ "arguments": tc.function.arguments,
215
+ },
216
+ }
217
+ for tc in message.tool_calls
218
+ ]
219
+
220
+ return result
@@ -0,0 +1,55 @@
1
+ """
2
+ Queue implementations for agent run scheduling.
3
+
4
+ Provides:
5
+ - RunQueue: Abstract interface
6
+ - QueuedRun: Data structure for queued runs
7
+ - InMemoryQueue: For testing and simple use cases
8
+ - RedisQueue: For production with Redis
9
+ - SQLiteQueue: For persistent local storage
10
+ """
11
+
12
+ from agent_runtime.queue.base import RunQueue, QueuedRun
13
+ from agent_runtime.queue.memory import InMemoryQueue
14
+
15
+ __all__ = [
16
+ "RunQueue",
17
+ "QueuedRun",
18
+ "InMemoryQueue",
19
+ "get_queue",
20
+ ]
21
+
22
+
23
+ def get_queue(backend: str = None, **kwargs) -> RunQueue:
24
+ """
25
+ Factory function to get a run queue.
26
+
27
+ Args:
28
+ backend: "memory", "redis", or "sqlite"
29
+ **kwargs: Backend-specific configuration
30
+
31
+ Returns:
32
+ RunQueue instance
33
+ """
34
+ from agent_runtime.config import get_config
35
+
36
+ config = get_config()
37
+ backend = backend or config.queue_backend
38
+
39
+ if backend == "memory":
40
+ return InMemoryQueue()
41
+
42
+ elif backend == "redis":
43
+ from agent_runtime.queue.redis import RedisQueue
44
+ url = kwargs.get("url") or config.redis_url
45
+ if not url:
46
+ raise ValueError("redis_url is required for redis queue backend")
47
+ return RedisQueue(url=url)
48
+
49
+ elif backend == "sqlite":
50
+ from agent_runtime.queue.sqlite import SQLiteQueue
51
+ path = kwargs.get("path") or config.sqlite_path or "agent_runtime.db"
52
+ return SQLiteQueue(path=path)
53
+
54
+ else:
55
+ raise ValueError(f"Unknown queue backend: {backend}")