openrouter-haystack 0.2.2__tar.gz → 0.3.0__tar.gz

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 (17) hide show
  1. {openrouter_haystack-0.2.2 → openrouter_haystack-0.3.0}/CHANGELOG.md +12 -0
  2. {openrouter_haystack-0.2.2 → openrouter_haystack-0.3.0}/PKG-INFO +2 -2
  3. openrouter_haystack-0.3.0/examples/openrouter_with_structured_outputs.py +35 -0
  4. openrouter_haystack-0.3.0/pydoc/config_docusaurus.yml +28 -0
  5. {openrouter_haystack-0.2.2 → openrouter_haystack-0.3.0}/pyproject.toml +2 -2
  6. {openrouter_haystack-0.2.2 → openrouter_haystack-0.3.0}/src/haystack_integrations/components/generators/openrouter/chat/chat_generator.py +54 -28
  7. {openrouter_haystack-0.2.2 → openrouter_haystack-0.3.0}/tests/test_openrouter_chat_generator.py +129 -1
  8. {openrouter_haystack-0.2.2 → openrouter_haystack-0.3.0}/tests/test_openrouter_chat_generator_async.py +64 -1
  9. {openrouter_haystack-0.2.2 → openrouter_haystack-0.3.0}/.gitignore +0 -0
  10. {openrouter_haystack-0.2.2 → openrouter_haystack-0.3.0}/LICENSE.txt +0 -0
  11. {openrouter_haystack-0.2.2 → openrouter_haystack-0.3.0}/README.md +0 -0
  12. {openrouter_haystack-0.2.2 → openrouter_haystack-0.3.0}/examples/openrouter_with_tools_example.py +0 -0
  13. {openrouter_haystack-0.2.2 → openrouter_haystack-0.3.0}/pydoc/config.yml +0 -0
  14. {openrouter_haystack-0.2.2 → openrouter_haystack-0.3.0}/src/haystack_integrations/components/generators/openrouter/__init__.py +0 -0
  15. {openrouter_haystack-0.2.2 → openrouter_haystack-0.3.0}/src/haystack_integrations/components/generators/openrouter/chat/__init__.py +0 -0
  16. {openrouter_haystack-0.2.2 → openrouter_haystack-0.3.0}/src/haystack_integrations/components/generators/py.typed +0 -0
  17. {openrouter_haystack-0.2.2 → openrouter_haystack-0.3.0}/tests/__init__.py +0 -0
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [integrations/openrouter-v0.2.2] - 2025-09-23
4
+
5
+ ### 🐛 Bug Fixes
6
+
7
+ - Chore: Fix linting in tests for Openrouter integration (#2261)
8
+ - Update OpenRouterChatGenerator to work with `haystack-ai>=2.18.0` (#2295)
9
+
10
+ ### 🧹 Chores
11
+
12
+ - Standardize readmes - part 2 (#2205)
13
+
14
+
3
15
  ## [integrations/openrouter-v0.2.1] - 2025-08-07
4
16
 
5
17
  ### 🚀 Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openrouter-haystack
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/openrouter#readme
5
5
  Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
6
6
  Project-URL: Source, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/openrouter
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Programming Language :: Python :: Implementation :: CPython
19
19
  Classifier: Programming Language :: Python :: Implementation :: PyPy
20
20
  Requires-Python: >=3.9
21
- Requires-Dist: haystack-ai>=2.13.1
21
+ Requires-Dist: haystack-ai>=2.19.0
22
22
  Description-Content-Type: text/markdown
23
23
 
24
24
  # openrouter-haystack
@@ -0,0 +1,35 @@
1
+ # SPDX-FileCopyrightText: 2024-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+
6
+ # This example demonstrates how to use the OpenRouterChatGenerator component
7
+ # with structured outputs.
8
+ # To run this example, you will need to
9
+ # set `OPENROUTER_API_KEY` environment variable
10
+
11
+ from haystack.dataclasses import ChatMessage
12
+ from pydantic import BaseModel
13
+
14
+ from haystack_integrations.components.generators.openrouter import OpenRouterChatGenerator
15
+
16
+
17
+ class NobelPrizeInfo(BaseModel):
18
+ recipient_name: str
19
+ award_year: int
20
+ category: str
21
+ achievement_description: str
22
+ nationality: str
23
+
24
+
25
+ chat_messages = [
26
+ ChatMessage.from_user(
27
+ "In 2021, American scientist David Julius received the Nobel Prize in"
28
+ " Physiology or Medicine for his groundbreaking discoveries on how the human body"
29
+ " senses temperature and touch."
30
+ )
31
+ ]
32
+ component = OpenRouterChatGenerator(generation_kwargs={"response_format": NobelPrizeInfo})
33
+ results = component.run(chat_messages)
34
+
35
+ # print(results)
@@ -0,0 +1,28 @@
1
+ loaders:
2
+ - ignore_when_discovered:
3
+ - __init__
4
+ modules:
5
+ - haystack_integrations.components.generators.openrouter.chat.chat_generator
6
+ search_path:
7
+ - ../src
8
+ type: haystack_pydoc_tools.loaders.CustomPythonLoader
9
+ processors:
10
+ - do_not_filter_modules: false
11
+ documented_only: true
12
+ expression: null
13
+ skip_empty_modules: true
14
+ type: filter
15
+ - type: smart
16
+ - type: crossref
17
+ renderer:
18
+ description: OpenRouter integration for Haystack
19
+ id: integrations-openrouter
20
+ markdown:
21
+ add_member_class_prefix: false
22
+ add_method_class_prefix: true
23
+ classdef_code_block: false
24
+ descriptive_class_title: false
25
+ descriptive_module_title: true
26
+ filename: openrouter.md
27
+ title: OpenRouter
28
+ type: haystack_pydoc_tools.renderers.DocusaurusRenderer
@@ -23,7 +23,7 @@ classifiers = [
23
23
  "Programming Language :: Python :: Implementation :: CPython",
24
24
  "Programming Language :: Python :: Implementation :: PyPy",
25
25
  ]
26
- dependencies = ["haystack-ai>=2.13.1"]
26
+ dependencies = ["haystack-ai>=2.19.0"]
27
27
 
28
28
  [project.urls]
29
29
  Documentation = "https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/openrouter#readme"
@@ -154,4 +154,4 @@ addopts = "--strict-markers"
154
154
  markers = [
155
155
  "integration: integration tests",
156
156
  ]
157
- log_cli = true
157
+ log_cli = true
@@ -2,12 +2,12 @@
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
- from typing import Any, Dict, List, Optional, Union
5
+ from typing import Any, Dict, Optional
6
6
 
7
7
  from haystack import component, default_to_dict, logging
8
8
  from haystack.components.generators.chat import OpenAIChatGenerator
9
9
  from haystack.dataclasses import ChatMessage, StreamingCallbackT
10
- from haystack.tools import Tool, Toolset, _check_duplicate_tool_names
10
+ from haystack.tools import ToolsType, _check_duplicate_tool_names, flatten_tools_or_toolsets, serialize_tools_or_toolset
11
11
  from haystack.utils import serialize_callable
12
12
  from haystack.utils.auth import Secret
13
13
 
@@ -64,7 +64,7 @@ class OpenRouterChatGenerator(OpenAIChatGenerator):
64
64
  streaming_callback: Optional[StreamingCallbackT] = None,
65
65
  api_base_url: Optional[str] = "https://openrouter.ai/api/v1",
66
66
  generation_kwargs: Optional[Dict[str, Any]] = None,
67
- tools: Optional[Union[List[Tool], Toolset]] = None,
67
+ tools: Optional[ToolsType] = None,
68
68
  timeout: Optional[float] = None,
69
69
  extra_headers: Optional[Dict[str, Any]] = None,
70
70
  max_retries: Optional[int] = None,
@@ -98,6 +98,14 @@ class OpenRouterChatGenerator(OpenAIChatGenerator):
98
98
  events as they become available, with the stream terminated by a data: [DONE] message.
99
99
  - `safe_prompt`: Whether to inject a safety prompt before all conversations.
100
100
  - `random_seed`: The seed to use for random sampling.
101
+ - `response_format`: A JSON schema or a Pydantic model that enforces the structure of the model's response.
102
+ If provided, the output will always be validated against this
103
+ format (unless the model returns a tool call).
104
+ For details, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs).
105
+ Notes:
106
+ - This parameter accepts Pydantic models and JSON schemas for latest models starting from GPT-4o.
107
+ - For structured outputs with streaming,
108
+ the `response_format` must be a JSON schema and not a Pydantic model.
101
109
  :param tools:
102
110
  A list of tools or a Toolset for which the model can prepare calls. This parameter can accept either a
103
111
  list of `Tool` objects or a `Toolset` instance.
@@ -148,7 +156,7 @@ class OpenRouterChatGenerator(OpenAIChatGenerator):
148
156
  api_base_url=self.api_base_url,
149
157
  generation_kwargs=self.generation_kwargs,
150
158
  api_key=self.api_key.to_dict(),
151
- tools=[tool.to_dict() for tool in self.tools] if self.tools else None,
159
+ tools=serialize_tools_or_toolset(self.tools),
152
160
  extra_headers=self.extra_headers,
153
161
  timeout=self.timeout,
154
162
  max_retries=self.max_retries,
@@ -158,46 +166,64 @@ class OpenRouterChatGenerator(OpenAIChatGenerator):
158
166
  def _prepare_api_call(
159
167
  self,
160
168
  *,
161
- messages: List[ChatMessage],
169
+ messages: list[ChatMessage],
162
170
  streaming_callback: Optional[StreamingCallbackT] = None,
163
- generation_kwargs: Optional[Dict[str, Any]] = None,
164
- tools: Optional[Union[List[Tool], Toolset]] = None,
171
+ generation_kwargs: Optional[dict[str, Any]] = None,
172
+ tools: Optional[ToolsType] = None,
165
173
  tools_strict: Optional[bool] = None,
166
- ) -> Dict[str, Any]:
174
+ ) -> dict[str, Any]:
167
175
  # update generation kwargs by merging with the generation kwargs passed to the run method
168
176
  generation_kwargs = {**self.generation_kwargs, **(generation_kwargs or {})}
169
177
  extra_headers = {**(self.extra_headers or {})}
170
178
 
179
+ is_streaming = streaming_callback is not None
180
+ num_responses = generation_kwargs.pop("n", 1)
181
+
182
+ if is_streaming and num_responses > 1:
183
+ msg = "Cannot stream multiple responses, please set n=1."
184
+ raise ValueError(msg)
185
+ response_format = generation_kwargs.pop("response_format", None)
186
+
171
187
  # adapt ChatMessage(s) to the format expected by the OpenAI API
172
188
  openai_formatted_messages = [message.to_openai_dict_format() for message in messages]
173
189
 
174
- tools = tools or self.tools
175
- if isinstance(tools, Toolset):
176
- tools = list(tools)
190
+ flattened_tools = flatten_tools_or_toolsets(tools or self.tools)
177
191
  tools_strict = tools_strict if tools_strict is not None else self.tools_strict
178
- _check_duplicate_tool_names(list(tools or []))
192
+ _check_duplicate_tool_names(flattened_tools)
179
193
 
180
194
  openai_tools = {}
181
- if tools:
182
- tool_definitions = [
183
- {"type": "function", "function": {**t.tool_spec, **({"strict": tools_strict} if tools_strict else {})}}
184
- for t in tools
185
- ]
195
+ if flattened_tools:
196
+ tool_definitions = []
197
+ for t in flattened_tools:
198
+ function_spec = {**t.tool_spec}
199
+ if tools_strict:
200
+ function_spec["strict"] = True
201
+ function_spec["parameters"]["additionalProperties"] = False
202
+ tool_definitions.append({"type": "function", "function": function_spec})
186
203
  openai_tools = {"tools": tool_definitions}
187
204
 
188
- is_streaming = streaming_callback is not None
189
- num_responses = generation_kwargs.pop("n", 1)
190
- if is_streaming and num_responses > 1:
191
- msg = "Cannot stream multiple responses, please set n=1."
192
- raise ValueError(msg)
193
-
194
- return {
205
+ base_args = {
195
206
  "model": self.model,
196
- "messages": openai_formatted_messages, # type: ignore[arg-type] # openai expects list of specific message types
197
- "stream": streaming_callback is not None,
207
+ "messages": openai_formatted_messages,
198
208
  "n": num_responses,
199
209
  **openai_tools,
200
- "extra_body": {**generation_kwargs},
201
210
  "extra_headers": {**extra_headers},
202
- "openai_endpoint": "create",
211
+ "extra_body": {**generation_kwargs},
203
212
  }
213
+
214
+ if response_format and not is_streaming:
215
+ # for structured outputs without streaming, we use openai's parse endpoint
216
+ # Note: `stream` cannot be passed to chat.completions.parse
217
+ # we pass a key `openai_endpoint` as a hint to the run method to use the parse endpoint
218
+ # this key will be removed before the API call is made
219
+ return {**base_args, "response_format": response_format, "openai_endpoint": "parse"}
220
+
221
+ # for structured outputs with streaming, we use openai's create endpoint
222
+ # we pass a key `openai_endpoint` as a hint to the run method to use the create endpoint
223
+ # this key will be removed before the API call is made
224
+ final_args = {**base_args, "stream": is_streaming, "openai_endpoint": "create"}
225
+
226
+ # We only set the response_format parameter if it's not None since None is not a valid value in the API.
227
+ if response_format:
228
+ final_args["response_format"] = response_format
229
+ return final_args
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import os
2
3
  from datetime import datetime
3
4
  from unittest.mock import patch
@@ -8,7 +9,7 @@ from haystack import Pipeline
8
9
  from haystack.components.generators.utils import print_streaming_chunk
9
10
  from haystack.components.tools import ToolInvoker
10
11
  from haystack.dataclasses import ChatMessage, ChatRole, StreamingChunk, ToolCall
11
- from haystack.tools import Tool
12
+ from haystack.tools import Tool, Toolset
12
13
  from haystack.utils.auth import Secret
13
14
  from openai import OpenAIError
14
15
  from openai.types.chat import ChatCompletion, ChatCompletionChunk, ChatCompletionMessage
@@ -16,10 +17,22 @@ from openai.types.chat.chat_completion import Choice
16
17
  from openai.types.chat.chat_completion_chunk import Choice as ChoiceChunk
17
18
  from openai.types.chat.chat_completion_chunk import ChoiceDelta, ChoiceDeltaToolCall, ChoiceDeltaToolCallFunction
18
19
  from openai.types.completion_usage import CompletionTokensDetails, CompletionUsage, PromptTokensDetails
20
+ from pydantic import BaseModel
19
21
 
20
22
  from haystack_integrations.components.generators.openrouter.chat.chat_generator import OpenRouterChatGenerator
21
23
 
22
24
 
25
+ class CalendarEvent(BaseModel):
26
+ event_name: str
27
+ event_date: str
28
+ event_location: str
29
+
30
+
31
+ @pytest.fixture
32
+ def calendar_event_model():
33
+ return CalendarEvent
34
+
35
+
23
36
  class CollectorCallback:
24
37
  """
25
38
  Callback to collect streaming chunks for testing purposes.
@@ -440,6 +453,41 @@ class TestOpenRouterChatGenerator:
440
453
  == results["tool_invoker"]["tool_messages"][0].tool_call_result.result
441
454
  )
442
455
 
456
+ @pytest.mark.skipif(
457
+ not os.environ.get("OPENROUTER_API_KEY", None),
458
+ reason="Export an env var called OPENROUTER_API_KEY containing the OpenRouter API key to run this test.",
459
+ )
460
+ @pytest.mark.integration
461
+ def test_live_run_with_response_format_json_schema(self):
462
+ response_schema = {
463
+ "type": "json_schema",
464
+ "json_schema": {
465
+ "name": "CapitalCity",
466
+ "strict": True,
467
+ "schema": {
468
+ "title": "CapitalCity",
469
+ "type": "object",
470
+ "properties": {
471
+ "city": {"title": "City", "type": "string"},
472
+ "country": {"title": "Country", "type": "string"},
473
+ },
474
+ "required": ["city", "country"],
475
+ "additionalProperties": False,
476
+ },
477
+ },
478
+ }
479
+
480
+ chat_messages = [ChatMessage.from_user("What's the capital of France?")]
481
+ comp = OpenRouterChatGenerator(generation_kwargs={"response_format": response_schema})
482
+ results = comp.run(chat_messages)
483
+ assert len(results["replies"]) == 1
484
+ message: ChatMessage = results["replies"][0]
485
+ msg = json.loads(message.text)
486
+ assert "Paris" in msg["city"]
487
+ assert isinstance(msg["country"], str)
488
+ assert "France" in msg["country"]
489
+ assert message.meta["finish_reason"] == "stop"
490
+
443
491
  def test_serde_in_pipeline(self, monkeypatch):
444
492
  """
445
493
  Test serialization/deserialization of OpenRouterChatGenerator in a Pipeline,
@@ -539,6 +587,86 @@ class TestOpenRouterChatGenerator:
539
587
  assert loaded_generator.tools[0].description == generator.tools[0].description
540
588
  assert loaded_generator.tools[0].parameters == generator.tools[0].parameters
541
589
 
590
+ @pytest.mark.skipif(
591
+ not os.environ.get("OPENROUTER_API_KEY", None),
592
+ reason="Export an env var called OPENROUTER_API_KEY containing the OpenRouter API key to run this test.",
593
+ )
594
+ @pytest.mark.integration
595
+ def test_live_run_with_response_format_pydantic_model(self, calendar_event_model):
596
+ chat_messages = [
597
+ ChatMessage.from_user("The marketing summit takes place on October12th at the Hilton Hotel downtown.")
598
+ ]
599
+ component = OpenRouterChatGenerator(generation_kwargs={"response_format": calendar_event_model})
600
+ results = component.run(chat_messages)
601
+ assert len(results["replies"]) == 1
602
+ message: ChatMessage = results["replies"][0]
603
+ msg = json.loads(message.text)
604
+ assert "Marketing Summit" in msg["event_name"]
605
+ assert isinstance(msg["event_date"], str)
606
+ assert isinstance(msg["event_location"], str)
607
+
608
+ @pytest.mark.skipif(
609
+ not os.environ.get("OPENROUTER_API_KEY", None),
610
+ reason="Export an env var called OPENROUTER_API_KEY containing the OpenRouter API key to run this test.",
611
+ )
612
+ @pytest.mark.integration
613
+ def test_integration_mixing_tools_and_toolset(self):
614
+ """Test mixing Tool list and Toolset at runtime."""
615
+
616
+ def weather_function(city: str) -> str:
617
+ """Get weather information for a city."""
618
+ return f"Weather in {city}: 22°C, sunny"
619
+
620
+ def time_function(city: str) -> str:
621
+ """Get current time in a city."""
622
+ return f"Current time in {city}: 14:30"
623
+
624
+ def echo_function(text: str) -> str:
625
+ """Echo a text."""
626
+ return text
627
+
628
+ # Create tools
629
+ weather_tool = Tool(
630
+ name="weather",
631
+ description="Get weather information for a city",
632
+ parameters={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
633
+ function=weather_function,
634
+ )
635
+
636
+ time_tool = Tool(
637
+ name="time",
638
+ description="Get current time in a city",
639
+ parameters={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
640
+ function=time_function,
641
+ )
642
+
643
+ echo_tool = Tool(
644
+ name="echo",
645
+ description="Echo a text",
646
+ parameters={"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]},
647
+ function=echo_function,
648
+ )
649
+
650
+ # Create Toolset with weather and time tools
651
+ toolset = Toolset([weather_tool, time_tool])
652
+
653
+ # Initialize with no tools, we'll pass them at runtime
654
+ component = OpenRouterChatGenerator()
655
+
656
+ # Pass mixed list: echo_tool (individual) and toolset (weather + time) at runtime
657
+ # This tests that both individual tools and toolsets can be combined
658
+ messages = [ChatMessage.from_user("Echo this: Hello World")]
659
+ results = component.run(messages, tools=[echo_tool, toolset])
660
+
661
+ assert len(results["replies"]) == 1
662
+ message = results["replies"][0]
663
+
664
+ # Should be able to use echo_tool from the runtime mixed list
665
+ assert message.tool_calls is not None
666
+ tool_call = message.tool_calls[0]
667
+ assert tool_call.tool_name == "echo"
668
+ assert tool_call.arguments == {"text": "Hello World"}
669
+
542
670
 
543
671
  class TestChatCompletionChunkConversion:
544
672
  def test_handle_stream_response(self):
@@ -9,7 +9,7 @@ from haystack.dataclasses import (
9
9
  ChatRole,
10
10
  StreamingChunk,
11
11
  )
12
- from haystack.tools import Tool
12
+ from haystack.tools import Tool, Toolset
13
13
  from openai import AsyncOpenAI
14
14
  from openai.types.chat import ChatCompletion, ChatCompletionMessage
15
15
  from openai.types.chat.chat_completion import Choice
@@ -262,3 +262,66 @@ class TestOpenRouterChatGeneratorAsync:
262
262
  assert tool_call.tool_name == "weather"
263
263
  assert tool_call.arguments == {"city": "Paris"}
264
264
  assert tool_message.meta["finish_reason"] == "tool_calls"
265
+
266
+ @pytest.mark.skipif(
267
+ not os.environ.get("OPENROUTER_API_KEY", None),
268
+ reason="Export an env var called OPENROUTER_API_KEY containing the OpenRouter API key to run this test.",
269
+ )
270
+ @pytest.mark.integration
271
+ @pytest.mark.asyncio
272
+ async def test_integration_mixing_tools_and_toolset_async(self):
273
+ """Test mixing Tool list and Toolset at runtime in async mode."""
274
+
275
+ def weather_function(city: str) -> str:
276
+ """Get weather information for a city."""
277
+ return f"Weather in {city}: 22°C, sunny"
278
+
279
+ def time_function(city: str) -> str:
280
+ """Get current time in a city."""
281
+ return f"Current time in {city}: 14:30"
282
+
283
+ def echo_function(text: str) -> str:
284
+ """Echo a text."""
285
+ return text
286
+
287
+ # Create tools
288
+ weather_tool = Tool(
289
+ name="weather",
290
+ description="Get weather information for a city",
291
+ parameters={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
292
+ function=weather_function,
293
+ )
294
+
295
+ time_tool = Tool(
296
+ name="time",
297
+ description="Get current time in a city",
298
+ parameters={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
299
+ function=time_function,
300
+ )
301
+
302
+ echo_tool = Tool(
303
+ name="echo",
304
+ description="Echo a text",
305
+ parameters={"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]},
306
+ function=echo_function,
307
+ )
308
+
309
+ # Create Toolset with weather and time tools
310
+ toolset = Toolset([weather_tool, time_tool])
311
+
312
+ # Initialize with no tools, we'll pass them at runtime
313
+ component = OpenRouterChatGenerator()
314
+
315
+ # Pass mixed list: echo_tool (individual) and toolset (weather + time) at runtime
316
+ # This tests that both individual tools and toolsets can be combined
317
+ messages = [ChatMessage.from_user("Echo this: Hello World")]
318
+ results = await component.run_async(messages, tools=[echo_tool, toolset])
319
+
320
+ assert len(results["replies"]) == 1
321
+ message = results["replies"][0]
322
+
323
+ # Should be able to use echo_tool from the runtime mixed list
324
+ assert message.tool_calls is not None
325
+ tool_call = message.tool_calls[0]
326
+ assert tool_call.tool_name == "echo"
327
+ assert tool_call.arguments == {"text": "Hello World"}