diff --git a/ollama/_types.py b/ollama/_types.py index ad041013..8b217ee1 100644 --- a/ollama/_types.py +++ b/ollama/_types.py @@ -330,6 +330,9 @@ class Message(SubscriptableBaseModel): tool_name: Optional[str] = None 'Name of the executed tool.' + tool_call_id: Optional[str] = None + 'ID of the tool call that produced this message.' + class ToolCall(SubscriptableBaseModel): """ Model tool calls. @@ -346,6 +349,9 @@ class Function(SubscriptableBaseModel): arguments: Mapping[str, Any] 'Arguments of the function.' + id: Optional[str] = None + 'ID of the tool call.' + function: Function 'Function to be called.' diff --git a/tests/test_client.py b/tests/test_client.py index c212a11d..50dd0c38 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1527,3 +1527,231 @@ def test_client_chat_with_think_level(httpserver: HTTPServer): assert response['model'] == 'qwen3.8:27b' assert response['message']['content'] == 'Hi there.' assert response['message']['thinking'] == 'Thinking deeply...' + + +def test_client_chat_tool_call_id(httpserver: HTTPServer): + httpserver.expect_ordered_request( + '/api/chat', + method='POST', + json={ + 'model': 'dummy', + 'messages': [{'role': 'user', 'content': 'What is the weather?'}], + 'tools': [], + 'stream': False, + }, + ).respond_with_json( + { + 'model': 'dummy', + 'message': { + 'role': 'assistant', + 'content': '', + 'tool_calls': [ + { + 'id': 'call_12345abcdef', + 'function': { + 'name': 'get_weather', + 'arguments': {'location': 'San Francisco'}, + }, + }, + { + 'function': { + 'name': 'get_time', + 'arguments': {'timezone': 'UTC'}, + }, + }, + ], + }, + } + ) + + client = Client(httpserver.url_for('/')) + response = client.chat('dummy', messages=[{'role': 'user', 'content': 'What is the weather?'}]) + assert len(response.message.tool_calls) == 2 + + # Case 1: ID is provided by the server + assert response.message.tool_calls[0].id == 'call_12345abcdef' + assert response.message.tool_calls[0]['id'] == 'call_12345abcdef' + assert response.message.tool_calls[0].function.name == 'get_weather' + + # Case 2: ID is omitted (backward compatibility) + assert response.message.tool_calls[1].id is None + assert response.message.tool_calls[1].get('id') is None + assert response.message.tool_calls[1].function.name == 'get_time' + + # Verify serialization round-trip + dumped = response.message.model_dump() + assert dumped['tool_calls'][0]['id'] == 'call_12345abcdef' + assert dumped['tool_calls'][1]['id'] is None + + +async def test_async_client_chat_tool_call_id(httpserver: HTTPServer): + httpserver.expect_ordered_request( + '/api/chat', + method='POST', + json={ + 'model': 'dummy', + 'messages': [{'role': 'user', 'content': 'What is the weather?'}], + 'tools': [], + 'stream': False, + }, + ).respond_with_json( + { + 'model': 'dummy', + 'message': { + 'role': 'assistant', + 'content': '', + 'tool_calls': [ + { + 'id': 'call_async_98765', + 'function': { + 'name': 'get_weather', + 'arguments': {'location': 'Tokyo'}, + }, + } + ], + }, + } + ) + + async_client = AsyncClient(httpserver.url_for('/')) + response = await async_client.chat('dummy', messages=[{'role': 'user', 'content': 'What is the weather?'}]) + assert len(response.message.tool_calls) == 1 + assert response.message.tool_calls[0].id == 'call_async_98765' + assert response.message.tool_calls[0]['id'] == 'call_async_98765' + + +def test_client_chat_tool_result_message(httpserver: HTTPServer): + httpserver.expect_ordered_request( + '/api/chat', + method='POST', + json={ + 'model': 'dummy', + 'messages': [ + {'role': 'user', 'content': 'What is the weather in SF?'}, + { + 'role': 'assistant', + 'tool_calls': [ + { + 'id': 'call_12345', + 'function': { + 'name': 'get_weather', + 'arguments': {'location': 'San Francisco'}, + }, + } + ], + }, + { + 'role': 'tool', + 'tool_call_id': 'call_12345', + 'tool_name': 'get_weather', + 'content': '72 degrees and sunny', + }, + ], + 'tools': [], + 'stream': False, + }, + ).respond_with_json( + { + 'model': 'dummy', + 'message': { + 'role': 'assistant', + 'content': 'The weather in San Francisco is 72 degrees and sunny.', + }, + } + ) + + client = Client(httpserver.url_for('/')) + messages = [ + {'role': 'user', 'content': 'What is the weather in SF?'}, + { + 'role': 'assistant', + 'content': '', + 'tool_calls': [ + { + 'id': 'call_12345', + 'function': { + 'name': 'get_weather', + 'arguments': {'location': 'San Francisco'}, + }, + } + ], + }, + { + 'role': 'tool', + 'tool_call_id': 'call_12345', + 'tool_name': 'get_weather', + 'content': '72 degrees and sunny', + }, + ] + response = client.chat('dummy', messages=messages) + assert response.message.content == 'The weather in San Francisco is 72 degrees and sunny.' + + +async def test_async_client_chat_tool_result_message(httpserver: HTTPServer): + httpserver.expect_ordered_request( + '/api/chat', + method='POST', + json={ + 'model': 'dummy', + 'messages': [ + {'role': 'user', 'content': 'What is the weather in Tokyo?'}, + { + 'role': 'assistant', + 'tool_calls': [ + { + 'id': 'call_async_999', + 'function': { + 'name': 'get_weather', + 'arguments': {'location': 'Tokyo'}, + }, + } + ], + }, + { + 'role': 'tool', + 'tool_call_id': 'call_async_999', + 'tool_name': 'get_weather', + 'content': '18 degrees and rainy', + }, + ], + 'tools': [], + 'stream': False, + }, + ).respond_with_json( + { + 'model': 'dummy', + 'message': { + 'role': 'assistant', + 'content': 'The weather in Tokyo is 18 degrees and rainy.', + }, + } + ) + + async_client = AsyncClient(httpserver.url_for('/')) + messages = [ + {'role': 'user', 'content': 'What is the weather in Tokyo?'}, + { + 'role': 'assistant', + 'content': '', + 'tool_calls': [ + { + 'id': 'call_async_999', + 'function': { + 'name': 'get_weather', + 'arguments': {'location': 'Tokyo'}, + }, + } + ], + }, + { + 'role': 'tool', + 'tool_call_id': 'call_async_999', + 'tool_name': 'get_weather', + 'content': '18 degrees and rainy', + }, + ] + response = await async_client.chat('dummy', messages=messages) + assert response.message.content == 'The weather in Tokyo is 18 degrees and rainy.' + + +