Skip to content

Commit e6427a6

Browse files
TaoChenOSUmoonbox3
andauthored
Python: Ollama tool call and image content (microsoft#9422)
### Motivation and Context <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> Addresses: https://github.com/orgs/microsoft/projects/866/views/59?sliceBy%5Bvalue%5D=python&pane=issue&itemId=72604972&issue=microsoft%7Csemantic-kernel%7C7539 Function calling has been added to Ollama. This PR adds support of the feature to the Ollama AI connector. > Note: streaming function calling is not supported by Ollama. Image content support is also added. ### Description <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> 1. Ollama function calling 2. Ollama image content ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone 😄 --------- Co-authored-by: Evan Mattson <[email protected]>
1 parent f14e431 commit e6427a6

11 files changed

+492
-21
lines changed

Diff for: .github/workflows/python-integration-tests.yml

+12-4
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ jobs:
9696
if: matrix.os == 'ubuntu-latest'
9797
run: |
9898
ollama pull ${{ vars.OLLAMA_CHAT_MODEL_ID }}
99+
ollama pull ${{ vars.OLLAMA_CHAT_MODEL_ID_IMAGE }}
100+
ollama pull ${{ vars.OLLAMA_CHAT_MODEL_ID_TOOL_CALL }}
99101
ollama pull ${{ vars.OLLAMA_TEXT_MODEL_ID }}
100102
ollama pull ${{ vars.OLLAMA_EMBEDDING_MODEL_ID }}
101103
ollama list
@@ -156,8 +158,10 @@ jobs:
156158
MISTRALAI_EMBEDDING_MODEL_ID: ${{ vars.MISTRALAI_EMBEDDING_MODEL_ID }}
157159
ANTHROPIC_API_KEY: ${{secrets.ANTHROPIC_API_KEY}}
158160
ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
159-
OLLAMA_CHAT_MODEL_ID: "${{ matrix.os == 'ubuntu-latest' && vars.OLLAMA_CHAT_MODEL_ID || '' }}" # phi3
160-
OLLAMA_TEXT_MODEL_ID: "${{ matrix.os == 'ubuntu-latest' && vars.OLLAMA_TEXT_MODEL_ID || '' }}" # phi3
161+
OLLAMA_CHAT_MODEL_ID: "${{ matrix.os == 'ubuntu-latest' && vars.OLLAMA_CHAT_MODEL_ID || '' }}" # llava-phi3
162+
OLLAMA_CHAT_MODEL_ID_IMAGE: "${{ matrix.os == 'ubuntu-latest' && vars.OLLAMA_CHAT_MODEL_ID_IMAGE || '' }}" # llava-phi3
163+
OLLAMA_CHAT_MODEL_ID_TOOL_CALL: "${{ matrix.os == 'ubuntu-latest' && vars.OLLAMA_CHAT_MODEL_ID_TOOL_CALL || '' }}" # llama3.2
164+
OLLAMA_TEXT_MODEL_ID: "${{ matrix.os == 'ubuntu-latest' && vars.OLLAMA_TEXT_MODEL_ID || '' }}" # llava-phi3
161165
OLLAMA_EMBEDDING_MODEL_ID: "${{ matrix.os == 'ubuntu-latest' && vars.OLLAMA_EMBEDDING_MODEL_ID || '' }}" # nomic-embed-text
162166
GOOGLE_AI_GEMINI_MODEL_ID: ${{ vars.GOOGLE_AI_GEMINI_MODEL_ID }}
163167
GOOGLE_AI_EMBEDDING_MODEL_ID: ${{ vars.GOOGLE_AI_EMBEDDING_MODEL_ID }}
@@ -232,6 +236,8 @@ jobs:
232236
if: matrix.os == 'ubuntu-latest'
233237
run: |
234238
ollama pull ${{ vars.OLLAMA_CHAT_MODEL_ID }}
239+
ollama pull ${{ vars.OLLAMA_CHAT_MODEL_ID_IMAGE }}
240+
ollama pull ${{ vars.OLLAMA_CHAT_MODEL_ID_TOOL_CALL }}
235241
ollama pull ${{ vars.OLLAMA_TEXT_MODEL_ID }}
236242
ollama pull ${{ vars.OLLAMA_EMBEDDING_MODEL_ID }}
237243
ollama list
@@ -292,8 +298,10 @@ jobs:
292298
MISTRALAI_EMBEDDING_MODEL_ID: ${{ vars.MISTRALAI_EMBEDDING_MODEL_ID }}
293299
ANTHROPIC_API_KEY: ${{secrets.ANTHROPIC_API_KEY}}
294300
ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
295-
OLLAMA_CHAT_MODEL_ID: "${{ matrix.os == 'ubuntu-latest' && vars.OLLAMA_CHAT_MODEL_ID || '' }}" # phi3
296-
OLLAMA_TEXT_MODEL_ID: "${{ matrix.os == 'ubuntu-latest' && vars.OLLAMA_TEXT_MODEL_ID || '' }}" # phi3
301+
OLLAMA_CHAT_MODEL_ID: "${{ matrix.os == 'ubuntu-latest' && vars.OLLAMA_CHAT_MODEL_ID || '' }}" # llava-phi3
302+
OLLAMA_CHAT_MODEL_ID_IMAGE: "${{ matrix.os == 'ubuntu-latest' && vars.OLLAMA_CHAT_MODEL_ID_IMAGE || '' }}" # llava-phi3
303+
OLLAMA_CHAT_MODEL_ID_TOOL_CALL: "${{ matrix.os == 'ubuntu-latest' && vars.OLLAMA_CHAT_MODEL_ID_TOOL_CALL || '' }}" # llama3.2
304+
OLLAMA_TEXT_MODEL_ID: "${{ matrix.os == 'ubuntu-latest' && vars.OLLAMA_TEXT_MODEL_ID || '' }}" # llava-phi3
297305
OLLAMA_EMBEDDING_MODEL_ID: "${{ matrix.os == 'ubuntu-latest' && vars.OLLAMA_EMBEDDING_MODEL_ID || '' }}" # nomic-embed-text
298306
GOOGLE_AI_GEMINI_MODEL_ID: ${{ vars.GOOGLE_AI_GEMINI_MODEL_ID }}
299307
GOOGLE_AI_EMBEDDING_MODEL_ID: ${{ vars.GOOGLE_AI_EMBEDDING_MODEL_ID }}

Diff for: python/semantic_kernel/connectors/ai/google/google_ai/services/google_ai_chat_completion.py

-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

3-
43
import logging
54
import sys
65
from collections.abc import AsyncGenerator, Callable

Diff for: python/semantic_kernel/connectors/ai/ollama/ollama_prompt_execution_settings.py

+8
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from typing import Any, Literal
44

5+
from pydantic import Field
6+
57
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
68

79

@@ -27,6 +29,12 @@ class OllamaTextPromptExecutionSettings(OllamaPromptExecutionSettings):
2729
class OllamaChatPromptExecutionSettings(OllamaPromptExecutionSettings):
2830
"""Settings for Ollama chat prompt execution."""
2931

32+
tools: list[dict[str, Any]] | None = Field(
33+
None,
34+
max_length=64,
35+
description="Do not set this manually. It is set by the service based on the function choice configuration.",
36+
)
37+
3038

3139
class OllamaEmbeddingPromptExecutionSettings(OllamaPromptExecutionSettings):
3240
"""Settings for Ollama embedding prompt execution."""

Diff for: python/semantic_kernel/connectors/ai/ollama/services/ollama_chat_completion.py

+132-15
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import logging
44
import sys
5-
from collections.abc import AsyncGenerator, AsyncIterator, Mapping
5+
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping
66
from typing import TYPE_CHECKING, Any, ClassVar
77

88
if sys.version_info >= (3, 12):
@@ -12,17 +12,33 @@
1212

1313
import httpx
1414
from ollama import AsyncClient
15+
from ollama._types import Message
1516
from pydantic import ValidationError
1617

1718
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
19+
from semantic_kernel.connectors.ai.completion_usage import CompletionUsage
20+
from semantic_kernel.connectors.ai.function_call_choice_configuration import FunctionCallChoiceConfiguration
21+
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceType
1822
from semantic_kernel.connectors.ai.ollama.ollama_prompt_execution_settings import OllamaChatPromptExecutionSettings
1923
from semantic_kernel.connectors.ai.ollama.ollama_settings import OllamaSettings
2024
from semantic_kernel.connectors.ai.ollama.services.ollama_base import OllamaBase
25+
from semantic_kernel.connectors.ai.ollama.services.utils import (
26+
MESSAGE_CONVERTERS,
27+
update_settings_from_function_choice_configuration,
28+
)
2129
from semantic_kernel.contents import AuthorRole
2230
from semantic_kernel.contents.chat_history import ChatHistory
23-
from semantic_kernel.contents.chat_message_content import ChatMessageContent
31+
from semantic_kernel.contents.chat_message_content import ITEM_TYPES, ChatMessageContent
32+
from semantic_kernel.contents.function_call_content import FunctionCallContent
33+
from semantic_kernel.contents.streaming_chat_message_content import ITEM_TYPES as STREAMING_ITEM_TYPES
2434
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
25-
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError, ServiceInvalidResponseError
35+
from semantic_kernel.contents.streaming_text_content import StreamingTextContent
36+
from semantic_kernel.contents.text_content import TextContent
37+
from semantic_kernel.exceptions.service_exceptions import (
38+
ServiceInitializationError,
39+
ServiceInvalidExecutionSettingsError,
40+
ServiceInvalidResponseError,
41+
)
2642
from semantic_kernel.utils.telemetry.model_diagnostics.decorators import (
2743
trace_chat_completion,
2844
trace_streaming_chat_completion,
@@ -40,7 +56,7 @@ class OllamaChatCompletion(OllamaBase, ChatCompletionClientBase):
4056
Make sure to have the ollama service running either locally or remotely.
4157
"""
4258

43-
SUPPORTS_FUNCTION_CALLING: ClassVar[bool] = False
59+
SUPPORTS_FUNCTION_CALLING: ClassVar[bool] = True
4460

4561
def __init__(
4662
self,
@@ -97,6 +113,36 @@ def service_url(self) -> str | None:
97113
return str(self.client._client.base_url)
98114
return None
99115

116+
@override
117+
def _prepare_chat_history_for_request(
118+
self,
119+
chat_history: ChatHistory,
120+
role_key: str = "role",
121+
content_key: str = "content",
122+
) -> list[Message]:
123+
return [MESSAGE_CONVERTERS[message.role](message) for message in chat_history.messages]
124+
125+
@override
126+
def _verify_function_choice_settings(self, settings: "PromptExecutionSettings") -> None:
127+
if settings.function_choice_behavior and settings.function_choice_behavior.type_ in [
128+
FunctionChoiceType.REQUIRED,
129+
FunctionChoiceType.NONE,
130+
]:
131+
raise ServiceInvalidExecutionSettingsError(
132+
"Ollama does not support function choice behavior of type 'required' or 'none' yet."
133+
)
134+
135+
@override
136+
def _update_function_choice_settings_callback(
137+
self,
138+
) -> Callable[[FunctionCallChoiceConfiguration, "PromptExecutionSettings", FunctionChoiceType], None]:
139+
return update_settings_from_function_choice_configuration
140+
141+
@override
142+
def _reset_function_choice_settings(self, settings: "PromptExecutionSettings") -> None:
143+
if hasattr(settings, "tools"):
144+
settings.tools = None
145+
100146
@override
101147
@trace_chat_completion(OllamaBase.MODEL_PROVIDER_NAME)
102148
async def _inner_get_chat_message_contents(
@@ -124,11 +170,9 @@ async def _inner_get_chat_message_contents(
124170
)
125171

126172
return [
127-
ChatMessageContent(
128-
inner_content=response_object,
129-
ai_model_id=self.ai_model_id,
130-
role=AuthorRole.ASSISTANT,
131-
content=response_object.get("message", {"content": None}).get("content", None),
173+
self._create_chat_message_content(
174+
response_object,
175+
self._get_metadata_from_response(response_object),
132176
)
133177
]
134178

@@ -143,6 +187,11 @@ async def _inner_get_streaming_chat_message_contents(
143187
settings = self.get_prompt_execution_settings_from_settings(settings)
144188
assert isinstance(settings, OllamaChatPromptExecutionSettings) # nosec
145189

190+
if settings.tools:
191+
raise ServiceInvalidExecutionSettingsError(
192+
"Ollama does not support tool calling in streaming chat completion."
193+
)
194+
146195
prepared_chat_history = self._prepare_chat_history_for_request(chat_history)
147196

148197
response_object = await self.client.chat(
@@ -160,13 +209,81 @@ async def _inner_get_streaming_chat_message_contents(
160209

161210
async for part in response_object:
162211
yield [
163-
StreamingChatMessageContent(
164-
role=AuthorRole.ASSISTANT,
165-
choice_index=0,
166-
inner_content=part,
167-
ai_model_id=self.ai_model_id,
168-
content=part.get("message", {"content": None}).get("content", None),
212+
self._create_streaming_chat_message_content(
213+
part,
214+
self._get_metadata_from_response(part),
169215
)
170216
]
171217

172218
# endregion
219+
220+
def _create_chat_message_content(self, response: Mapping[str, Any], metadata: dict[str, Any]) -> ChatMessageContent:
221+
"""Create a chat message content from the response."""
222+
items: list[ITEM_TYPES] = []
223+
if not (message := response.get("message", None)):
224+
raise ServiceInvalidResponseError("No message content found in response.")
225+
226+
if content := message.get("content", None):
227+
items.append(
228+
TextContent(
229+
text=content,
230+
inner_content=message,
231+
)
232+
)
233+
if tool_calls := message.get("tool_calls", None):
234+
for tool_call in tool_calls:
235+
items.append(
236+
FunctionCallContent(
237+
inner_content=tool_call,
238+
ai_model_id=self.ai_model_id,
239+
name=tool_call.get("function").get("name"),
240+
arguments=tool_call.get("function").get("arguments"),
241+
)
242+
)
243+
244+
return ChatMessageContent(
245+
role=AuthorRole.ASSISTANT,
246+
items=items,
247+
inner_content=response,
248+
metadata=metadata,
249+
)
250+
251+
def _create_streaming_chat_message_content(
252+
self, part: Mapping[str, Any], metadata: dict[str, Any]
253+
) -> StreamingChatMessageContent:
254+
"""Create a streaming chat message content from the response part."""
255+
items: list[STREAMING_ITEM_TYPES] = []
256+
if not (message := part.get("message", None)):
257+
raise ServiceInvalidResponseError("No message content found in response part.")
258+
259+
if content := message.get("content", None):
260+
items.append(
261+
StreamingTextContent(
262+
choice_index=0,
263+
text=content,
264+
inner_content=message,
265+
)
266+
)
267+
268+
return StreamingChatMessageContent(
269+
role=AuthorRole.ASSISTANT,
270+
choice_index=0,
271+
items=items,
272+
inner_content=part,
273+
ai_model_id=self.ai_model_id,
274+
metadata=metadata,
275+
)
276+
277+
def _get_metadata_from_response(self, response: Mapping[str, Any]) -> dict[str, Any]:
278+
"""Get metadata from the response."""
279+
metadata = {
280+
"model": response.get("model"),
281+
}
282+
283+
if "prompt_eval_count" in response and "eval_count" in response:
284+
metadata["usage"] = CompletionUsage(
285+
prompt_tokens=response.get("prompt_eval_count"),
286+
completion_tokens=response.get("eval_count"),
287+
)
288+
289+
return metadata

0 commit comments

Comments
 (0)