django-agent-runtime 0.3.6__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.
- django_agent_runtime/__init__.py +25 -0
- django_agent_runtime/admin.py +155 -0
- django_agent_runtime/api/__init__.py +26 -0
- django_agent_runtime/api/permissions.py +109 -0
- django_agent_runtime/api/serializers.py +114 -0
- django_agent_runtime/api/views.py +472 -0
- django_agent_runtime/apps.py +26 -0
- django_agent_runtime/conf.py +241 -0
- django_agent_runtime/examples/__init__.py +10 -0
- django_agent_runtime/examples/langgraph_adapter.py +164 -0
- django_agent_runtime/examples/langgraph_tools.py +179 -0
- django_agent_runtime/examples/simple_chat.py +69 -0
- django_agent_runtime/examples/tool_agent.py +157 -0
- django_agent_runtime/management/__init__.py +2 -0
- django_agent_runtime/management/commands/__init__.py +2 -0
- django_agent_runtime/management/commands/runagent.py +419 -0
- django_agent_runtime/migrations/0001_initial.py +117 -0
- django_agent_runtime/migrations/0002_persistence_models.py +129 -0
- django_agent_runtime/migrations/0003_persistenceconversation_active_branch_id_and_more.py +212 -0
- django_agent_runtime/migrations/0004_add_anonymous_session_id.py +18 -0
- django_agent_runtime/migrations/__init__.py +2 -0
- django_agent_runtime/models/__init__.py +54 -0
- django_agent_runtime/models/base.py +450 -0
- django_agent_runtime/models/concrete.py +146 -0
- django_agent_runtime/persistence/__init__.py +60 -0
- django_agent_runtime/persistence/helpers.py +148 -0
- django_agent_runtime/persistence/models.py +506 -0
- django_agent_runtime/persistence/stores.py +1191 -0
- django_agent_runtime/runtime/__init__.py +23 -0
- django_agent_runtime/runtime/events/__init__.py +65 -0
- django_agent_runtime/runtime/events/base.py +135 -0
- django_agent_runtime/runtime/events/db.py +129 -0
- django_agent_runtime/runtime/events/redis.py +228 -0
- django_agent_runtime/runtime/events/sync.py +140 -0
- django_agent_runtime/runtime/interfaces.py +475 -0
- django_agent_runtime/runtime/llm/__init__.py +91 -0
- django_agent_runtime/runtime/llm/anthropic.py +249 -0
- django_agent_runtime/runtime/llm/litellm_adapter.py +173 -0
- django_agent_runtime/runtime/llm/openai.py +230 -0
- django_agent_runtime/runtime/queue/__init__.py +75 -0
- django_agent_runtime/runtime/queue/base.py +158 -0
- django_agent_runtime/runtime/queue/postgres.py +248 -0
- django_agent_runtime/runtime/queue/redis_streams.py +336 -0
- django_agent_runtime/runtime/queue/sync.py +277 -0
- django_agent_runtime/runtime/registry.py +186 -0
- django_agent_runtime/runtime/runner.py +540 -0
- django_agent_runtime/runtime/tracing/__init__.py +48 -0
- django_agent_runtime/runtime/tracing/langfuse.py +117 -0
- django_agent_runtime/runtime/tracing/noop.py +36 -0
- django_agent_runtime/urls.py +39 -0
- django_agent_runtime-0.3.6.dist-info/METADATA +723 -0
- django_agent_runtime-0.3.6.dist-info/RECORD +55 -0
- django_agent_runtime-0.3.6.dist-info/WHEEL +5 -0
- django_agent_runtime-0.3.6.dist-info/licenses/LICENSE +22 -0
- django_agent_runtime-0.3.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Anthropic API client implementation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import AsyncIterator, Optional
|
|
7
|
+
|
|
8
|
+
from django_agent_runtime.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 = None
|
|
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: str = "claude-sonnet-4-20250514",
|
|
38
|
+
**kwargs,
|
|
39
|
+
):
|
|
40
|
+
if AsyncAnthropic is None:
|
|
41
|
+
raise ImportError(
|
|
42
|
+
"anthropic package is required for AnthropicClient. "
|
|
43
|
+
"Install it with: pip install anthropic"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
self.default_model = default_model
|
|
47
|
+
|
|
48
|
+
# Resolve API key with clear priority
|
|
49
|
+
resolved_api_key = self._resolve_api_key(api_key)
|
|
50
|
+
|
|
51
|
+
if not resolved_api_key:
|
|
52
|
+
raise AnthropicConfigurationError(
|
|
53
|
+
"Anthropic API key is not configured.\n\n"
|
|
54
|
+
"Configure it using one of these methods:\n"
|
|
55
|
+
" 1. Set ANTHROPIC_API_KEY in your DJANGO_AGENT_RUNTIME settings:\n"
|
|
56
|
+
" DJANGO_AGENT_RUNTIME = {\n"
|
|
57
|
+
" 'MODEL_PROVIDER': 'anthropic',\n"
|
|
58
|
+
" 'ANTHROPIC_API_KEY': 'sk-ant-...',\n"
|
|
59
|
+
" ...\n"
|
|
60
|
+
" }\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 DJANGO_AGENT_RUNTIME settings
|
|
79
|
+
3. ANTHROPIC_API_KEY environment variable
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Resolved API key or None
|
|
83
|
+
"""
|
|
84
|
+
if explicit_key:
|
|
85
|
+
return explicit_key
|
|
86
|
+
|
|
87
|
+
# Try Django settings
|
|
88
|
+
try:
|
|
89
|
+
from django_agent_runtime.conf import runtime_settings
|
|
90
|
+
settings = runtime_settings()
|
|
91
|
+
settings_key = settings.get_anthropic_api_key()
|
|
92
|
+
if settings_key:
|
|
93
|
+
return settings_key
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
# Fall back to environment variable
|
|
98
|
+
return os.environ.get("ANTHROPIC_API_KEY")
|
|
99
|
+
|
|
100
|
+
async def generate(
|
|
101
|
+
self,
|
|
102
|
+
messages: list[Message],
|
|
103
|
+
*,
|
|
104
|
+
model: Optional[str] = None,
|
|
105
|
+
stream: bool = False,
|
|
106
|
+
tools: Optional[list[dict]] = None,
|
|
107
|
+
temperature: Optional[float] = None,
|
|
108
|
+
max_tokens: Optional[int] = None,
|
|
109
|
+
**kwargs,
|
|
110
|
+
) -> LLMResponse:
|
|
111
|
+
"""Generate a completion from Anthropic."""
|
|
112
|
+
model = model or self.default_model
|
|
113
|
+
|
|
114
|
+
# Extract system message
|
|
115
|
+
system_message = None
|
|
116
|
+
chat_messages = []
|
|
117
|
+
for msg in messages:
|
|
118
|
+
if msg.get("role") == "system":
|
|
119
|
+
system_message = msg.get("content", "")
|
|
120
|
+
else:
|
|
121
|
+
chat_messages.append(self._convert_message(msg))
|
|
122
|
+
|
|
123
|
+
# Build request
|
|
124
|
+
request_kwargs = {
|
|
125
|
+
"model": model,
|
|
126
|
+
"messages": chat_messages,
|
|
127
|
+
"max_tokens": max_tokens or 4096,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if system_message:
|
|
131
|
+
request_kwargs["system"] = system_message
|
|
132
|
+
if tools:
|
|
133
|
+
request_kwargs["tools"] = self._convert_tools(tools)
|
|
134
|
+
if temperature is not None:
|
|
135
|
+
|
|
136
|
+
request_kwargs["temperature"] = temperature
|
|
137
|
+
|
|
138
|
+
request_kwargs.update(kwargs)
|
|
139
|
+
|
|
140
|
+
# Make request
|
|
141
|
+
response = await self._client.messages.create(**request_kwargs)
|
|
142
|
+
|
|
143
|
+
# Convert response
|
|
144
|
+
return LLMResponse(
|
|
145
|
+
message=self._convert_response(response),
|
|
146
|
+
usage={
|
|
147
|
+
"prompt_tokens": response.usage.input_tokens,
|
|
148
|
+
"completion_tokens": response.usage.output_tokens,
|
|
149
|
+
"total_tokens": response.usage.input_tokens + response.usage.output_tokens,
|
|
150
|
+
},
|
|
151
|
+
model=response.model,
|
|
152
|
+
finish_reason=response.stop_reason or "",
|
|
153
|
+
raw_response=response,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async def stream(
|
|
157
|
+
self,
|
|
158
|
+
messages: list[Message],
|
|
159
|
+
*,
|
|
160
|
+
model: Optional[str] = None,
|
|
161
|
+
tools: Optional[list[dict]] = None,
|
|
162
|
+
**kwargs,
|
|
163
|
+
) -> AsyncIterator[LLMStreamChunk]:
|
|
164
|
+
"""Stream a completion from Anthropic."""
|
|
165
|
+
model = model or self.default_model
|
|
166
|
+
|
|
167
|
+
# Extract system message
|
|
168
|
+
system_message = None
|
|
169
|
+
chat_messages = []
|
|
170
|
+
for msg in messages:
|
|
171
|
+
if msg.get("role") == "system":
|
|
172
|
+
system_message = msg.get("content", "")
|
|
173
|
+
else:
|
|
174
|
+
chat_messages.append(self._convert_message(msg))
|
|
175
|
+
|
|
176
|
+
request_kwargs = {
|
|
177
|
+
"model": model,
|
|
178
|
+
"messages": chat_messages,
|
|
179
|
+
"max_tokens": kwargs.pop("max_tokens", 4096),
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if system_message:
|
|
183
|
+
request_kwargs["system"] = system_message
|
|
184
|
+
if tools:
|
|
185
|
+
request_kwargs["tools"] = self._convert_tools(tools)
|
|
186
|
+
|
|
187
|
+
request_kwargs.update(kwargs)
|
|
188
|
+
|
|
189
|
+
async with self._client.messages.stream(**request_kwargs) as stream:
|
|
190
|
+
async for event in stream:
|
|
191
|
+
if event.type == "content_block_delta":
|
|
192
|
+
if hasattr(event.delta, "text"):
|
|
193
|
+
yield LLMStreamChunk(delta=event.delta.text)
|
|
194
|
+
elif event.type == "message_stop":
|
|
195
|
+
yield LLMStreamChunk(finish_reason="stop")
|
|
196
|
+
|
|
197
|
+
def _convert_message(self, msg: Message) -> dict:
|
|
198
|
+
"""Convert our message format to Anthropic format."""
|
|
199
|
+
role = msg.get("role", "user")
|
|
200
|
+
if role == "assistant":
|
|
201
|
+
role = "assistant"
|
|
202
|
+
elif role == "tool":
|
|
203
|
+
role = "user" # Tool results go as user messages in Anthropic
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
"role": role,
|
|
207
|
+
"content": msg.get("content", ""),
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
def _convert_tools(self, tools: list[dict]) -> list[dict]:
|
|
211
|
+
"""Convert OpenAI tool format to Anthropic format."""
|
|
212
|
+
result = []
|
|
213
|
+
for tool in tools:
|
|
214
|
+
if tool.get("type") == "function":
|
|
215
|
+
func = tool["function"]
|
|
216
|
+
result.append({
|
|
217
|
+
"name": func["name"],
|
|
218
|
+
"description": func.get("description", ""),
|
|
219
|
+
"input_schema": func.get("parameters", {}),
|
|
220
|
+
})
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
def _convert_response(self, response) -> Message:
|
|
224
|
+
"""Convert Anthropic response to our format."""
|
|
225
|
+
content = ""
|
|
226
|
+
tool_calls = []
|
|
227
|
+
|
|
228
|
+
for block in response.content:
|
|
229
|
+
if block.type == "text":
|
|
230
|
+
content += block.text
|
|
231
|
+
elif block.type == "tool_use":
|
|
232
|
+
tool_calls.append({
|
|
233
|
+
"id": block.id,
|
|
234
|
+
"type": "function",
|
|
235
|
+
"function": {
|
|
236
|
+
"name": block.name,
|
|
237
|
+
"arguments": str(block.input),
|
|
238
|
+
},
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
result: Message = {
|
|
242
|
+
"role": "assistant",
|
|
243
|
+
"content": content,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if tool_calls:
|
|
247
|
+
result["tool_calls"] = tool_calls
|
|
248
|
+
|
|
249
|
+
return result
|
|
@@ -0,0 +1,173 @@
|
|
|
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 django_agent_runtime.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: str = "gpt-4o",
|
|
36
|
+
**kwargs,
|
|
37
|
+
):
|
|
38
|
+
if litellm is None:
|
|
39
|
+
raise ImportError("litellm package is required for LiteLLMClient")
|
|
40
|
+
|
|
41
|
+
self.default_model = default_model
|
|
42
|
+
self._kwargs = kwargs
|
|
43
|
+
|
|
44
|
+
async def generate(
|
|
45
|
+
self,
|
|
46
|
+
messages: list[Message],
|
|
47
|
+
*,
|
|
48
|
+
model: Optional[str] = None,
|
|
49
|
+
stream: bool = False,
|
|
50
|
+
tools: Optional[list[dict]] = None,
|
|
51
|
+
temperature: Optional[float] = None,
|
|
52
|
+
max_tokens: Optional[int] = None,
|
|
53
|
+
**kwargs,
|
|
54
|
+
) -> LLMResponse:
|
|
55
|
+
"""Generate a completion using LiteLLM."""
|
|
56
|
+
model = model or self.default_model
|
|
57
|
+
|
|
58
|
+
# Build request
|
|
59
|
+
request_kwargs = {
|
|
60
|
+
"model": model,
|
|
61
|
+
"messages": self._convert_messages(messages),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if tools:
|
|
65
|
+
request_kwargs["tools"] = tools
|
|
66
|
+
if temperature is not None:
|
|
67
|
+
request_kwargs["temperature"] = temperature
|
|
68
|
+
if max_tokens is not None:
|
|
69
|
+
request_kwargs["max_tokens"] = max_tokens
|
|
70
|
+
|
|
71
|
+
request_kwargs.update(self._kwargs)
|
|
72
|
+
request_kwargs.update(kwargs)
|
|
73
|
+
|
|
74
|
+
# Make request
|
|
75
|
+
response = await acompletion(**request_kwargs)
|
|
76
|
+
|
|
77
|
+
# Convert response
|
|
78
|
+
choice = response.choices[0]
|
|
79
|
+
message = choice.message
|
|
80
|
+
|
|
81
|
+
return LLMResponse(
|
|
82
|
+
message=self._convert_response_message(message),
|
|
83
|
+
usage={
|
|
84
|
+
"prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
|
|
85
|
+
"completion_tokens": response.usage.completion_tokens if response.usage else 0,
|
|
86
|
+
"total_tokens": response.usage.total_tokens if response.usage else 0,
|
|
87
|
+
},
|
|
88
|
+
model=response.model or model,
|
|
89
|
+
finish_reason=choice.finish_reason or "",
|
|
90
|
+
raw_response=response,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def stream(
|
|
94
|
+
self,
|
|
95
|
+
messages: list[Message],
|
|
96
|
+
*,
|
|
97
|
+
model: Optional[str] = None,
|
|
98
|
+
tools: Optional[list[dict]] = None,
|
|
99
|
+
**kwargs,
|
|
100
|
+
) -> AsyncIterator[LLMStreamChunk]:
|
|
101
|
+
"""Stream a completion using LiteLLM."""
|
|
102
|
+
model = model or self.default_model
|
|
103
|
+
|
|
104
|
+
request_kwargs = {
|
|
105
|
+
"model": model,
|
|
106
|
+
"messages": self._convert_messages(messages),
|
|
107
|
+
"stream": True,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if tools:
|
|
111
|
+
request_kwargs["tools"] = tools
|
|
112
|
+
|
|
113
|
+
request_kwargs.update(self._kwargs)
|
|
114
|
+
request_kwargs.update(kwargs)
|
|
115
|
+
|
|
116
|
+
response = await acompletion(**request_kwargs)
|
|
117
|
+
|
|
118
|
+
async for chunk in response:
|
|
119
|
+
if not chunk.choices:
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
choice = chunk.choices[0]
|
|
123
|
+
delta = choice.delta
|
|
124
|
+
|
|
125
|
+
yield LLMStreamChunk(
|
|
126
|
+
delta=getattr(delta, "content", "") or "",
|
|
127
|
+
tool_calls=getattr(delta, "tool_calls", None),
|
|
128
|
+
finish_reason=choice.finish_reason,
|
|
129
|
+
usage=None,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def _convert_messages(self, messages: list[Message]) -> list[dict]:
|
|
133
|
+
"""Convert our message format to LiteLLM format (OpenAI-compatible)."""
|
|
134
|
+
result = []
|
|
135
|
+
for msg in messages:
|
|
136
|
+
converted = {
|
|
137
|
+
"role": msg.get("role", "user"),
|
|
138
|
+
"content": msg.get("content", ""),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if msg.get("name"):
|
|
142
|
+
converted["name"] = msg["name"]
|
|
143
|
+
if msg.get("tool_call_id"):
|
|
144
|
+
converted["tool_call_id"] = msg["tool_call_id"]
|
|
145
|
+
if msg.get("tool_calls"):
|
|
146
|
+
converted["tool_calls"] = msg["tool_calls"]
|
|
147
|
+
|
|
148
|
+
result.append(converted)
|
|
149
|
+
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
def _convert_response_message(self, message) -> Message:
|
|
153
|
+
"""Convert LiteLLM response message to our format."""
|
|
154
|
+
result: Message = {
|
|
155
|
+
"role": message.role,
|
|
156
|
+
"content": message.content or "",
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if hasattr(message, "tool_calls") and message.tool_calls:
|
|
160
|
+
result["tool_calls"] = [
|
|
161
|
+
{
|
|
162
|
+
"id": tc.id,
|
|
163
|
+
"type": tc.type,
|
|
164
|
+
"function": {
|
|
165
|
+
"name": tc.function.name,
|
|
166
|
+
"arguments": tc.function.arguments,
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
for tc in message.tool_calls
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
return result
|
|
173
|
+
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenAI API client implementation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import AsyncIterator, Optional
|
|
7
|
+
|
|
8
|
+
from django_agent_runtime.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 = None
|
|
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: str = "gpt-4o",
|
|
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 openai"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
self.default_model = default_model
|
|
49
|
+
|
|
50
|
+
# Resolve API key with clear priority
|
|
51
|
+
resolved_api_key = self._resolve_api_key(api_key)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
self._client = AsyncOpenAI(
|
|
55
|
+
api_key=resolved_api_key,
|
|
56
|
+
organization=organization,
|
|
57
|
+
base_url=base_url,
|
|
58
|
+
**kwargs,
|
|
59
|
+
)
|
|
60
|
+
except OpenAIError as e:
|
|
61
|
+
if "api_key" in str(e).lower():
|
|
62
|
+
raise OpenAIConfigurationError(
|
|
63
|
+
"OpenAI API key is not configured.\n\n"
|
|
64
|
+
"Configure it using one of these methods:\n"
|
|
65
|
+
" 1. Set OPENAI_API_KEY in your DJANGO_AGENT_RUNTIME settings:\n"
|
|
66
|
+
" DJANGO_AGENT_RUNTIME = {\n"
|
|
67
|
+
" 'OPENAI_API_KEY': 'sk-...',\n"
|
|
68
|
+
" ...\n"
|
|
69
|
+
" }\n\n"
|
|
70
|
+
" 2. Set the OPENAI_API_KEY environment variable:\n"
|
|
71
|
+
" export OPENAI_API_KEY='sk-...'\n\n"
|
|
72
|
+
" 3. Pass api_key directly to get_llm_client():\n"
|
|
73
|
+
" llm = get_llm_client(api_key='sk-...')"
|
|
74
|
+
) from e
|
|
75
|
+
raise
|
|
76
|
+
|
|
77
|
+
def _resolve_api_key(self, explicit_key: Optional[str]) -> Optional[str]:
|
|
78
|
+
"""
|
|
79
|
+
Resolve API key with clear priority order.
|
|
80
|
+
|
|
81
|
+
Priority:
|
|
82
|
+
1. Explicit api_key parameter passed to __init__
|
|
83
|
+
2. OPENAI_API_KEY in DJANGO_AGENT_RUNTIME settings
|
|
84
|
+
3. OPENAI_API_KEY environment variable
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Resolved API key or None (let OpenAI client raise its own error)
|
|
88
|
+
"""
|
|
89
|
+
if explicit_key:
|
|
90
|
+
return explicit_key
|
|
91
|
+
|
|
92
|
+
# Try Django settings
|
|
93
|
+
try:
|
|
94
|
+
from django_agent_runtime.conf import runtime_settings
|
|
95
|
+
settings = runtime_settings()
|
|
96
|
+
settings_key = settings.get_openai_api_key()
|
|
97
|
+
if settings_key:
|
|
98
|
+
return settings_key
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
# Fall back to environment variable (OpenAI client will also check this)
|
|
103
|
+
return os.environ.get("OPENAI_API_KEY")
|
|
104
|
+
|
|
105
|
+
async def generate(
|
|
106
|
+
self,
|
|
107
|
+
messages: list[Message],
|
|
108
|
+
*,
|
|
109
|
+
model: Optional[str] = None,
|
|
110
|
+
stream: bool = False,
|
|
111
|
+
tools: Optional[list[dict]] = None,
|
|
112
|
+
temperature: Optional[float] = None,
|
|
113
|
+
max_tokens: Optional[int] = None,
|
|
114
|
+
**kwargs,
|
|
115
|
+
) -> LLMResponse:
|
|
116
|
+
"""Generate a completion from OpenAI."""
|
|
117
|
+
model = model or self.default_model
|
|
118
|
+
|
|
119
|
+
# Build request
|
|
120
|
+
request_kwargs = {
|
|
121
|
+
"model": model,
|
|
122
|
+
"messages": self._convert_messages(messages),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if tools:
|
|
126
|
+
request_kwargs["tools"] = tools
|
|
127
|
+
if temperature is not None:
|
|
128
|
+
request_kwargs["temperature"] = temperature
|
|
129
|
+
if max_tokens is not None:
|
|
130
|
+
request_kwargs["max_tokens"] = max_tokens
|
|
131
|
+
|
|
132
|
+
request_kwargs.update(kwargs)
|
|
133
|
+
|
|
134
|
+
# Make request
|
|
135
|
+
response = await self._client.chat.completions.create(**request_kwargs)
|
|
136
|
+
|
|
137
|
+
# Convert response
|
|
138
|
+
choice = response.choices[0]
|
|
139
|
+
message = choice.message
|
|
140
|
+
|
|
141
|
+
return LLMResponse(
|
|
142
|
+
message=self._convert_response_message(message),
|
|
143
|
+
usage={
|
|
144
|
+
"prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
|
|
145
|
+
"completion_tokens": response.usage.completion_tokens if response.usage else 0,
|
|
146
|
+
"total_tokens": response.usage.total_tokens if response.usage else 0,
|
|
147
|
+
},
|
|
148
|
+
model=response.model,
|
|
149
|
+
finish_reason=choice.finish_reason or "",
|
|
150
|
+
raw_response=response,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
async def stream(
|
|
154
|
+
self,
|
|
155
|
+
messages: list[Message],
|
|
156
|
+
*,
|
|
157
|
+
model: Optional[str] = None,
|
|
158
|
+
tools: Optional[list[dict]] = None,
|
|
159
|
+
**kwargs,
|
|
160
|
+
) -> AsyncIterator[LLMStreamChunk]:
|
|
161
|
+
"""Stream a completion from OpenAI."""
|
|
162
|
+
model = model or self.default_model
|
|
163
|
+
|
|
164
|
+
request_kwargs = {
|
|
165
|
+
"model": model,
|
|
166
|
+
"messages": self._convert_messages(messages),
|
|
167
|
+
"stream": True,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if tools:
|
|
171
|
+
request_kwargs["tools"] = tools
|
|
172
|
+
|
|
173
|
+
request_kwargs.update(kwargs)
|
|
174
|
+
|
|
175
|
+
async with await self._client.chat.completions.create(**request_kwargs) as stream:
|
|
176
|
+
async for chunk in stream:
|
|
177
|
+
if not chunk.choices:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
choice = chunk.choices[0]
|
|
181
|
+
delta = choice.delta
|
|
182
|
+
|
|
183
|
+
yield LLMStreamChunk(
|
|
184
|
+
delta=delta.content or "",
|
|
185
|
+
tool_calls=delta.tool_calls if hasattr(delta, "tool_calls") else None,
|
|
186
|
+
finish_reason=choice.finish_reason,
|
|
187
|
+
usage=None, # Usage comes in final chunk for some models
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def _convert_messages(self, messages: list[Message]) -> list[dict]:
|
|
191
|
+
"""Convert our message format to OpenAI format."""
|
|
192
|
+
result = []
|
|
193
|
+
for msg in messages:
|
|
194
|
+
converted = {
|
|
195
|
+
"role": msg.get("role", "user"),
|
|
196
|
+
"content": msg.get("content", ""),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if msg.get("name"):
|
|
200
|
+
converted["name"] = msg["name"]
|
|
201
|
+
if msg.get("tool_call_id"):
|
|
202
|
+
converted["tool_call_id"] = msg["tool_call_id"]
|
|
203
|
+
if msg.get("tool_calls"):
|
|
204
|
+
converted["tool_calls"] = msg["tool_calls"]
|
|
205
|
+
|
|
206
|
+
result.append(converted)
|
|
207
|
+
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
def _convert_response_message(self, message) -> Message:
|
|
211
|
+
"""Convert OpenAI response message to our format."""
|
|
212
|
+
result: Message = {
|
|
213
|
+
"role": message.role,
|
|
214
|
+
"content": message.content or "",
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if message.tool_calls:
|
|
218
|
+
result["tool_calls"] = [
|
|
219
|
+
{
|
|
220
|
+
"id": tc.id,
|
|
221
|
+
"type": tc.type,
|
|
222
|
+
"function": {
|
|
223
|
+
"name": tc.function.name,
|
|
224
|
+
"arguments": tc.function.arguments,
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
for tc in message.tool_calls
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
return result
|