Python : Ollama Connector for Agent Framework (#1104)

* Initial Commit for Olama Connector

* Added Olama Sample

* Add Sample & Fixed Open Telemetry

* Fixed Spelling from Olama to Ollama

* remove"opentelemetry-semantic-conventions-ai ~=0.4.13" since its handled in a different pr

* Added Tool Calling

* Finalizing test cases

* Adjust samples to be more reliable

* Update python/packages/ollama/agent_framework_ollama/_chat_client.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update python/packages/ollama/pyproject.toml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update python/packages/ollama/tests/test_ollama_chat_client.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update python/packages/ollama/agent_framework_ollama/_chat_client.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Improved Docstrings & Sample

* Update python/packages/ollama/agent_framework_ollama/_chat_client.py

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>

* Integrate PR Feedback
- Divided Streaming and Non-Streaming into independent Methods
- Catch Ollama Validation Error
- Add OTEL Provider Name
- Checked Ollama Messages
- Add Usage Statistics

* Revert setting, so it can be none

* Validate Message formatting between AF and Ollama

* Catch Ollama Error and raise a ServiceResponse Error

* Fix mypy error

* remove .vscode comma

* Add Reasoning support & adjust to new structure

* Add Ollama Multimodality and Reasoning

* Add test cases for reasoning

* Add Tests for Error Handling in Ollama Client

* Update python/samples/getting_started/multimodal_input/ollama_chat_multimodal.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Integrated Copilot Feedback

* Implement first PR Feedback

* Adjust Readme files for examples

* Adjust argument passing via additional chat options

* Implemented PR Feedback

* Removing Ollama Package from Core and moving samples

* Fix Link & Adding Samples to Main Sample Readme

* Fixing Links in Readme

* Moved Multimodal and Chat Example

* Fixed Link in ChatClient to Ollama

* Fix AgentFramework Links in Ollama Project

* Fix observability breaking change

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
This commit is contained in:
Nico Möller
2025-12-16 16:02:38 +01:00
committed by GitHub
Unverified
parent 1dbf3fd5cf
commit 2f06fe557a
18 changed files with 1593 additions and 285 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+13
View File
@@ -0,0 +1,13 @@
# Get Started with Microsoft Agent Framework Ollama
Please install this package as the extra for `agent-framework`:
```bash
pip install agent-framework-ollama --pre
```
and see the [README](https://github.com/microsoft/agent-framework/tree/main/python/README.md) for more information.
# Run samples with the Ollama Conector
You can find samples how to run the connector under the [Getting_started] (./getting_started/README.md) folder
@@ -0,0 +1,16 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
from ._chat_client import OllamaChatClient, OllamaSettings
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"OllamaChatClient",
"OllamaSettings",
"__version__",
]
@@ -0,0 +1,315 @@
# Copyright (c) Microsoft. All rights reserved.
import json
from collections.abc import (
AsyncIterable,
Callable,
Mapping,
MutableMapping,
MutableSequence,
Sequence,
)
from itertools import chain
from typing import Any, ClassVar
from agent_framework import (
AIFunction,
BaseChatClient,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Contents,
DataContent,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
TextReasoningContent,
ToolProtocol,
UsageDetails,
get_logger,
use_chat_middleware,
use_function_invocation,
)
from agent_framework._pydantic import AFBaseSettings
from agent_framework.exceptions import (
ServiceInitializationError,
ServiceInvalidRequestError,
ServiceResponseException,
)
from agent_framework.observability import use_instrumentation
from ollama import AsyncClient
# Rename imported types to avoid naming conflicts with Agent Framework types
from ollama._types import ChatResponse as OllamaChatResponse
from ollama._types import Message as OllamaMessage
from pydantic import ValidationError
class OllamaSettings(AFBaseSettings):
"""Ollama settings."""
env_prefix: ClassVar[str] = "OLLAMA_"
host: str | None = None
model_id: str | None = None
logger = get_logger("agent_framework.ollama")
@use_function_invocation
@use_instrumentation
@use_chat_middleware
class OllamaChatClient(BaseChatClient):
"""Ollama Chat completion class."""
OTEL_PROVIDER_NAME: ClassVar[str] = "ollama"
def __init__(
self,
*,
host: str | None = None,
client: AsyncClient | None = None,
model_id: str | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize an Ollama Chat client.
Keyword Args:
host: Ollama server URL, if none `http://localhost:11434` is used.
Can be set via the OLLAMA_HOST env variable.
client: An optional Ollama Client instance. If not provided, a new instance will be created.
model_id: The Ollama chat model ID to use. Can be set via the OLLAMA_MODEL_ID env variable.
env_file_path: An optional path to a dotenv (.env) file to load environment variables from.
env_file_encoding: The encoding to use when reading the dotenv (.env) file. Defaults to 'utf-8'.
**kwargs: Additional keyword arguments passed to BaseChatClient.
"""
try:
ollama_settings = OllamaSettings(
host=host,
model_id=model_id,
env_file_encoding=env_file_encoding,
env_file_path=env_file_path,
)
except ValidationError as ex:
raise ServiceInitializationError("Failed to create Ollama settings.", ex) from ex
if ollama_settings.model_id is None:
raise ServiceInitializationError(
"Ollama chat model ID must be provided via model_id or OLLAMA_MODEL_ID environment variable."
)
self.model_id = ollama_settings.model_id
self.client = client or AsyncClient(host=ollama_settings.host)
# Save Host URL for serialization with to_dict()
self.host = str(self.client._client.base_url)
super().__init__(**kwargs)
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> ChatResponse:
options_dict = self._prepare_options(messages, chat_options)
try:
response: OllamaChatResponse = await self.client.chat( # type: ignore[misc]
stream=False,
**options_dict,
**kwargs,
)
except Exception as ex:
raise ServiceResponseException(f"Ollama chat request failed : {ex}", ex) from ex
return self._ollama_response_to_agent_framework_response(response)
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
options_dict = self._prepare_options(messages, chat_options)
try:
response_object: AsyncIterable[OllamaChatResponse] = await self.client.chat( # type: ignore[misc]
stream=True,
**options_dict,
**kwargs,
)
except Exception as ex:
raise ServiceResponseException(f"Ollama streaming chat request failed : {ex}", ex) from ex
async for part in response_object:
yield self._ollama_streaming_response_to_agent_framework_response(part)
def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions) -> dict[str, Any]:
# Preprocess web search tool if it exists
options_dict = chat_options.to_dict(exclude={"instructions", "type"})
# Promote additional_properties to the top level of options_dict
additional_props = options_dict.pop("additional_properties", {})
options_dict.update(additional_props)
# Prepare Messages from Agent Framework format to Ollama format
if messages and "messages" not in options_dict:
options_dict["messages"] = self._prepare_chat_history_for_request(messages)
if "messages" not in options_dict:
raise ServiceInvalidRequestError("Messages are required for chat completions")
# Prepare Tools from Agent Framework format to Json Schema format
if chat_options.tools:
options_dict["tools"] = self._chat_to_tool_spec(chat_options.tools)
# Currently Ollama only supports auto tool choice
if chat_options.tool_choice == "required":
raise ServiceInvalidRequestError("Ollama does not support required tool choice.")
# Always auto: remove tool_choice since Ollama does not expose configuration to force or disable tools.
if "tool_choice" in options_dict:
del options_dict["tool_choice"]
# Rename model_id to model for Ollama API, if no model is provided use the one from client initialization
if "model_id" in options_dict:
options_dict["model"] = options_dict.pop("model_id")
if "model_id" not in options_dict:
options_dict["model"] = self.model_id
return options_dict
def _prepare_chat_history_for_request(self, messages: MutableSequence[ChatMessage]) -> list[OllamaMessage]:
ollama_messages = [self._agent_framework_message_to_ollama_message(msg) for msg in messages]
# Flatten the list of lists into a single list
return list(chain.from_iterable(ollama_messages))
def _agent_framework_message_to_ollama_message(self, message: ChatMessage) -> list[OllamaMessage]:
message_converters: dict[str, Callable[[ChatMessage], list[OllamaMessage]]] = {
Role.SYSTEM.value: self._format_system_message,
Role.USER.value: self._format_user_message,
Role.ASSISTANT.value: self._format_assistant_message,
Role.TOOL.value: self._format_tool_message,
}
return message_converters[message.role.value](message)
def _format_system_message(self, message: ChatMessage) -> list[OllamaMessage]:
return [OllamaMessage(role="system", content=message.text)]
def _format_user_message(self, message: ChatMessage) -> list[OllamaMessage]:
if not any(isinstance(c, (DataContent, TextContent)) for c in message.contents) and not message.text:
raise ServiceInvalidRequestError(
"Ollama connector currently only supports user messages with TextContent or DataContent."
)
if not any(isinstance(c, DataContent) for c in message.contents):
return [OllamaMessage(role="user", content=message.text)]
user_message = OllamaMessage(role="user", content=message.text)
data_contents = [c for c in message.contents if isinstance(c, DataContent)]
if data_contents:
if not any(c.has_top_level_media_type("image") for c in data_contents):
raise ServiceInvalidRequestError("Only image data content is supported for user messages in Ollama.")
# Ollama expects base64 strings without prefix
user_message["images"] = [c.uri.split(",")[1] for c in data_contents]
return [user_message]
def _format_assistant_message(self, message: ChatMessage) -> list[OllamaMessage]:
text_content = message.text
reasoning_contents = "".join(c.text for c in message.contents if isinstance(c, TextReasoningContent))
assistant_message = OllamaMessage(role="assistant", content=text_content, thinking=reasoning_contents)
tool_calls = [item for item in message.contents if isinstance(item, FunctionCallContent)]
if tool_calls:
assistant_message["tool_calls"] = [
{
"function": {
"call_id": tool_call.call_id,
"name": tool_call.name,
"arguments": tool_call.arguments
if isinstance(tool_call.arguments, Mapping)
else json.loads(tool_call.arguments or "{}"),
}
}
for tool_call in tool_calls
]
return [assistant_message]
def _format_tool_message(self, message: ChatMessage) -> list[OllamaMessage]:
# Ollama does not support multiple tool results in a single message, so we create a separate
return [
OllamaMessage(role="tool", content=str(item.result), tool_name=item.call_id)
for item in message.contents
if isinstance(item, FunctionResultContent)
]
def _ollama_response_to_agent_framework_content(self, response: OllamaChatResponse) -> list[Contents]:
contents: list[Contents] = []
if response.message.thinking:
contents.append(TextReasoningContent(text=response.message.thinking))
if response.message.content:
contents.append(TextContent(text=response.message.content))
if response.message.tool_calls:
tool_calls = self._parse_ollama_tool_calls(response.message.tool_calls)
contents.extend(tool_calls)
return contents
def _ollama_streaming_response_to_agent_framework_response(
self, response: OllamaChatResponse
) -> ChatResponseUpdate:
contents = self._ollama_response_to_agent_framework_content(response)
return ChatResponseUpdate(
contents=contents,
role=Role.ASSISTANT,
ai_model_id=response.model,
created_at=response.created_at,
)
def _ollama_response_to_agent_framework_response(self, response: OllamaChatResponse) -> ChatResponse:
contents = self._ollama_response_to_agent_framework_content(response)
return ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, contents=contents)],
model_id=response.model,
created_at=response.created_at,
usage_details=UsageDetails(
input_token_count=response.prompt_eval_count,
output_token_count=response.eval_count,
),
)
def _parse_ollama_tool_calls(self, tool_calls: Sequence[OllamaMessage.ToolCall]) -> list[Contents]:
resp: list[Contents] = []
for tool in tool_calls:
fcc = FunctionCallContent(
call_id=tool.function.name, # Use name of function as call ID since Ollama doesn't provide a call ID
name=tool.function.name,
arguments=tool.function.arguments if isinstance(tool.function.arguments, dict) else "",
raw_representation=tool.function,
)
resp.append(fcc)
return resp
def _chat_to_tool_spec(self, tools: list[ToolProtocol | MutableMapping[str, Any]]) -> list[dict[str, Any]]:
chat_tools: list[dict[str, Any]] = []
for tool in tools:
if isinstance(tool, ToolProtocol):
match tool:
case AIFunction():
chat_tools.append(tool.to_json_schema_spec())
case _:
raise ServiceInvalidRequestError(
"Unsupported tool type '"
f"{type(tool).__name__}"
"' for Ollama client. Supported tool types: AIFunction."
)
else:
chat_tools.append(tool if isinstance(tool, dict) else dict(tool))
return chat_tools
@@ -0,0 +1,38 @@
# Ollama Examples
This folder contains examples demonstrating how to use Ollama models with the Agent Framework.
## Prerequisites
1. **Install Ollama**: Download and install Ollama from [ollama.com](https://ollama.com/)
2. **Start Ollama**: Ensure Ollama is running on your local machine
3. **Pull a model**: Run `ollama pull mistral` (or any other model you prefer)
- For function calling examples, use models that support tool calling like `mistral` or `qwen2.5`
- For reasoning examples, use models that support reasoning like `qwen2.5:8b`
- For Multimodality you can use models like `gemma3:4b`
> **Note**: Not all models support all features. Function calling and reasoning capabilities depend on the specific model you're using.
## Examples
| File | Description |
|------|-------------|
| [`ollama_agent_basic.py`](ollama_agent_basic.py) | Demonstrates basic Ollama agent usage with the native Ollama Chat Client. Shows both streaming and non-streaming responses with tool calling capabilities. |
| [`ollama_agent_reasoning.py`](ollama_agent_reasoning.py) | Demonstrates Ollama agent with reasoning capabilities using the native Ollama Chat Client. Shows how to enable thinking/reasoning mode. |
| [`ollama_chat_client.py`](ollama_chat_client.py) | Ollama Chat Client with native Ollama Chat Client |
| [`ollama_chat_multimodal.py`](ollama_chat_multimodal.py) | Ollama Chat with multimodal native Ollama Chat Client |
## Configuration
The examples use environment variables for configuration. Set the appropriate variables based on which example you're running:
### For Native Ollama Examples (`ollama_agent_basic.py`, `ollama_agent_reasoning.py`)
Set the following environment variables:
- `OLLAMA_HOST`: The base URL for your Ollama server (optional, defaults to `http://localhost:11434`)
- Example: `export OLLAMA_HOST="http://localhost:11434"`
- `OLLAMA_CHAT_MODEL_ID`: The model name to use
- Example: `export OLLAMA_CHAT_MODEL_ID="qwen2.5:8b"`
- Must be a model you have pulled with Ollama
@@ -0,0 +1,68 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from datetime import datetime
from agent_framework_ollama import OllamaChatClient
"""
Ollama Agent Basic Example
This sample demonstrates implementing a Ollama agent with basic tool usage.
Ensure to install Ollama and have a model running locally before running the sample
Not all Models support function calling, to test function calling try llama3.2 or qwen3:4b
Set the model to use via the OLLAMA_CHAT_MODEL_ID environment variable or modify the code below.
https://ollama.com/
"""
def get_time(location: str) -> str:
"""Get the current time."""
return f"The current time in {location} is {datetime.now().strftime('%I:%M %p')}."
async def non_streaming_example() -> None:
"""Example of non-streaming response (get the complete result at once)."""
print("=== Non-streaming Response Example ===")
agent = OllamaChatClient().create_agent(
name="TimeAgent",
instructions="You are a helpful time agent answer in one sentence.",
tools=get_time,
)
query = "What time is it in Seattle? Use a tool call"
print(f"User: {query}")
result = await agent.run(query)
print(f"Result: {result}\n")
async def streaming_example() -> None:
"""Example of streaming response (get results as they are generated)."""
print("=== Streaming Response Example ===")
agent = OllamaChatClient().create_agent(
name="TimeAgent",
instructions="You are a helpful time agent answer in one sentence.",
tools=get_time,
)
query = "What time is it in San Francisco? Use a tool call"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
async for chunk in agent.run_stream(query):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
async def main() -> None:
print("=== Basic Ollama Chat Client Agent Example ===")
await non_streaming_example()
await streaming_example()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,46 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework import TextReasoningContent
from agent_framework_ollama import OllamaChatClient
"""
Ollama Agent Reasoning Example
This sample demonstrates implementing a Ollama agent with reasoning.
Ensure to install Ollama and have a model running locally before running the sample
Not all Models support reasoning, to test reasoning try qwen3:8b
Set the model to use via the OLLAMA_CHAT_MODEL_ID environment variable or modify the code below.
https://ollama.com/
"""
async def reasoning_example() -> None:
print("=== Response Reasoning Example ===")
agent = OllamaChatClient().create_agent(
name="TimeAgent",
instructions="You are a helpful agent answer in one sentence.",
additional_chat_options={"think": True}, # Enable Reasoning on agent level
)
query = "Hey what is 3+4? Can you explain how you got to that answer?"
print(f"User: {query}")
# Enable Reasoning on per request level
result = await agent.run(query)
reasoning = "".join(c.text for c in result.messages[-1].contents if isinstance(c, TextReasoningContent))
print(f"Reasoning: {reasoning}")
print(f"Answer: {result}\n")
async def main() -> None:
print("=== Basic Ollama Chat Client Agent Reasoning ===")
await reasoning_example()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,36 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from datetime import datetime
from agent_framework_ollama import OllamaChatClient
# Ensure to install Ollama and have a model running locally before running the sample
# Not all Models support function calling, to test function calling try llama3.2
# Set the model to use via the OLLAMA_CHAT_MODEL_ID environment variable or modify the code below.
# https://ollama.com/
def get_time():
"""Get the current time."""
return f"The current time is {datetime.now().strftime('%I:%M %p')}."
async def main() -> None:
client = OllamaChatClient()
message = "What time is it? Use a tool call"
stream = False
print(f"User: {message}")
if stream:
print("Assistant: ", end="")
async for chunk in client.get_streaming_response(message, tools=get_time):
if str(chunk):
print(str(chunk), end="")
print("")
else:
response = await client.get_response(message, tools=get_time)
print(f"Assistant: {response}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,54 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework import ChatMessage, DataContent, Role, TextContent
from agent_framework_ollama import OllamaChatClient
"""
Ollama Agent Multimodal Example
This sample demonstrates implementing a Ollama agent with multimodal input capabilities.
Ensure to install Ollama and have a model running locally before running the sample
Not all Models support multimodal input, to test multimodal input try gemma3:4b
Set the model to use via the OLLAMA_CHAT_MODEL_ID environment variable or modify the code below.
https://ollama.com/
"""
def create_sample_image() -> str:
"""Create a simple 1x1 pixel PNG image for testing."""
# This is a tiny red pixel in PNG format
png_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
return f"data:image/png;base64,{png_data}"
async def test_image() -> None:
"""Test image analysis with Ollama."""
client = OllamaChatClient()
image_uri = create_sample_image()
message = ChatMessage(
role=Role.USER,
contents=[
TextContent(text="What's in this image?"),
DataContent(uri=image_uri, media_type="image/png"),
],
)
response = await client.get_response(message)
print(f"Image Response: {response}")
async def main() -> None:
print("=== Testing Ollama Multimodal ===")
await test_image()
if __name__ == "__main__":
asyncio.run(main())
+95
View File
@@ -0,0 +1,95 @@
[project]
name = "agent-framework-ollama"
description = "Ollama integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "0.1.0b1"
license-files = ["LICENSE"]
urls.homepage = "https://learn.microsoft.com/en-us/agent-framework/"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Framework :: Pydantic :: 2",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core",
"ollama >= 0.5.3",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
[tool.ruff]
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W"]
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extend = "../../pyproject.toml"
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
disallow_any_unimported = true
[tool.bandit]
targets = ["agent_framework_ollama"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ollama"
test = "pytest --cov=agent_framework_ollama --cov-report=term-missing:skip-covered tests"
[tool.uv.build-backend]
module-name = "agent_framework_ollama"
module-root = ""
[build-system]
requires = ["uv_build>=0.8.2,<0.9.0"]
build-backend = "uv_build"
+47
View File
@@ -0,0 +1,47 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from agent_framework import ChatMessage
from pytest import fixture
# region: Connector Settings fixtures
@fixture
def exclude_list(request: Any) -> list[str]:
"""Fixture that returns a list of environment variables to exclude."""
return request.param if hasattr(request, "param") else []
@fixture
def override_env_param_dict(request: Any) -> dict[str, str]:
"""Fixture that returns a dict of environment variables to override."""
return request.param if hasattr(request, "param") else {}
# These two fixtures are used for multiple things, also non-connector tests
@fixture()
def ollama_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore
"""Fixture to set environment variables for OllamaSettings."""
if exclude_list is None:
exclude_list = []
if override_env_param_dict is None:
override_env_param_dict = {}
env_vars = {"OLLAMA_HOST": "http://localhost:12345", "OLLAMA_MODEL_ID": "test"}
env_vars.update(override_env_param_dict) # type: ignore
for key, value in env_vars.items():
if key in exclude_list:
monkeypatch.delenv(key, raising=False) # type: ignore
continue
monkeypatch.setenv(key, value) # type: ignore
return env_vars
@fixture
def chat_history() -> list[ChatMessage]:
return []
@@ -0,0 +1,511 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from collections.abc import AsyncIterable
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import (
BaseChatClient,
ChatMessage,
ChatResponseUpdate,
DataContent,
FunctionCallContent,
FunctionResultContent,
HostedWebSearchTool,
TextContent,
TextReasoningContent,
UriContent,
chat_middleware,
)
from agent_framework.exceptions import (
ServiceInitializationError,
ServiceInvalidRequestError,
ServiceResponseException,
)
from ollama import AsyncClient
from ollama._types import ChatResponse as OllamaChatResponse
from ollama._types import Message as OllamaMessage
from openai import AsyncStream
from agent_framework_ollama import OllamaChatClient
# region Service Setup
skip_if_azure_integration_tests_disabled = pytest.mark.skipif(
os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true"
or os.getenv("OLLAMA_MODEL_ID", "") in ("", "test-model"),
reason="No real Ollama chat model provided; skipping integration tests."
if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true"
else "Integration tests are disabled.",
)
@pytest.fixture
def mock_streaming_chat_completion_response() -> AsyncStream[OllamaChatResponse]:
response = OllamaChatResponse(
message=OllamaMessage(content="test", role="assistant"),
model="test",
)
stream = MagicMock(spec=AsyncStream)
stream.__aiter__.return_value = [response]
return stream
@pytest.fixture
def mock_streaming_chat_completion_response_reasoning() -> AsyncStream[OllamaChatResponse]:
response = OllamaChatResponse(
message=OllamaMessage(thinking="test", role="assistant"),
model="test",
)
stream = MagicMock(spec=AsyncStream)
stream.__aiter__.return_value = [response]
return stream
@pytest.fixture
def mock_chat_completion_response() -> OllamaChatResponse:
return OllamaChatResponse(
message=OllamaMessage(content="test", role="assistant"),
model="test",
eval_count=1,
prompt_eval_count=1,
created_at="2024-01-01T00:00:00Z",
)
@pytest.fixture
def mock_chat_completion_response_reasoning() -> OllamaChatResponse:
return OllamaChatResponse(
message=OllamaMessage(thinking="test", role="assistant"),
model="test",
eval_count=1,
prompt_eval_count=1,
created_at="2024-01-01T00:00:00Z",
)
@pytest.fixture
def mock_streaming_chat_completion_tool_call() -> AsyncStream[OllamaChatResponse]:
ollama_tool_call = OllamaChatResponse(
message=OllamaMessage(
content="",
role="assistant",
tool_calls=[{"function": {"name": "hello_world", "arguments": {"arg1": "value1"}}}],
),
model="test",
)
stream = MagicMock(spec=AsyncStream)
stream.__aiter__.return_value = [ollama_tool_call]
return stream
@pytest.fixture
def mock_chat_completion_tool_call() -> OllamaChatResponse:
return OllamaChatResponse(
message=OllamaMessage(
content="",
role="assistant",
tool_calls=[{"function": {"name": "hello_world", "arguments": {"arg1": "value1"}}}],
),
model="test",
created_at="2024-01-01T00:00:00Z",
)
def hello_world(arg1: str) -> str:
return "Hello World"
def test_init(ollama_unit_test_env: dict[str, str]) -> None:
# Test successful initialization
ollama_chat_client = OllamaChatClient()
assert ollama_chat_client.client is not None
assert isinstance(ollama_chat_client.client, AsyncClient)
assert ollama_chat_client.model_id == ollama_unit_test_env["OLLAMA_MODEL_ID"]
assert isinstance(ollama_chat_client, BaseChatClient)
def test_init_client(ollama_unit_test_env: dict[str, str]) -> None:
# Test successful initialization with provided client
test_client = MagicMock(spec=AsyncClient)
# Mock underlying HTTP client's base_url
test_client._client = MagicMock()
test_client._client.base_url = ollama_unit_test_env["OLLAMA_MODEL_ID"]
ollama_chat_client = OllamaChatClient(client=test_client)
assert ollama_chat_client.client is test_client
assert ollama_chat_client.model_id == ollama_unit_test_env["OLLAMA_MODEL_ID"]
assert isinstance(ollama_chat_client, BaseChatClient)
@pytest.mark.parametrize("exclude_list", [["OLLAMA_MODEL_ID"]], indirect=True)
def test_with_invalid_settings(ollama_unit_test_env: dict[str, str]) -> None:
with pytest.raises(ServiceInitializationError):
OllamaChatClient(
host="http://localhost:12345",
model_id=None,
env_file_path="test.env",
)
def test_serialize(ollama_unit_test_env: dict[str, str]) -> None:
settings = {
"host": ollama_unit_test_env["OLLAMA_HOST"],
"model_id": ollama_unit_test_env["OLLAMA_MODEL_ID"],
}
ollama_chat_client = OllamaChatClient.from_dict(settings)
serialized = ollama_chat_client.to_dict()
assert isinstance(serialized, dict)
assert serialized["host"] == ollama_unit_test_env["OLLAMA_HOST"]
assert serialized["model_id"] == ollama_unit_test_env["OLLAMA_MODEL_ID"]
def test_chat_middleware(ollama_unit_test_env: dict[str, str]) -> None:
@chat_middleware
async def sample_middleware(context, next):
await next(context)
ollama_chat_client = OllamaChatClient(middleware=[sample_middleware])
assert len(ollama_chat_client.middleware) == 1
assert ollama_chat_client.middleware[0] == sample_middleware
def test_additional_properties(ollama_unit_test_env: dict[str, str]) -> None:
additional_properties = {
"user_location": {
"country": "US",
"city": "Seattle",
}
}
ollama_chat_client = OllamaChatClient(
additional_properties=additional_properties,
)
assert ollama_chat_client.additional_properties == additional_properties
# region CMC
async def test_empty_messages() -> None:
ollama_chat_client = OllamaChatClient(
host="http://localhost:12345",
model_id="test-model",
)
with pytest.raises(ServiceInvalidRequestError):
await ollama_chat_client.get_response(messages=[])
async def test_function_choice_required_argument() -> None:
ollama_chat_client = OllamaChatClient(
host="http://localhost:12345",
model_id="test-model",
)
with pytest.raises(ServiceInvalidRequestError):
await ollama_chat_client.get_response(
messages=[ChatMessage(text="hello world", role="user")],
tool_choice="required",
tools=[hello_world],
)
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
mock_chat_completion_response: AsyncStream[OllamaChatResponse],
) -> None:
mock_chat.return_value = mock_chat_completion_response
chat_history.append(ChatMessage(text="hello world", role="system"))
chat_history.append(ChatMessage(text="hello world", role="user"))
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(messages=chat_history)
assert result.text == "test"
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_reasoning(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
mock_chat_completion_response_reasoning: AsyncStream[OllamaChatResponse],
) -> None:
mock_chat.return_value = mock_chat_completion_response_reasoning
chat_history.append(ChatMessage(text="hello world", role="user"))
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(messages=chat_history)
reasoning = "".join(c.text for c in result.messages.pop().contents if isinstance(c, TextReasoningContent))
assert reasoning == "test"
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_chat_failure(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
) -> None:
# Simulate a failure in the Ollama client
mock_chat.side_effect = Exception("Connection error")
chat_history.append(ChatMessage(text="hello world", role="user"))
ollama_client = OllamaChatClient()
with pytest.raises(ServiceResponseException) as exc_info:
await ollama_client.get_response(messages=chat_history)
assert "Ollama chat request failed" in str(exc_info.value)
assert "Connection error" in str(exc_info.value)
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_streaming(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
mock_streaming_chat_completion_response: AsyncStream[OllamaChatResponse],
) -> None:
mock_chat.return_value = mock_streaming_chat_completion_response
chat_history.append(ChatMessage(text="hello world", role="system"))
chat_history.append(ChatMessage(text="hello world", role="user"))
ollama_client = OllamaChatClient()
result = ollama_client.get_streaming_response(messages=chat_history)
async for chunk in result:
assert chunk.text == "test"
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_streaming_reasoning(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
mock_streaming_chat_completion_response_reasoning: AsyncStream[OllamaChatResponse],
) -> None:
mock_chat.return_value = mock_streaming_chat_completion_response_reasoning
chat_history.append(ChatMessage(text="hello world", role="user"))
ollama_client = OllamaChatClient()
result = ollama_client.get_streaming_response(messages=chat_history)
async for chunk in result:
reasoning = "".join(c.text for c in chunk.contents if isinstance(c, TextReasoningContent))
assert reasoning == "test"
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_streaming_chat_failure(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
) -> None:
# Simulate a failure in the Ollama client for streaming
mock_chat.side_effect = Exception("Streaming connection error")
chat_history.append(ChatMessage(text="hello world", role="user"))
ollama_client = OllamaChatClient()
with pytest.raises(ServiceResponseException) as exc_info:
async for _ in ollama_client.get_streaming_response(messages=chat_history):
pass
assert "Ollama streaming chat request failed" in str(exc_info.value)
assert "Streaming connection error" in str(exc_info.value)
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_streaming_with_tool_call(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
mock_streaming_chat_completion_response: AsyncStream[OllamaChatResponse],
mock_streaming_chat_completion_tool_call: AsyncStream[OllamaChatResponse],
) -> None:
mock_chat.side_effect = [
mock_streaming_chat_completion_tool_call,
mock_streaming_chat_completion_response,
]
chat_history.append(ChatMessage(text="hello world", role="user"))
ollama_client = OllamaChatClient()
result = ollama_client.get_streaming_response(messages=chat_history, tools=[hello_world])
chunks: list[ChatResponseUpdate] = []
async for chunk in result:
chunks.append(chunk)
# Check parsed Toolcalls
assert isinstance(chunks[0].contents[0], FunctionCallContent)
tool_call = chunks[0].contents[0]
assert tool_call.name == "hello_world"
assert tool_call.arguments == {"arg1": "value1"}
assert isinstance(chunks[1].contents[0], FunctionResultContent)
tool_result = chunks[1].contents[0]
assert tool_result.result == "Hello World"
assert isinstance(chunks[2].contents[0], TextContent)
text_result = chunks[2].contents[0]
assert text_result.text == "test"
async def test_cmc_with_hosted_tool_call(
ollama_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
) -> None:
with pytest.raises(ServiceInvalidRequestError):
additional_properties = {
"user_location": {
"country": "US",
"city": "Seattle",
}
}
chat_history.append(ChatMessage(text="hello world", role="user"))
ollama_client = OllamaChatClient()
await ollama_client.get_response(
messages=chat_history,
tools=[HostedWebSearchTool(additional_properties=additional_properties)],
)
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_with_data_content_type(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
mock_chat_completion_response: OllamaChatResponse,
) -> None:
mock_chat.return_value = mock_chat_completion_response
chat_history.append(
ChatMessage(
contents=[DataContent(uri="data:image/png;base64,xyz", media_type="image/png")],
role="user",
)
)
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(messages=chat_history)
assert result.text == "test"
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_with_invalid_data_content_media_type(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
mock_streaming_chat_completion_response: AsyncStream[OllamaChatResponse],
) -> None:
with pytest.raises(ServiceInvalidRequestError):
mock_chat.return_value = mock_streaming_chat_completion_response
# Remote Uris are not supported by Ollama client
chat_history.append(
ChatMessage(
contents=[DataContent(uri="data:audio/mp3;base64,xyz", media_type="audio/mp3")],
role="user",
)
)
ollama_client = OllamaChatClient()
ollama_client.client.chat = AsyncMock(return_value=mock_streaming_chat_completion_response)
await ollama_client.get_response(messages=chat_history)
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_with_invalid_content_type(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
mock_chat_completion_response: AsyncStream[OllamaChatResponse],
) -> None:
with pytest.raises(ServiceInvalidRequestError):
mock_chat.return_value = mock_chat_completion_response
# Remote Uris are not supported by Ollama client
chat_history.append(
ChatMessage(
contents=[UriContent(uri="http://example.com/image.png", media_type="image/png")],
role="user",
)
)
ollama_client = OllamaChatClient()
await ollama_client.get_response(messages=chat_history)
@skip_if_azure_integration_tests_disabled
async def test_cmc_integration_with_tool_call(
chat_history: list[ChatMessage],
) -> None:
chat_history.append(ChatMessage(text="Call the hello world function and repeat what it says", role="user"))
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(messages=chat_history, tools=[hello_world])
assert "hello" in result.text.lower() and "world" in result.text.lower()
assert isinstance(result.messages[-2].contents[0], FunctionResultContent)
tool_result = result.messages[-2].contents[0]
assert tool_result.result == "Hello World"
@skip_if_azure_integration_tests_disabled
async def test_cmc_integration_with_chat_completion(
chat_history: list[ChatMessage],
) -> None:
chat_history.append(ChatMessage(text="Say Hello World", role="user"))
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(messages=chat_history)
assert "hello" in result.text.lower()
@skip_if_azure_integration_tests_disabled
async def test_cmc_streaming_integration_with_tool_call(
chat_history: list[ChatMessage],
) -> None:
chat_history.append(ChatMessage(text="Call the hello world function and repeat what it says", role="user"))
ollama_client = OllamaChatClient()
result: AsyncIterable[ChatResponseUpdate] = ollama_client.get_streaming_response(
messages=chat_history, tools=[hello_world]
)
chunks: list[ChatResponseUpdate] = []
async for chunk in result:
chunks.append(chunk)
for c in chunks:
if len(c.contents) > 0:
if isinstance(c.contents[0], FunctionResultContent):
tool_result = c.contents[0]
assert tool_result.result == "Hello World"
if isinstance(c.contents[0], FunctionCallContent):
tool_call = c.contents[0]
assert tool_call.name == "hello_world"
@skip_if_azure_integration_tests_disabled
async def test_cmc_streaming_integration_with_chat_completion(
chat_history: list[ChatMessage],
) -> None:
chat_history.append(ChatMessage(text="Say Hello World", role="user"))
ollama_client = OllamaChatClient()
result: AsyncIterable[ChatResponseUpdate] = ollama_client.get_streaming_response(messages=chat_history)
full_text = ""
async for chunk in result:
full_text += chunk.text
assert "hello" in full_text.lower() and "world" in full_text.lower()