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.
- agent_runtime/__init__.py +110 -0
- agent_runtime/config.py +172 -0
- agent_runtime/events/__init__.py +55 -0
- agent_runtime/events/base.py +86 -0
- agent_runtime/events/memory.py +89 -0
- agent_runtime/events/redis.py +185 -0
- agent_runtime/events/sqlite.py +168 -0
- agent_runtime/interfaces.py +390 -0
- agent_runtime/llm/__init__.py +83 -0
- agent_runtime/llm/anthropic.py +237 -0
- agent_runtime/llm/litellm_client.py +175 -0
- agent_runtime/llm/openai.py +220 -0
- agent_runtime/queue/__init__.py +55 -0
- agent_runtime/queue/base.py +167 -0
- agent_runtime/queue/memory.py +184 -0
- agent_runtime/queue/redis.py +453 -0
- agent_runtime/queue/sqlite.py +420 -0
- agent_runtime/registry.py +74 -0
- agent_runtime/runner.py +403 -0
- agent_runtime/state/__init__.py +53 -0
- agent_runtime/state/base.py +69 -0
- agent_runtime/state/memory.py +51 -0
- agent_runtime/state/redis.py +109 -0
- agent_runtime/state/sqlite.py +158 -0
- agent_runtime/tracing/__init__.py +47 -0
- agent_runtime/tracing/langfuse.py +119 -0
- agent_runtime/tracing/noop.py +34 -0
- agent_runtime_core-0.1.0.dist-info/METADATA +75 -0
- agent_runtime_core-0.1.0.dist-info/RECORD +31 -0
- agent_runtime_core-0.1.0.dist-info/WHEEL +4 -0
- agent_runtime_core-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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}")
|