ollama-haystack 2.1.2__tar.gz → 2.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 (23) hide show
  1. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/CHANGELOG.md +14 -0
  2. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/PKG-INFO +2 -1
  3. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/pyproject.toml +3 -2
  4. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/src/haystack_integrations/components/generators/ollama/chat/chat_generator.py +123 -41
  5. ollama_haystack-2.3.0/tests/test_chat_generator.py +529 -0
  6. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/tests/test_generator.py +2 -2
  7. ollama_haystack-2.1.2/tests/test_chat_generator.py +0 -169
  8. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/.gitignore +0 -0
  9. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/LICENSE.txt +0 -0
  10. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/README.md +0 -0
  11. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/examples/chat_generator_example.py +0 -0
  12. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/examples/embedders_example.py +0 -0
  13. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/examples/generator_example.py +0 -0
  14. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/pydoc/config.yml +0 -0
  15. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/src/haystack_integrations/components/embedders/ollama/__init__.py +0 -0
  16. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/src/haystack_integrations/components/embedders/ollama/document_embedder.py +0 -0
  17. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/src/haystack_integrations/components/embedders/ollama/text_embedder.py +0 -0
  18. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/src/haystack_integrations/components/generators/ollama/__init__.py +0 -0
  19. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/src/haystack_integrations/components/generators/ollama/chat/__init__.py +0 -0
  20. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/src/haystack_integrations/components/generators/ollama/generator.py +0 -0
  21. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/tests/__init__.py +0 -0
  22. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/tests/test_document_embedder.py +0 -0
  23. {ollama_haystack-2.1.2 → ollama_haystack-2.3.0}/tests/test_text_embedder.py +0 -0
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [integrations/ollama-v2.2.0] - 2025-01-16
4
+
5
+ ### 🚀 Features
6
+
7
+ - Ollama - add support for tools (#1294)
8
+
9
+
10
+ ## [integrations/ollama-v2.1.2] - 2024-12-18
11
+
12
+ ### 🐛 Bug Fixes
13
+
14
+ - Make Ollama Chat Generator compatible with new ChatMessage (#1256)
15
+
16
+
3
17
  ## [integrations/ollama-v2.1.1] - 2024-12-10
4
18
 
5
19
  ### 🌀 Miscellaneous
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ollama-haystack
3
- Version: 2.1.2
3
+ Version: 2.3.0
4
4
  Summary: An integration between the Ollama LLM framework and Haystack
5
5
  Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/ollama#readme
6
6
  Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy
20
20
  Requires-Python: >=3.8
21
21
  Requires-Dist: haystack-ai
22
22
  Requires-Dist: ollama>=0.4.0
23
+ Requires-Dist: pydantic
23
24
  Description-Content-Type: text/markdown
24
25
 
25
26
  # ollama-haystack
@@ -26,7 +26,7 @@ classifiers = [
26
26
  "Programming Language :: Python :: Implementation :: CPython",
27
27
  "Programming Language :: Python :: Implementation :: PyPy",
28
28
  ]
29
- dependencies = ["haystack-ai", "ollama>=0.4.0"]
29
+ dependencies = ["haystack-ai", "ollama>=0.4.0", "pydantic"]
30
30
 
31
31
  [project.urls]
32
32
  Documentation = "https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/ollama#readme"
@@ -51,6 +51,7 @@ dependencies = [
51
51
  "pytest",
52
52
  "pytest-rerunfailures",
53
53
  "haystack-pydoc-tools",
54
+ "jsonschema", # needed for Tool
54
55
  ]
55
56
  [tool.hatch.envs.default.scripts]
56
57
  test = "pytest {args:tests}"
@@ -164,5 +165,5 @@ markers = [
164
165
  addopts = ["--import-mode=importlib"]
165
166
 
166
167
  [[tool.mypy.overrides]]
167
- module = ["haystack.*", "haystack_integrations.*", "pytest.*", "ollama.*"]
168
+ module = ["haystack.*", "haystack_integrations.*", "pytest.*", "ollama.*", "pydantic.*"]
168
169
  ignore_missing_imports = true
@@ -1,12 +1,69 @@
1
- from typing import Any, Callable, Dict, List, Optional, Union
1
+ from typing import Any, Callable, Dict, List, Literal, Optional, Union
2
2
 
3
3
  from haystack import component, default_from_dict, default_to_dict
4
- from haystack.dataclasses import ChatMessage, StreamingChunk
4
+ from haystack.dataclasses import ChatMessage, StreamingChunk, ToolCall
5
+ from haystack.tools import Tool, _check_duplicate_tool_names, deserialize_tools_inplace
5
6
  from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
7
+ from pydantic.json_schema import JsonSchemaValue
6
8
 
7
9
  from ollama import ChatResponse, Client
8
10
 
9
11
 
12
+ def _convert_chatmessage_to_ollama_format(message: ChatMessage) -> Dict[str, Any]:
13
+ """
14
+ Convert a ChatMessage to the format expected by Ollama Chat API.
15
+ """
16
+ text_contents = message.texts
17
+ tool_calls = message.tool_calls
18
+ tool_call_results = message.tool_call_results
19
+
20
+ if not text_contents and not tool_calls and not tool_call_results:
21
+ msg = "A `ChatMessage` must contain at least one `TextContent`, `ToolCall`, or `ToolCallResult`."
22
+ raise ValueError(msg)
23
+ elif len(text_contents) + len(tool_call_results) > 1:
24
+ msg = "A `ChatMessage` can only contain one `TextContent` or one `ToolCallResult`."
25
+ raise ValueError(msg)
26
+
27
+ ollama_msg: Dict[str, Any] = {"role": message._role.value}
28
+
29
+ if tool_call_results:
30
+ # Ollama does not provide a way to communicate errors in tool invocations, so we ignore the error field
31
+ ollama_msg["content"] = tool_call_results[0].result
32
+ return ollama_msg
33
+
34
+ if text_contents:
35
+ ollama_msg["content"] = text_contents[0]
36
+ if tool_calls:
37
+ # Ollama does not support tool call id, so we ignore it
38
+ ollama_msg["tool_calls"] = [
39
+ {"type": "function", "function": {"name": tc.tool_name, "arguments": tc.arguments}} for tc in tool_calls
40
+ ]
41
+ return ollama_msg
42
+
43
+
44
+ def _convert_ollama_response_to_chatmessage(ollama_response: "ChatResponse") -> ChatMessage:
45
+ """
46
+ Converts the non-streaming response from the Ollama API to a ChatMessage with assistant role.
47
+ """
48
+ response_dict = ollama_response.model_dump()
49
+
50
+ ollama_message = response_dict["message"]
51
+
52
+ text = ollama_message["content"]
53
+
54
+ tool_calls = []
55
+ if ollama_tool_calls := ollama_message.get("tool_calls"):
56
+ for ollama_tc in ollama_tool_calls:
57
+ tool_calls.append(
58
+ ToolCall(tool_name=ollama_tc["function"]["name"], arguments=ollama_tc["function"]["arguments"])
59
+ )
60
+
61
+ message = ChatMessage.from_assistant(text=text, tool_calls=tool_calls)
62
+
63
+ message.meta.update({key: value for key, value in response_dict.items() if key != "message"})
64
+ return message
65
+
66
+
10
67
  @component
11
68
  class OllamaChatGenerator:
12
69
  """
@@ -40,6 +97,8 @@ class OllamaChatGenerator:
40
97
  timeout: int = 120,
41
98
  keep_alive: Optional[Union[float, str]] = None,
42
99
  streaming_callback: Optional[Callable[[StreamingChunk], None]] = None,
100
+ tools: Optional[List[Tool]] = None,
101
+ response_format: Optional[Union[None, Literal["json"], JsonSchemaValue]] = None,
43
102
  ):
44
103
  """
45
104
  :param model:
@@ -52,9 +111,6 @@ class OllamaChatGenerator:
52
111
  [Ollama docs](https://github.com/jmorganca/ollama/blob/main/docs/modelfile.md#valid-parameters-and-values).
53
112
  :param timeout:
54
113
  The number of seconds before throwing a timeout error from the Ollama API.
55
- :param streaming_callback:
56
- A callback function that is called when a new token is received from the stream.
57
- The callback function accepts StreamingChunk as an argument.
58
114
  :param keep_alive:
59
115
  The option that controls how long the model will stay loaded into memory following the request.
60
116
  If not set, it will use the default value from the Ollama (5 minutes).
@@ -63,15 +119,30 @@ class OllamaChatGenerator:
63
119
  - a number in seconds (such as 3600)
64
120
  - any negative number which will keep the model loaded in memory (e.g. -1 or "-1m")
65
121
  - '0' which will unload the model immediately after generating a response.
122
+ :param streaming_callback:
123
+ A callback function that is called when a new token is received from the stream.
124
+ The callback function accepts StreamingChunk as an argument.
125
+ :param tools:
126
+ A list of tools for which the model can prepare calls.
127
+ Not all models support tools. For a list of models compatible with tools, see the
128
+ [models page](https://ollama.com/search?c=tools).
129
+ :param response_format:
130
+ The format for structured model outputs. The value can be:
131
+ - None: No specific structure or format is applied to the response. The response is returned as-is.
132
+ - "json": The response is formatted as a JSON object.
133
+ - JSON Schema: The response is formatted as a JSON object that adheres to the specified JSON Schema.
66
134
  """
67
135
 
136
+ _check_duplicate_tool_names(tools)
137
+
68
138
  self.timeout = timeout
69
139
  self.generation_kwargs = generation_kwargs or {}
70
140
  self.url = url
71
141
  self.model = model
72
142
  self.keep_alive = keep_alive
73
143
  self.streaming_callback = streaming_callback
74
-
144
+ self.tools = tools
145
+ self.response_format = response_format
75
146
  self._client = Client(host=self.url, timeout=self.timeout)
76
147
 
77
148
  def to_dict(self) -> Dict[str, Any]:
@@ -82,6 +153,7 @@ class OllamaChatGenerator:
82
153
  Dictionary with serialized data.
83
154
  """
84
155
  callback_name = serialize_callable(self.streaming_callback) if self.streaming_callback else None
156
+ serialized_tools = [tool.to_dict() for tool in self.tools] if self.tools else None
85
157
  return default_to_dict(
86
158
  self,
87
159
  model=self.model,
@@ -90,6 +162,8 @@ class OllamaChatGenerator:
90
162
  generation_kwargs=self.generation_kwargs,
91
163
  timeout=self.timeout,
92
164
  streaming_callback=callback_name,
165
+ tools=serialized_tools,
166
+ response_format=self.response_format,
93
167
  )
94
168
 
95
169
  @classmethod
@@ -102,34 +176,14 @@ class OllamaChatGenerator:
102
176
  :returns:
103
177
  Deserialized component.
104
178
  """
179
+ deserialize_tools_inplace(data["init_parameters"], key="tools")
180
+
105
181
  init_params = data.get("init_parameters", {})
106
- serialized_callback_handler = init_params.get("streaming_callback")
107
- if serialized_callback_handler:
182
+
183
+ if serialized_callback_handler := init_params.get("streaming_callback"):
108
184
  data["init_parameters"]["streaming_callback"] = deserialize_callable(serialized_callback_handler)
109
185
  return default_from_dict(cls, data)
110
186
 
111
- def _message_to_dict(self, message: ChatMessage) -> Dict[str, str]:
112
- return {"role": message.role.value, "content": message.text}
113
-
114
- def _build_message_from_ollama_response(self, ollama_response: ChatResponse) -> ChatMessage:
115
- """
116
- Converts the non-streaming response from the Ollama API to a ChatMessage.
117
- """
118
- response_dict = ollama_response.model_dump()
119
- message = ChatMessage.from_assistant(response_dict["message"]["content"])
120
- message.meta.update({key: value for key, value in response_dict.items() if key != "message"})
121
- return message
122
-
123
- def _convert_to_streaming_response(self, chunks: List[StreamingChunk]) -> Dict[str, List[Any]]:
124
- """
125
- Converts a list of chunks response required Haystack format.
126
- """
127
-
128
- replies = [ChatMessage.from_assistant("".join([c.content for c in chunks]))]
129
- meta = {key: value for key, value in chunks[0].meta.items() if key != "message"}
130
-
131
- return {"replies": replies, "meta": [meta]}
132
-
133
187
  def _build_chunk(self, chunk_response: Any) -> StreamingChunk:
134
188
  """
135
189
  Converts the response from the Ollama API to a StreamingChunk.
@@ -143,23 +197,28 @@ class OllamaChatGenerator:
143
197
  chunk_message = StreamingChunk(content, meta)
144
198
  return chunk_message
145
199
 
146
- def _handle_streaming_response(self, response) -> List[StreamingChunk]:
200
+ def _handle_streaming_response(self, response) -> Dict[str, List[Any]]:
147
201
  """
148
- Handles Streaming response cases
202
+ Handles streaming response and converts it to Haystack format
149
203
  """
150
204
  chunks: List[StreamingChunk] = []
151
205
  for chunk in response:
152
- chunk_delta: StreamingChunk = self._build_chunk(chunk)
206
+ chunk_delta = self._build_chunk(chunk)
153
207
  chunks.append(chunk_delta)
154
208
  if self.streaming_callback is not None:
155
209
  self.streaming_callback(chunk_delta)
156
- return chunks
210
+
211
+ replies = [ChatMessage.from_assistant("".join([c.content for c in chunks]))]
212
+ meta = {key: value for key, value in chunks[0].meta.items() if key != "message"}
213
+
214
+ return {"replies": replies, "meta": [meta]}
157
215
 
158
216
  @component.output_types(replies=List[ChatMessage])
159
217
  def run(
160
218
  self,
161
219
  messages: List[ChatMessage],
162
220
  generation_kwargs: Optional[Dict[str, Any]] = None,
221
+ tools: Optional[List[Tool]] = None,
163
222
  ):
164
223
  """
165
224
  Runs an Ollama Model on a given chat history.
@@ -170,21 +229,44 @@ class OllamaChatGenerator:
170
229
  Optional arguments to pass to the Ollama generation endpoint, such as temperature,
171
230
  top_p, etc. See the
172
231
  [Ollama docs](https://github.com/jmorganca/ollama/blob/main/docs/modelfile.md#valid-parameters-and-values).
173
- :param streaming_callback:
174
- A callback function that will be called with each response chunk in streaming mode.
232
+ :param tools:
233
+ A list of tools for which the model can prepare calls. If set, it will override the `tools` parameter set
234
+ during component initialization.
175
235
  :returns: A dictionary with the following keys:
176
236
  - `replies`: The responses from the model
177
237
  """
178
238
  generation_kwargs = {**self.generation_kwargs, **(generation_kwargs or {})}
179
239
 
180
240
  stream = self.streaming_callback is not None
181
- messages = [self._message_to_dict(message) for message in messages]
241
+ tools = tools or self.tools
242
+ _check_duplicate_tool_names(tools)
243
+
244
+ if stream and tools:
245
+ msg = "Ollama does not support tools and streaming at the same time. Please choose one."
246
+ raise ValueError(msg)
247
+
248
+ if self.response_format and tools:
249
+ msg = "Ollama does not support tools and response_format at the same time. Please choose one."
250
+ raise ValueError(msg)
251
+
252
+ if self.response_format and stream:
253
+ msg = "Ollama does not support streaming and response_format at the same time. Please choose one."
254
+ raise ValueError(msg)
255
+
256
+ ollama_tools = [{"type": "function", "function": {**t.tool_spec}} for t in tools] if tools else None
257
+
258
+ ollama_messages = [_convert_chatmessage_to_ollama_format(msg) for msg in messages]
182
259
  response = self._client.chat(
183
- model=self.model, messages=messages, stream=stream, keep_alive=self.keep_alive, options=generation_kwargs
260
+ model=self.model,
261
+ messages=ollama_messages,
262
+ tools=ollama_tools,
263
+ stream=stream,
264
+ keep_alive=self.keep_alive,
265
+ options=generation_kwargs,
266
+ format=self.response_format,
184
267
  )
185
268
 
186
269
  if stream:
187
- chunks: List[StreamingChunk] = self._handle_streaming_response(response)
188
- return self._convert_to_streaming_response(chunks)
270
+ return self._handle_streaming_response(response)
189
271
 
190
- return {"replies": [self._build_message_from_ollama_response(response)]}
272
+ return {"replies": [_convert_ollama_response_to_chatmessage(response)]}
@@ -0,0 +1,529 @@
1
+ import json
2
+ from unittest.mock import Mock, patch
3
+
4
+ import pytest
5
+ from haystack.components.generators.utils import print_streaming_chunk
6
+ from haystack.dataclasses import (
7
+ ChatMessage,
8
+ ChatRole,
9
+ StreamingChunk,
10
+ TextContent,
11
+ ToolCall,
12
+ )
13
+ from haystack.tools import Tool
14
+ from ollama._types import ChatResponse, ResponseError
15
+
16
+ from haystack_integrations.components.generators.ollama.chat.chat_generator import (
17
+ OllamaChatGenerator,
18
+ _convert_chatmessage_to_ollama_format,
19
+ _convert_ollama_response_to_chatmessage,
20
+ )
21
+
22
+
23
+ @pytest.fixture
24
+ def tools():
25
+ tool_parameters = {
26
+ "type": "object",
27
+ "properties": {"city": {"type": "string"}},
28
+ "required": ["city"],
29
+ }
30
+ tool = Tool(
31
+ name="weather",
32
+ description="useful to determine the weather in a given location",
33
+ parameters=tool_parameters,
34
+ function=lambda x: x,
35
+ )
36
+
37
+ return [tool]
38
+
39
+
40
+ def test_convert_chatmessage_to_ollama_format():
41
+ message = ChatMessage.from_system("You are good assistant")
42
+ assert _convert_chatmessage_to_ollama_format(message) == {
43
+ "role": "system",
44
+ "content": "You are good assistant",
45
+ }
46
+
47
+ message = ChatMessage.from_user("I have a question")
48
+ assert _convert_chatmessage_to_ollama_format(message) == {
49
+ "role": "user",
50
+ "content": "I have a question",
51
+ }
52
+
53
+ message = ChatMessage.from_assistant(text="I have an answer", meta={"finish_reason": "stop"})
54
+ assert _convert_chatmessage_to_ollama_format(message) == {
55
+ "role": "assistant",
56
+ "content": "I have an answer",
57
+ }
58
+
59
+ message = ChatMessage.from_assistant(
60
+ tool_calls=[ToolCall(id="123", tool_name="weather", arguments={"city": "Paris"})]
61
+ )
62
+ assert _convert_chatmessage_to_ollama_format(message) == {
63
+ "role": "assistant",
64
+ "tool_calls": [
65
+ {
66
+ "type": "function",
67
+ "function": {"name": "weather", "arguments": {"city": "Paris"}},
68
+ }
69
+ ],
70
+ }
71
+
72
+ tool_result = json.dumps({"weather": "sunny", "temperature": "25"})
73
+ message = ChatMessage.from_tool(
74
+ tool_result=tool_result,
75
+ origin=ToolCall(tool_name="weather", arguments={"city": "Paris"}),
76
+ )
77
+ assert _convert_chatmessage_to_ollama_format(message) == {
78
+ "role": "tool",
79
+ "content": tool_result,
80
+ }
81
+
82
+
83
+ def test_convert_chatmessage_to_ollama_invalid():
84
+ message = ChatMessage(_role=ChatRole.ASSISTANT, _content=[])
85
+ with pytest.raises(ValueError):
86
+ _convert_chatmessage_to_ollama_format(message)
87
+
88
+ message = ChatMessage(
89
+ _role=ChatRole.ASSISTANT,
90
+ _content=[
91
+ TextContent(text="I have an answer"),
92
+ TextContent(text="I have another answer"),
93
+ ],
94
+ )
95
+ with pytest.raises(ValueError):
96
+ _convert_chatmessage_to_ollama_format(message)
97
+
98
+
99
+ def test_convert_ollama_response_to_chatmessage():
100
+ model = "some_model"
101
+
102
+ ollama_response = ChatResponse(
103
+ model=model,
104
+ created_at="2023-12-12T14:13:43.416799Z",
105
+ message={"role": "assistant", "content": "Hello! How are you today?"},
106
+ done=True,
107
+ total_duration=5191566416,
108
+ load_duration=2154458,
109
+ prompt_eval_count=26,
110
+ prompt_eval_duration=383809000,
111
+ eval_count=298,
112
+ eval_duration=4799921000,
113
+ )
114
+
115
+ observed = _convert_ollama_response_to_chatmessage(ollama_response)
116
+
117
+ assert observed.role == "assistant"
118
+ assert observed.text == "Hello! How are you today?"
119
+
120
+
121
+ def test_convert_ollama_response_to_chatmessage_with_tools():
122
+ model = "some_model"
123
+
124
+ ollama_response = ChatResponse(
125
+ model=model,
126
+ created_at="2023-12-12T14:13:43.416799Z",
127
+ message={
128
+ "role": "assistant",
129
+ "content": "",
130
+ "tool_calls": [
131
+ {
132
+ "function": {
133
+ "name": "get_current_weather",
134
+ "arguments": {"format": "celsius", "location": "Paris, FR"},
135
+ }
136
+ }
137
+ ],
138
+ },
139
+ done=True,
140
+ total_duration=5191566416,
141
+ load_duration=2154458,
142
+ prompt_eval_count=26,
143
+ prompt_eval_duration=383809000,
144
+ eval_count=298,
145
+ eval_duration=4799921000,
146
+ )
147
+
148
+ observed = _convert_ollama_response_to_chatmessage(ollama_response)
149
+
150
+ assert observed.role == "assistant"
151
+ assert observed.text == ""
152
+ assert observed.tool_call == ToolCall(
153
+ tool_name="get_current_weather",
154
+ arguments={"format": "celsius", "location": "Paris, FR"},
155
+ )
156
+
157
+
158
+ class TestOllamaChatGenerator:
159
+ def test_init_default(self):
160
+ component = OllamaChatGenerator()
161
+ assert component.model == "orca-mini"
162
+ assert component.url == "http://localhost:11434"
163
+ assert component.generation_kwargs == {}
164
+ assert component.timeout == 120
165
+ assert component.streaming_callback is None
166
+ assert component.tools is None
167
+ assert component.keep_alive is None
168
+ assert component.response_format is None
169
+
170
+ def test_init(self, tools):
171
+ component = OllamaChatGenerator(
172
+ model="llama2",
173
+ url="http://my-custom-endpoint:11434",
174
+ generation_kwargs={"temperature": 0.5},
175
+ timeout=5,
176
+ keep_alive="10m",
177
+ streaming_callback=print_streaming_chunk,
178
+ tools=tools,
179
+ response_format={"type": "object", "properties": {"name": {"type": "string"}, "age": {"type": "number"}}},
180
+ )
181
+
182
+ assert component.model == "llama2"
183
+ assert component.url == "http://my-custom-endpoint:11434"
184
+ assert component.generation_kwargs == {"temperature": 0.5}
185
+ assert component.timeout == 5
186
+ assert component.keep_alive == "10m"
187
+ assert component.streaming_callback is print_streaming_chunk
188
+ assert component.tools == tools
189
+ assert component.response_format == {
190
+ "type": "object",
191
+ "properties": {"name": {"type": "string"}, "age": {"type": "number"}},
192
+ }
193
+
194
+ def test_init_fail_with_duplicate_tool_names(self, tools):
195
+
196
+ duplicate_tools = [tools[0], tools[0]]
197
+ with pytest.raises(ValueError):
198
+ OllamaChatGenerator(tools=duplicate_tools)
199
+
200
+ def test_to_dict(self):
201
+ tool = Tool(
202
+ name="name",
203
+ description="description",
204
+ parameters={"x": {"type": "string"}},
205
+ function=print,
206
+ )
207
+
208
+ component = OllamaChatGenerator(
209
+ model="llama2",
210
+ streaming_callback=print_streaming_chunk,
211
+ url="custom_url",
212
+ generation_kwargs={"max_tokens": 10, "some_test_param": "test-params"},
213
+ tools=[tool],
214
+ keep_alive="5m",
215
+ response_format={"type": "object", "properties": {"name": {"type": "string"}, "age": {"type": "number"}}},
216
+ )
217
+ data = component.to_dict()
218
+ assert data == {
219
+ "type": "haystack_integrations.components.generators.ollama.chat.chat_generator.OllamaChatGenerator",
220
+ "init_parameters": {
221
+ "timeout": 120,
222
+ "model": "llama2",
223
+ "url": "custom_url",
224
+ "streaming_callback": "haystack.components.generators.utils.print_streaming_chunk",
225
+ "keep_alive": "5m",
226
+ "generation_kwargs": {
227
+ "max_tokens": 10,
228
+ "some_test_param": "test-params",
229
+ },
230
+ "tools": [
231
+ {
232
+ "type": "haystack.tools.tool.Tool",
233
+ "data": {
234
+ "description": "description",
235
+ "function": "builtins.print",
236
+ "name": "name",
237
+ "parameters": {
238
+ "x": {
239
+ "type": "string",
240
+ },
241
+ },
242
+ },
243
+ },
244
+ ],
245
+ "response_format": {
246
+ "type": "object",
247
+ "properties": {"name": {"type": "string"}, "age": {"type": "number"}},
248
+ },
249
+ },
250
+ }
251
+
252
+ def test_from_dict(self):
253
+ tool = Tool(
254
+ name="name",
255
+ description="description",
256
+ parameters={"x": {"type": "string"}},
257
+ function=print,
258
+ )
259
+
260
+ data = {
261
+ "type": "haystack_integrations.components.generators.ollama.chat.chat_generator.OllamaChatGenerator",
262
+ "init_parameters": {
263
+ "timeout": 120,
264
+ "model": "llama2",
265
+ "url": "custom_url",
266
+ "keep_alive": "5m",
267
+ "streaming_callback": "haystack.components.generators.utils.print_streaming_chunk",
268
+ "generation_kwargs": {
269
+ "max_tokens": 10,
270
+ "some_test_param": "test-params",
271
+ },
272
+ "tools": [
273
+ {
274
+ "type": "haystack.tools.tool.Tool",
275
+ "data": {
276
+ "description": "description",
277
+ "function": "builtins.print",
278
+ "name": "name",
279
+ "parameters": {
280
+ "x": {
281
+ "type": "string",
282
+ },
283
+ },
284
+ },
285
+ },
286
+ ],
287
+ "response_format": {
288
+ "type": "object",
289
+ "properties": {"name": {"type": "string"}, "age": {"type": "number"}},
290
+ },
291
+ },
292
+ }
293
+ component = OllamaChatGenerator.from_dict(data)
294
+ assert component.model == "llama2"
295
+ assert component.streaming_callback is print_streaming_chunk
296
+ assert component.url == "custom_url"
297
+ assert component.keep_alive == "5m"
298
+ assert component.generation_kwargs == {
299
+ "max_tokens": 10,
300
+ "some_test_param": "test-params",
301
+ }
302
+ assert component.timeout == 120
303
+ assert component.tools == [tool]
304
+ assert component.response_format == {
305
+ "type": "object",
306
+ "properties": {"name": {"type": "string"}, "age": {"type": "number"}},
307
+ }
308
+
309
+ @patch("haystack_integrations.components.generators.ollama.chat.chat_generator.Client")
310
+ def test_run(self, mock_client):
311
+ generator = OllamaChatGenerator()
312
+
313
+ mock_response = ChatResponse(
314
+ model="llama3.2",
315
+ created_at="2023-12-12T14:13:43.416799Z",
316
+ message={
317
+ "role": "assistant",
318
+ "content": "Fine. How can I help you today?",
319
+ },
320
+ done=True,
321
+ total_duration=5191566416,
322
+ load_duration=2154458,
323
+ prompt_eval_count=26,
324
+ prompt_eval_duration=383809000,
325
+ eval_count=298,
326
+ eval_duration=4799921000,
327
+ )
328
+
329
+ mock_client_instance = mock_client.return_value
330
+ mock_client_instance.chat.return_value = mock_response
331
+
332
+ result = generator.run(messages=[ChatMessage.from_user("Hello! How are you today?")])
333
+
334
+ mock_client_instance.chat.assert_called_once_with(
335
+ model="orca-mini",
336
+ messages=[{"role": "user", "content": "Hello! How are you today?"}],
337
+ stream=False,
338
+ tools=None,
339
+ options={},
340
+ keep_alive=None,
341
+ format=None,
342
+ )
343
+
344
+ assert "replies" in result
345
+ assert len(result["replies"]) == 1
346
+ assert result["replies"][0].text == "Fine. How can I help you today?"
347
+ assert result["replies"][0].role == "assistant"
348
+
349
+ @patch("haystack_integrations.components.generators.ollama.chat.chat_generator.Client")
350
+ def test_run_streaming(self, mock_client):
351
+ streaming_callback_called = False
352
+
353
+ def streaming_callback(_: StreamingChunk) -> None:
354
+ nonlocal streaming_callback_called
355
+ streaming_callback_called = True
356
+
357
+ generator = OllamaChatGenerator(streaming_callback=streaming_callback)
358
+
359
+ mock_response = iter(
360
+ [
361
+ ChatResponse(
362
+ model="llama3.2",
363
+ created_at="2023-12-12T14:13:43.416799Z",
364
+ message={"role": "assistant", "content": "first chunk "},
365
+ done=False,
366
+ ),
367
+ ChatResponse(
368
+ model="llama3.2",
369
+ created_at="2023-12-12T14:13:43.416799Z",
370
+ message={"role": "assistant", "content": "second chunk"},
371
+ done=True,
372
+ total_duration=4883583458,
373
+ load_duration=1334875,
374
+ prompt_eval_count=26,
375
+ prompt_eval_duration=342546000,
376
+ eval_count=282,
377
+ eval_duration=4535599000,
378
+ ),
379
+ ]
380
+ )
381
+
382
+ mock_client_instance = mock_client.return_value
383
+ mock_client_instance.chat.return_value = mock_response
384
+
385
+ result = generator.run(messages=[ChatMessage.from_user("irrelevant")])
386
+
387
+ assert streaming_callback_called
388
+
389
+ assert "replies" in result
390
+ assert len(result["replies"]) == 1
391
+ assert result["replies"][0].text == "first chunk second chunk"
392
+ assert result["replies"][0].role == "assistant"
393
+
394
+ def test_run_fail_with_tools_and_streaming(self, tools):
395
+ component = OllamaChatGenerator(tools=tools, streaming_callback=print_streaming_chunk)
396
+
397
+ with pytest.raises(ValueError):
398
+ message = ChatMessage.from_user("irrelevant")
399
+ component.run([message])
400
+
401
+ @pytest.mark.integration
402
+ def test_live_run(self):
403
+ chat_generator = OllamaChatGenerator(model="llama3.2:3b")
404
+
405
+ user_questions_and_assistant_answers = [
406
+ ("What's the capital of France?", "Paris"),
407
+ ("What is the capital of Canada?", "Ottawa"),
408
+ ("What is the capital of Ghana?", "Accra"),
409
+ ]
410
+
411
+ for question, answer in user_questions_and_assistant_answers:
412
+ message = ChatMessage.from_user(question)
413
+
414
+ response = chat_generator.run([message])
415
+
416
+ assert isinstance(response, dict)
417
+ assert isinstance(response["replies"], list)
418
+ assert answer in response["replies"][0].text
419
+
420
+ @pytest.mark.integration
421
+ def test_run_with_chat_history(self):
422
+ chat_generator = OllamaChatGenerator(model="llama3.2:3b")
423
+
424
+ chat_messages = [
425
+ ChatMessage.from_user("What is the largest city in the United Kingdom by population?"),
426
+ ChatMessage.from_assistant("London is the largest city in the United Kingdom by population"),
427
+ ChatMessage.from_user("And what is the second largest?"),
428
+ ]
429
+
430
+ response = chat_generator.run(chat_messages)
431
+
432
+ assert isinstance(response, dict)
433
+ assert isinstance(response["replies"], list)
434
+
435
+ assert any(city in response["replies"][-1].text for city in ["Manchester", "Birmingham", "Glasgow"])
436
+
437
+ @pytest.mark.integration
438
+ def test_run_model_unavailable(self):
439
+ component = OllamaChatGenerator(model="unknown_model")
440
+
441
+ with pytest.raises(ResponseError):
442
+ message = ChatMessage.from_user("irrelevant")
443
+ component.run([message])
444
+
445
+ @pytest.mark.integration
446
+ def test_run_with_streaming(self):
447
+ streaming_callback = Mock()
448
+ chat_generator = OllamaChatGenerator(model="llama3.2:3b", streaming_callback=streaming_callback)
449
+
450
+ chat_messages = [
451
+ ChatMessage.from_user("What is the largest city in the United Kingdom by population?"),
452
+ ChatMessage.from_assistant("London is the largest city in the United Kingdom by population"),
453
+ ChatMessage.from_user("And what is the second largest?"),
454
+ ]
455
+
456
+ response = chat_generator.run(chat_messages)
457
+
458
+ streaming_callback.assert_called()
459
+
460
+ assert isinstance(response, dict)
461
+ assert isinstance(response["replies"], list)
462
+ assert any(city in response["replies"][-1].text for city in ["Manchester", "Birmingham", "Glasgow"])
463
+
464
+ @pytest.mark.integration
465
+ def test_run_with_tools(self, tools):
466
+ chat_generator = OllamaChatGenerator(model="llama3.2:3b", tools=tools)
467
+
468
+ message = ChatMessage.from_user("What is the weather in Paris?")
469
+ response = chat_generator.run([message])
470
+
471
+ assert len(response["replies"]) == 1
472
+ message = response["replies"][0]
473
+
474
+ assert message.tool_calls
475
+ tool_call = message.tool_call
476
+ assert isinstance(tool_call, ToolCall)
477
+ assert tool_call.tool_name == "weather"
478
+ assert tool_call.arguments == {"city": "Paris"}
479
+
480
+ @pytest.mark.integration
481
+ def test_run_with_response_format(self):
482
+ response_format = {
483
+ "type": "object",
484
+ "properties": {"capital": {"type": "string"}, "population": {"type": "number"}},
485
+ }
486
+ chat_generator = OllamaChatGenerator(model="llama3.2:3b", response_format=response_format)
487
+
488
+ message = ChatMessage.from_user("What's the capital of France and its population?")
489
+ response = chat_generator.run([message])
490
+
491
+ assert isinstance(response, dict)
492
+ assert isinstance(response["replies"], list)
493
+
494
+ # Parse the response text as JSON and verify its structure
495
+ response_data = json.loads(response["replies"][0].text)
496
+ assert isinstance(response_data, dict)
497
+ assert "capital" in response_data
498
+ assert isinstance(response_data["capital"], str)
499
+ assert "population" in response_data
500
+ assert isinstance(response_data["population"], (int, float))
501
+ assert response_data["capital"] == "Paris"
502
+
503
+ def test_run_with_streaming_and_format(self):
504
+ response_format = {
505
+ "type": "object",
506
+ "properties": {"answer": {"type": "string"}},
507
+ }
508
+ streaming_callback = Mock()
509
+ chat_generator = OllamaChatGenerator(
510
+ model="llama3.2:3b", streaming_callback=streaming_callback, response_format=response_format
511
+ )
512
+
513
+ chat_messages = [
514
+ ChatMessage.from_user("What is the largest city in the United Kingdom by population?"),
515
+ ChatMessage.from_assistant("London is the largest city in the United Kingdom by population"),
516
+ ChatMessage.from_user("And what is the second largest?"),
517
+ ]
518
+ with pytest.raises(ValueError):
519
+ chat_generator.run([chat_messages])
520
+
521
+ def test_run_with_tools_and_format(self, tools):
522
+ response_format = {
523
+ "type": "object",
524
+ "properties": {"capital": {"type": "string"}, "population": {"type": "number"}},
525
+ }
526
+ chat_generator = OllamaChatGenerator(model="llama3.2:3b", tools=tools, response_format=response_format)
527
+ message = ChatMessage.from_user("What's the weather in Paris?")
528
+ with pytest.raises(ValueError):
529
+ chat_generator.run([message])
@@ -19,7 +19,7 @@ class TestOllamaGenerator:
19
19
  ("What is the capital of Ghana?", "Accra"),
20
20
  ]
21
21
 
22
- component = OllamaGenerator()
22
+ component = OllamaGenerator(model="llama3.2:3b")
23
23
 
24
24
  for prompt, answer in prompts_and_answers:
25
25
  results = component.run(prompt=prompt)
@@ -147,7 +147,7 @@ class TestOllamaGenerator:
147
147
  return chunk
148
148
 
149
149
  callback = Callback()
150
- component = OllamaGenerator(streaming_callback=callback)
150
+ component = OllamaGenerator(model="llama3.2:3b", streaming_callback=callback)
151
151
  results = component.run(prompt="What's the capital of Netherlands?")
152
152
 
153
153
  assert len(results["replies"]) == 1
@@ -1,169 +0,0 @@
1
- from typing import List
2
- from unittest.mock import Mock
3
-
4
- import pytest
5
- from haystack.components.generators.utils import print_streaming_chunk
6
- from haystack.dataclasses import ChatMessage
7
- from ollama._types import ChatResponse, ResponseError
8
-
9
- from haystack_integrations.components.generators.ollama import OllamaChatGenerator
10
-
11
-
12
- @pytest.fixture
13
- def chat_messages() -> List[ChatMessage]:
14
- return [
15
- ChatMessage.from_user("Tell me about why Super Mario is the greatest superhero"),
16
- ChatMessage.from_assistant(
17
- "Super Mario has prevented Bowser from destroying the world", {"something": "something"}
18
- ),
19
- ]
20
-
21
-
22
- class TestOllamaChatGenerator:
23
- def test_init_default(self):
24
- component = OllamaChatGenerator()
25
- assert component.model == "orca-mini"
26
- assert component.url == "http://localhost:11434"
27
- assert component.generation_kwargs == {}
28
- assert component.timeout == 120
29
- assert component.keep_alive is None
30
-
31
- def test_init(self):
32
- component = OllamaChatGenerator(
33
- model="llama2",
34
- url="http://my-custom-endpoint:11434",
35
- generation_kwargs={"temperature": 0.5},
36
- keep_alive="10m",
37
- timeout=5,
38
- )
39
-
40
- assert component.model == "llama2"
41
- assert component.url == "http://my-custom-endpoint:11434"
42
- assert component.generation_kwargs == {"temperature": 0.5}
43
- assert component.timeout == 5
44
- assert component.keep_alive == "10m"
45
-
46
- def test_to_dict(self):
47
- component = OllamaChatGenerator(
48
- model="llama2",
49
- streaming_callback=print_streaming_chunk,
50
- url="custom_url",
51
- generation_kwargs={"max_tokens": 10, "some_test_param": "test-params"},
52
- keep_alive="5m",
53
- )
54
- data = component.to_dict()
55
- assert data == {
56
- "type": "haystack_integrations.components.generators.ollama.chat.chat_generator.OllamaChatGenerator",
57
- "init_parameters": {
58
- "timeout": 120,
59
- "model": "llama2",
60
- "keep_alive": "5m",
61
- "url": "custom_url",
62
- "streaming_callback": "haystack.components.generators.utils.print_streaming_chunk",
63
- "generation_kwargs": {"max_tokens": 10, "some_test_param": "test-params"},
64
- },
65
- }
66
-
67
- def test_from_dict(self):
68
- data = {
69
- "type": "haystack_integrations.components.generators.ollama.chat.chat_generator.OllamaChatGenerator",
70
- "init_parameters": {
71
- "timeout": 120,
72
- "model": "llama2",
73
- "url": "custom_url",
74
- "keep_alive": "5m",
75
- "streaming_callback": "haystack.components.generators.utils.print_streaming_chunk",
76
- "generation_kwargs": {"max_tokens": 10, "some_test_param": "test-params"},
77
- },
78
- }
79
- component = OllamaChatGenerator.from_dict(data)
80
- assert component.model == "llama2"
81
- assert component.streaming_callback is print_streaming_chunk
82
- assert component.url == "custom_url"
83
- assert component.generation_kwargs == {"max_tokens": 10, "some_test_param": "test-params"}
84
- assert component.keep_alive == "5m"
85
-
86
- def test_build_message_from_ollama_response(self):
87
- model = "some_model"
88
-
89
- ollama_response = ChatResponse(
90
- model=model,
91
- created_at="2023-12-12T14:13:43.416799Z",
92
- message={"role": "assistant", "content": "Hello! How are you today?"},
93
- done=True,
94
- total_duration=5191566416,
95
- load_duration=2154458,
96
- prompt_eval_count=26,
97
- prompt_eval_duration=383809000,
98
- eval_count=298,
99
- eval_duration=4799921000,
100
- )
101
-
102
- observed = OllamaChatGenerator(model=model)._build_message_from_ollama_response(ollama_response)
103
-
104
- assert observed.role == "assistant"
105
- assert observed.text == "Hello! How are you today?"
106
-
107
- @pytest.mark.integration
108
- def test_run(self):
109
- chat_generator = OllamaChatGenerator()
110
-
111
- user_questions_and_assistant_answers = [
112
- ("What's the capital of France?", "Paris"),
113
- ("What is the capital of Canada?", "Ottawa"),
114
- ("What is the capital of Ghana?", "Accra"),
115
- ]
116
-
117
- for question, answer in user_questions_and_assistant_answers:
118
- message = ChatMessage.from_user(question)
119
-
120
- response = chat_generator.run([message])
121
-
122
- assert isinstance(response, dict)
123
- assert isinstance(response["replies"], list)
124
- assert answer in response["replies"][0].text
125
-
126
- @pytest.mark.integration
127
- def test_run_with_chat_history(self):
128
- chat_generator = OllamaChatGenerator()
129
-
130
- chat_history = [
131
- ChatMessage.from_user("What is the largest city in the United Kingdom by population?"),
132
- ChatMessage.from_assistant("London is the largest city in the United Kingdom by population"),
133
- ChatMessage.from_user("And what is the second largest?"),
134
- ]
135
-
136
- response = chat_generator.run(chat_history)
137
-
138
- assert isinstance(response, dict)
139
- assert isinstance(response["replies"], list)
140
- assert "Manchester" in response["replies"][-1].text or "Glasgow" in response["replies"][-1].text
141
-
142
- @pytest.mark.integration
143
- def test_run_model_unavailable(self):
144
- component = OllamaChatGenerator(model="Alistair_and_Stefano_are_great")
145
-
146
- with pytest.raises(ResponseError):
147
- message = ChatMessage.from_user(
148
- "Based on your infinite wisdom, can you tell me why Alistair and Stefano are so great?"
149
- )
150
- component.run([message])
151
-
152
- @pytest.mark.integration
153
- def test_run_with_streaming(self):
154
- streaming_callback = Mock()
155
- chat_generator = OllamaChatGenerator(streaming_callback=streaming_callback)
156
-
157
- chat_history = [
158
- ChatMessage.from_user("What is the largest city in the United Kingdom by population?"),
159
- ChatMessage.from_assistant("London is the largest city in the United Kingdom by population"),
160
- ChatMessage.from_user("And what is the second largest?"),
161
- ]
162
-
163
- response = chat_generator.run(chat_history)
164
-
165
- streaming_callback.assert_called()
166
-
167
- assert isinstance(response, dict)
168
- assert isinstance(response["replies"], list)
169
- assert "Manchester" in response["replies"][-1].text or "Glasgow" in response["replies"][-1].text