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.
Files changed (55) hide show
  1. django_agent_runtime/__init__.py +25 -0
  2. django_agent_runtime/admin.py +155 -0
  3. django_agent_runtime/api/__init__.py +26 -0
  4. django_agent_runtime/api/permissions.py +109 -0
  5. django_agent_runtime/api/serializers.py +114 -0
  6. django_agent_runtime/api/views.py +472 -0
  7. django_agent_runtime/apps.py +26 -0
  8. django_agent_runtime/conf.py +241 -0
  9. django_agent_runtime/examples/__init__.py +10 -0
  10. django_agent_runtime/examples/langgraph_adapter.py +164 -0
  11. django_agent_runtime/examples/langgraph_tools.py +179 -0
  12. django_agent_runtime/examples/simple_chat.py +69 -0
  13. django_agent_runtime/examples/tool_agent.py +157 -0
  14. django_agent_runtime/management/__init__.py +2 -0
  15. django_agent_runtime/management/commands/__init__.py +2 -0
  16. django_agent_runtime/management/commands/runagent.py +419 -0
  17. django_agent_runtime/migrations/0001_initial.py +117 -0
  18. django_agent_runtime/migrations/0002_persistence_models.py +129 -0
  19. django_agent_runtime/migrations/0003_persistenceconversation_active_branch_id_and_more.py +212 -0
  20. django_agent_runtime/migrations/0004_add_anonymous_session_id.py +18 -0
  21. django_agent_runtime/migrations/__init__.py +2 -0
  22. django_agent_runtime/models/__init__.py +54 -0
  23. django_agent_runtime/models/base.py +450 -0
  24. django_agent_runtime/models/concrete.py +146 -0
  25. django_agent_runtime/persistence/__init__.py +60 -0
  26. django_agent_runtime/persistence/helpers.py +148 -0
  27. django_agent_runtime/persistence/models.py +506 -0
  28. django_agent_runtime/persistence/stores.py +1191 -0
  29. django_agent_runtime/runtime/__init__.py +23 -0
  30. django_agent_runtime/runtime/events/__init__.py +65 -0
  31. django_agent_runtime/runtime/events/base.py +135 -0
  32. django_agent_runtime/runtime/events/db.py +129 -0
  33. django_agent_runtime/runtime/events/redis.py +228 -0
  34. django_agent_runtime/runtime/events/sync.py +140 -0
  35. django_agent_runtime/runtime/interfaces.py +475 -0
  36. django_agent_runtime/runtime/llm/__init__.py +91 -0
  37. django_agent_runtime/runtime/llm/anthropic.py +249 -0
  38. django_agent_runtime/runtime/llm/litellm_adapter.py +173 -0
  39. django_agent_runtime/runtime/llm/openai.py +230 -0
  40. django_agent_runtime/runtime/queue/__init__.py +75 -0
  41. django_agent_runtime/runtime/queue/base.py +158 -0
  42. django_agent_runtime/runtime/queue/postgres.py +248 -0
  43. django_agent_runtime/runtime/queue/redis_streams.py +336 -0
  44. django_agent_runtime/runtime/queue/sync.py +277 -0
  45. django_agent_runtime/runtime/registry.py +186 -0
  46. django_agent_runtime/runtime/runner.py +540 -0
  47. django_agent_runtime/runtime/tracing/__init__.py +48 -0
  48. django_agent_runtime/runtime/tracing/langfuse.py +117 -0
  49. django_agent_runtime/runtime/tracing/noop.py +36 -0
  50. django_agent_runtime/urls.py +39 -0
  51. django_agent_runtime-0.3.6.dist-info/METADATA +723 -0
  52. django_agent_runtime-0.3.6.dist-info/RECORD +55 -0
  53. django_agent_runtime-0.3.6.dist-info/WHEEL +5 -0
  54. django_agent_runtime-0.3.6.dist-info/licenses/LICENSE +22 -0
  55. 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