chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Ollama Package (agent-framework-ollama)
Integration with Ollama for local LLM inference.
## Main Classes
- **`OllamaChatClient`** - Chat client for Ollama models
- **`OllamaChatOptions`** - Options TypedDict for Ollama-specific parameters
- **`OllamaSettings`** - Pydantic settings for Ollama configuration
## Usage
```python
from agent_framework.ollama import OllamaChatClient
client = OllamaChatClient(model="llama3.2")
response = await client.get_response("Hello")
```
## Import Path
```python
from agent_framework.ollama import OllamaChatClient
# or directly:
from agent_framework_ollama import OllamaChatClient
```
+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 in the [Ollama provider samples](../../samples/02-agents/providers/ollama).
@@ -0,0 +1,21 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
from ._chat_client import OllamaChatClient, OllamaChatOptions, OllamaSettings
from ._embedding_client import OllamaEmbeddingClient, OllamaEmbeddingOptions, OllamaEmbeddingSettings
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"OllamaChatClient",
"OllamaChatOptions",
"OllamaEmbeddingClient",
"OllamaEmbeddingOptions",
"OllamaEmbeddingSettings",
"OllamaSettings",
"__version__",
]
@@ -0,0 +1,630 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import json
import logging
import sys
from collections.abc import (
AsyncIterable,
Awaitable,
Callable,
Mapping,
Sequence,
)
from itertools import chain
from typing import Any, ClassVar, Generic, TypedDict
from agent_framework import (
BaseChatClient,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Content,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
FunctionTool,
Message,
ResponseStream,
UsageDetails,
)
from agent_framework._settings import load_settings
from agent_framework.exceptions import (
ChatClientException,
ChatClientInvalidRequestException,
)
from agent_framework.observability import ChatTelemetryLayer
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 BaseModel
if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
else:
from typing_extensions import TypeVar # pragma: no cover
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
if sys.version_info >= (3, 11):
from typing import TypedDict # pragma: no cover
else:
from typing_extensions import TypedDict # pragma: no cover
__all__ = ["OllamaChatClient", "OllamaChatOptions"]
ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None)
# region Ollama Chat Options TypedDict
class OllamaChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False):
"""Ollama-specific chat options dict.
Extends base ChatOptions with Ollama-specific parameters.
Ollama passes model parameters through the `options` field.
See: https://github.com/ollama/ollama/blob/main/docs/api.md
Keys:
# Inherited from ChatOptions (mapped to Ollama options):
model: The model name, translates to ``model`` in Ollama API.
temperature: Sampling temperature, translates to ``options.temperature``.
top_p: Nucleus sampling, translates to ``options.top_p``.
max_tokens: Maximum tokens to generate, translates to ``options.num_predict``.
stop: Stop sequences, translates to ``options.stop``.
seed: Random seed for reproducibility, translates to ``options.seed``.
frequency_penalty: Frequency penalty, translates to ``options.frequency_penalty``.
presence_penalty: Presence penalty, translates to ``options.presence_penalty``.
tools: List of function tools.
response_format: Output format, translates to ``format``.
Use 'json' for JSON mode, a JSON schema dict, or a Pydantic model class
(converted to its JSON schema) for structured output.
# Options not supported in Ollama:
tool_choice: Ollama only supports auto tool choice.
allow_multiple_tool_calls: Not configurable.
user: Not supported.
store: Not supported.
logit_bias: Not supported.
metadata: Not supported.
# Ollama model-level options (placed in `options` dict):
# See: https://github.com/ollama/ollama/blob/main/docs/modelfile.mdx#valid-parameters-and-values
num_predict: Maximum number of tokens to predict (alternative to max_tokens).
top_k: Top-k sampling: limits tokens to k most likely. Higher = more diverse.
min_p: Minimum probability threshold for token selection.
typical_p: Locally typical sampling parameter (0.0-1.0).
repeat_penalty: Penalty for repeating tokens. Higher = less repetition.
repeat_last_n: Number of tokens to consider for repeat penalty.
penalize_newline: Whether to penalize newline characters.
num_ctx: Context window size (number of tokens).
num_batch: Batch size for prompt processing.
num_keep: Number of tokens to keep from initial prompt.
num_gpu: Number of layers to offload to GPU.
main_gpu: Main GPU for computation.
use_mmap: Whether to use memory-mapped files.
num_thread: Number of threads for CPU computation.
numa: Enable NUMA optimization.
# Ollama-specific top-level options:
keep_alive: How long to keep model loaded (default: '5m').
think: Whether thinking models should think before responding.
Examples:
.. code-block:: python
from agent_framework_ollama import OllamaChatOptions
# Basic usage - standard options automatically mapped
options: OllamaChatOptions = {
"temperature": 0.7,
"max_tokens": 1000,
"seed": 42,
}
# With Ollama-specific model options
options: OllamaChatOptions = {
"top_k": 40,
"num_ctx": 4096,
"keep_alive": "10m",
}
# With JSON output format
options: OllamaChatOptions = {
"response_format": "json",
}
# With structured output (JSON schema)
options: OllamaChatOptions = {
"response_format": {
"type": "object",
"properties": {"answer": {"type": "string"}},
"required": ["answer"],
},
}
"""
# Ollama model-level options (will be placed in `options` dict)
num_predict: int
"""Maximum number of tokens to predict (equivalent to max_tokens)."""
top_k: int
"""Top-k sampling: limits tokens to k most likely. Higher = more diverse."""
min_p: float
"""Minimum probability threshold for token selection."""
typical_p: float
"""Locally typical sampling parameter (0.0-1.0)."""
repeat_penalty: float
"""Penalty for repeating tokens. Higher = less repetition."""
repeat_last_n: int
"""Number of tokens to consider for repeat penalty."""
penalize_newline: bool
"""Whether to penalize newline characters."""
num_ctx: int
"""Context window size (number of tokens)."""
num_batch: int
"""Batch size for prompt processing."""
num_keep: int
"""Number of tokens to keep from initial prompt."""
num_gpu: int
"""Number of layers to offload to GPU."""
main_gpu: int
"""Main GPU for computation."""
use_mmap: bool
"""Whether to use memory-mapped files."""
num_thread: int
"""Number of threads for CPU computation."""
numa: bool
"""Enable NUMA optimization."""
# Ollama-specific top-level options
keep_alive: str | int
"""How long to keep the model loaded in memory after request.
Can be duration string (e.g., '5m', '1h') or seconds as int.
Set to 0 to unload immediately after request."""
think: bool
"""For thinking models: whether the model should think before responding."""
# ChatOptions fields not supported in Ollama
tool_choice: None # type: ignore[misc]
"""Not supported. Ollama only supports auto tool choice."""
allow_multiple_tool_calls: None # type: ignore[misc]
"""Not supported. Not configurable in Ollama."""
user: None # type: ignore[misc]
"""Not supported in Ollama."""
store: None # type: ignore[misc]
"""Not supported in Ollama."""
logit_bias: None # type: ignore[misc]
"""Not supported in Ollama."""
metadata: None # type: ignore[misc]
"""Not supported in Ollama."""
OLLAMA_OPTION_TRANSLATIONS: dict[str, str] = {
"response_format": "format",
}
"""Maps ChatOptions keys to Ollama API parameter names."""
# Keys that should be placed in the nested `options` dict for the Ollama API
OLLAMA_MODEL_OPTIONS: set[str] = {
# From ChatOptions (mapped to options.*)
"temperature",
"top_p",
"max_tokens", # -> num_predict
"stop",
"seed",
"frequency_penalty",
"presence_penalty",
# Ollama-specific model options
"num_predict",
"top_k",
"min_p",
"typical_p",
"repeat_penalty",
"repeat_last_n",
"penalize_newline",
"num_ctx",
"num_batch",
"num_keep",
"num_gpu",
"main_gpu",
"use_mmap",
"num_thread",
"numa",
}
# Translations for options that go into the nested `options` dict
OLLAMA_MODEL_OPTION_TRANSLATIONS: dict[str, str] = {
"max_tokens": "num_predict",
}
"""Maps ChatOptions keys to Ollama model option parameter names."""
OllamaChatOptionsT = TypeVar("OllamaChatOptionsT", bound=TypedDict, default="OllamaChatOptions", covariant=True) # type: ignore[valid-type]
# endregion
class OllamaSettings(TypedDict, total=False):
"""Ollama settings."""
host: str | None
model: str | None
logger = logging.getLogger("agent_framework.ollama")
class OllamaChatClient(
FunctionInvocationLayer[OllamaChatOptionsT],
ChatMiddlewareLayer[OllamaChatOptionsT],
ChatTelemetryLayer[OllamaChatOptionsT],
BaseChatClient[OllamaChatOptionsT],
):
"""Ollama Chat completion class with middleware, telemetry, and function invocation support."""
OTEL_PROVIDER_NAME: ClassVar[str] = "ollama"
def __init__(
self,
*,
host: str | None = None,
client: AsyncClient | None = None,
model: str | None = None,
additional_properties: dict[str, Any] | None = None,
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> 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: The Ollama chat model to use. Can be set via the OLLAMA_MODEL env variable.
additional_properties: Additional properties stored on the client instance.
middleware: Optional middleware to apply to the client.
function_invocation_configuration: Optional function invocation configuration override.
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'.
"""
ollama_settings = load_settings(
OllamaSettings,
env_prefix="OLLAMA_",
required_fields=["model"],
host=host,
model=model,
env_file_encoding=env_file_encoding,
env_file_path=env_file_path,
)
self.model = ollama_settings["model"] # type: ignore[assignment, reportTypedDictNotRequiredAccess]
# we can just pass in None for the host, the default is set by the Ollama package.
self.client = client or AsyncClient(host=ollama_settings.get("host"))
# Save Host URL for serialization with to_dict()
self.host = str(self.client._client.base_url) # type: ignore[reportUnknownMemberType,reportPrivateUsage,reportUnknownArgumentType]
super().__init__(
additional_properties=additional_properties,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
)
self.middleware = list(self.chat_middleware)
@override
def _inner_get_response(
self,
*,
messages: Sequence[Message],
options: Mapping[str, Any],
stream: bool = False,
**kwargs: Any,
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
if stream:
# Streaming mode
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
validated_options = await self._validate_options(options)
options_dict = self._prepare_options(messages, validated_options)
try:
response_object: AsyncIterable[OllamaChatResponse] = await self.client.chat( # type: ignore[misc]
stream=True,
**options_dict,
**kwargs,
)
except Exception as ex:
raise ChatClientException(f"Ollama streaming chat request failed : {ex}", ex) from ex
async for part in response_object:
yield self._parse_streaming_response_from_ollama(part)
return self._build_response_stream(_stream(), response_format=options.get("response_format"))
# Non-streaming mode
async def _get_response() -> ChatResponse:
validated_options = await self._validate_options(options)
options_dict = self._prepare_options(messages, validated_options)
try:
response: OllamaChatResponse = await self.client.chat( # type: ignore[misc]
stream=False,
**options_dict,
**kwargs,
)
except Exception as ex:
raise ChatClientException(f"Ollama chat request failed : {ex}", ex) from ex
return self._parse_response_from_ollama(
response,
response_format=validated_options.get("response_format"),
)
return _get_response()
def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, Any]) -> dict[str, Any]:
# Handle instructions by prepending to messages as system message
instructions = options.get("instructions")
if instructions:
from agent_framework._types import prepend_instructions_to_messages
messages = prepend_instructions_to_messages(list(messages), instructions, role="system")
# Keys to exclude from processing
exclude_keys = {"instructions", "tool_choice"}
# Build run_options and model_options separately
run_options: dict[str, Any] = {}
model_options: dict[str, Any] = {}
for key, value in options.items():
if key in exclude_keys or value is None:
continue
if key in OLLAMA_MODEL_OPTIONS:
# Apply model option translations (e.g., max_tokens -> num_predict)
translated_key = OLLAMA_MODEL_OPTION_TRANSLATIONS.get(key, key)
model_options[translated_key] = value
else:
# Apply top-level translations (e.g., response_format -> format)
translated_key = OLLAMA_OPTION_TRANSLATIONS.get(key, key)
if translated_key == "format" and isinstance(value, type) and issubclass(value, BaseModel):
# Ollama's `format` accepts '', 'json', or a JSON-schema dict, not a
# Pydantic model class. Convert the class to its JSON schema, matching
# OpenAIChatClient/FoundryChatClient and Ollama's documented usage
# (https://ollama.com/blog/structured-outputs). The original class is
# kept in `options` for typed parsing of the response.
value = value.model_json_schema()
run_options[translated_key] = value
# Add model options to run_options if any
if model_options:
run_options["options"] = model_options
# messages
if messages and "messages" not in run_options:
run_options["messages"] = self._prepare_messages_for_ollama(messages)
if "messages" not in run_options:
raise ChatClientInvalidRequestException("Messages are required for chat completions")
# model
if not run_options.get("model"):
if not self.model:
raise ValueError("model must be a non-empty string")
run_options["model"] = self.model
# tools
tools = options.get("tools")
if tools is not None and (prepared_tools := self._prepare_tools_for_ollama(tools)):
run_options["tools"] = prepared_tools
return run_options
def _prepare_messages_for_ollama(self, messages: Sequence[Message]) -> list[OllamaMessage]:
ollama_messages = [self._prepare_message_for_ollama(msg) for msg in messages]
# Flatten the list of lists into a single list
return list(chain.from_iterable(ollama_messages))
def _prepare_message_for_ollama(self, message: Message) -> list[OllamaMessage]:
message_converters: dict[str, Callable[[Message], list[OllamaMessage]]] = {
"system": self._format_system_message,
"user": self._format_user_message,
"assistant": self._format_assistant_message,
"tool": self._format_tool_message,
}
return message_converters[message.role](message)
def _format_system_message(self, message: Message) -> list[OllamaMessage]:
return [OllamaMessage(role="system", content=message.text)]
def _format_user_message(self, message: Message) -> list[OllamaMessage]:
if not any(c.type in {"text", "data"} for c in message.contents) and not message.text:
raise ChatClientInvalidRequestException(
"Ollama connector currently only supports user messages with TextContent or DataContent."
)
if not any(c.type == "data" 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 c.type == "data"]
if data_contents:
if not any(c.has_top_level_media_type("image") for c in data_contents):
raise ChatClientInvalidRequestException(
"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 if c.uri]
return [user_message]
def _format_assistant_message(self, message: Message) -> list[OllamaMessage]:
text_content = message.text
# Ollama shouldn't have encrypted reasoning, so we just process text.
reasoning_contents = "".join((c.text or "") for c in message.contents if c.type == "text_reasoning")
assistant_message = OllamaMessage(role="assistant", content=text_content, thinking=reasoning_contents)
tool_calls = [item for item in message.contents if item.type == "function_call"]
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: Message) -> list[OllamaMessage]:
# Ollama does not support multiple tool results in a single message, so we create a separate
messages: list[OllamaMessage] = []
for item in message.contents:
if item.type == "function_result":
if item.items:
text_parts = [c.text or "" for c in item.items if c.type == "text"]
rich_items = [c for c in item.items if c.type in ("data", "uri")]
if rich_items:
logger.warning(
"Ollama does not support rich content (images, audio) in tool results. "
"Rich content items will be omitted."
)
tool_text = "\n".join(text_parts) if text_parts else ""
else:
tool_text = str(item.result) if item.result is not None else ""
messages.append(OllamaMessage(role="tool", content=tool_text, tool_name=item.call_id))
return messages
def _parse_contents_from_ollama(self, response: OllamaChatResponse) -> list[Content]:
contents: list[Content] = []
if response.message.thinking:
contents.append(Content.from_text_reasoning(text=response.message.thinking))
if response.message.content:
contents.append(Content.from_text(text=response.message.content))
if response.message.tool_calls:
tool_calls = self._parse_tool_calls_from_ollama(response.message.tool_calls)
contents.extend(tool_calls)
return contents
def _parse_streaming_response_from_ollama(self, response: OllamaChatResponse) -> ChatResponseUpdate:
contents = self._parse_contents_from_ollama(response)
finish_reason = None
if response.done:
usage_details = UsageDetails(
**{
key: value
for key, value in {
"input_token_count": response.prompt_eval_count,
"output_token_count": response.eval_count,
"total_token_count": response.prompt_eval_count + response.eval_count
if isinstance(response.prompt_eval_count, int) and isinstance(response.eval_count, int)
else None,
}.items()
if isinstance(value, int)
}
)
if usage_details:
contents.append(Content.from_usage(usage_details, raw_representation=response))
finish_reason = response.done_reason if response.done_reason in ("stop", "length") else None
return ChatResponseUpdate(
contents=contents,
role="assistant",
model=response.model,
created_at=response.created_at,
finish_reason=finish_reason,
)
def _parse_response_from_ollama(
self,
response: OllamaChatResponse,
*,
response_format: Any | None = None,
) -> ChatResponse:
contents = self._parse_contents_from_ollama(response)
usage_details = UsageDetails(
**{
key: value
for key, value in {
"input_token_count": response.prompt_eval_count,
"output_token_count": response.eval_count,
"total_token_count": response.prompt_eval_count + response.eval_count
if isinstance(response.prompt_eval_count, int) and isinstance(response.eval_count, int)
else None,
}.items()
if isinstance(value, int)
}
)
finish_reason = response.done_reason if response.done_reason in ("stop", "length") else None
return ChatResponse(
messages=[Message(role="assistant", contents=contents)],
model=response.model,
created_at=response.created_at,
finish_reason=finish_reason,
usage_details=usage_details or None,
response_format=response_format,
)
def _parse_tool_calls_from_ollama(self, tool_calls: Sequence[OllamaMessage.ToolCall]) -> list[Content]:
resp: list[Content] = []
for tool in tool_calls:
fcc = Content.from_function_call(
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 _prepare_tools_for_ollama(self, tools: list[Any]) -> list[Any]:
"""Prepare tools for the Ollama API.
Converts FunctionTool to JSON schema format. All other tools pass through unchanged.
Args:
tools: List of tools to prepare.
Returns:
List of tool definitions ready for the Ollama API.
"""
chat_tools: list[Any] = []
for tool in tools:
if isinstance(tool, FunctionTool):
chat_tools.append(tool.to_json_schema_spec())
else:
# Pass through all other tools unchanged
chat_tools.append(tool)
return chat_tools
@@ -0,0 +1,230 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import logging
import sys
from collections.abc import Sequence
from typing import Any, ClassVar, Generic, TypedDict, cast
from agent_framework import (
BaseEmbeddingClient,
Embedding,
EmbeddingGenerationOptions,
GeneratedEmbeddings,
UsageDetails,
load_settings,
)
from agent_framework.observability import EmbeddingTelemetryLayer
from ollama import AsyncClient
if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
else:
from typing_extensions import TypeVar # pragma: no cover
logger = logging.getLogger("agent_framework.ollama")
class OllamaEmbeddingOptions(EmbeddingGenerationOptions, total=False):
"""Ollama-specific embedding options.
Extends EmbeddingGenerationOptions with Ollama-specific fields.
Examples:
.. code-block:: python
from agent_framework_ollama import OllamaEmbeddingOptions
options: OllamaEmbeddingOptions = {
"model": "nomic-embed-text",
"dimensions": 768,
"truncate": True,
}
"""
truncate: bool
"""Whether to truncate input text that exceeds the model's context length.
When True, input that is too long will be silently truncated.
When False (default), the request will fail if input exceeds the context length.
"""
keep_alive: float | str
"""How long to keep the model loaded in memory (e.g. ``"5m"``, ``300``)."""
OllamaEmbeddingOptionsT = TypeVar(
"OllamaEmbeddingOptionsT",
bound=TypedDict, # type: ignore[valid-type]
default="OllamaEmbeddingOptions",
covariant=True,
)
class OllamaEmbeddingSettings(TypedDict, total=False):
"""Ollama embedding settings."""
host: str | None
embedding_model: str | None
class RawOllamaEmbeddingClient(
BaseEmbeddingClient[str, list[float], OllamaEmbeddingOptionsT],
Generic[OllamaEmbeddingOptionsT],
):
"""Raw Ollama embedding client without telemetry.
Keyword Args:
model: The Ollama embedding model (e.g. "nomic-embed-text").
Can also be set via environment variable OLLAMA_EMBEDDING_MODEL.
host: Ollama server URL. Defaults to http://localhost:11434.
Can also be set via environment variable OLLAMA_HOST.
client: Optional pre-configured Ollama AsyncClient.
env_file_path: Path to .env file for settings.
env_file_encoding: Encoding for .env file.
"""
def __init__(
self,
*,
model: str | None = None,
host: str | None = None,
client: AsyncClient | None = None,
additional_properties: dict[str, Any] | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize a raw Ollama embedding client."""
ollama_settings = load_settings(
OllamaEmbeddingSettings,
env_prefix="OLLAMA_",
required_fields=["embedding_model"],
host=host,
embedding_model=model,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
self.model = ollama_settings["embedding_model"] # type: ignore[assignment,reportTypedDictNotRequiredAccess]
self.client = client or AsyncClient(host=ollama_settings.get("host"))
self.host = str(self.client._client.base_url) # type: ignore[reportUnknownMemberType,reportPrivateUsage,reportUnknownArgumentType]
super().__init__(additional_properties=additional_properties)
def service_url(self) -> str:
"""Get the URL of the service."""
return self.host
async def get_embeddings(
self,
values: Sequence[str],
*,
options: OllamaEmbeddingOptionsT | None = None,
) -> GeneratedEmbeddings[list[float], OllamaEmbeddingOptionsT]:
"""Call the Ollama embed API.
Args:
values: The text values to generate embeddings for.
options: Optional embedding generation options.
Returns:
Generated embeddings with usage metadata.
Raises:
ValueError: If model is not provided or values is empty.
"""
if not values:
return GeneratedEmbeddings([], options=options)
opts: dict[str, Any] = options or {} # type: ignore
model = opts.get("model") or self.model
if not model:
raise ValueError("model is required")
kwargs: dict[str, Any] = {"model": model, "input": list(values)}
if (truncate := opts.get("truncate")) is not None:
kwargs["truncate"] = truncate
if keep_alive := opts.get("keep_alive"):
kwargs["keep_alive"] = keep_alive
if dimensions := opts.get("dimensions"):
kwargs["dimensions"] = dimensions
response = await self.client.embed(**kwargs)
embeddings = [
Embedding(
vector=list(emb),
dimensions=len(emb),
model=response.get("model") or model,
)
for emb in response.get("embeddings", [])
]
usage_dict: UsageDetails | None = None
prompt_eval_count = response.get("prompt_eval_count")
if prompt_eval_count is not None:
usage_dict = {"input_token_count": prompt_eval_count}
return GeneratedEmbeddings(embeddings, options=cast(OllamaEmbeddingOptionsT, opts), usage=usage_dict)
class OllamaEmbeddingClient(
EmbeddingTelemetryLayer[str, list[float], OllamaEmbeddingOptionsT],
RawOllamaEmbeddingClient[OllamaEmbeddingOptionsT],
Generic[OllamaEmbeddingOptionsT],
):
"""Ollama embedding client with telemetry support.
Keyword Args:
model: The Ollama embedding model (e.g. "nomic-embed-text").
Can also be set via environment variable OLLAMA_EMBEDDING_MODEL.
host: Ollama server URL. Defaults to http://localhost:11434.
Can also be set via environment variable OLLAMA_HOST.
client: Optional pre-configured Ollama AsyncClient.
env_file_path: Path to .env file for settings.
env_file_encoding: Encoding for .env file.
Examples:
.. code-block:: python
from agent_framework_ollama import OllamaEmbeddingClient
# Using environment variables
# Set OLLAMA_EMBEDDING_MODEL=nomic-embed-text
client = OllamaEmbeddingClient()
# Or passing parameters directly
client = OllamaEmbeddingClient(
model="nomic-embed-text",
host="http://localhost:11434",
)
# Generate embeddings
result = await client.get_embeddings(["Hello, world!"])
print(result[0].vector)
"""
OTEL_PROVIDER_NAME: ClassVar[str] = "ollama"
def __init__(
self,
*,
model: str | None = None,
host: str | None = None,
client: AsyncClient | None = None,
otel_provider_name: str | None = None,
additional_properties: dict[str, Any] | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize an Ollama embedding client."""
super().__init__(
model=model,
host=host,
client=client,
additional_properties=additional_properties,
otel_provider_name=otel_provider_name,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
+105
View File
@@ -0,0 +1,105 @@
[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 = "1.0.0b260709"
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>=1.11.0,<2",
"ollama>=0.5.3,<0.5.4",
]
[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 = []
markers = [
"integration: marks tests as integration tests that require external services",
]
timeout = 120
[tool.ruff]
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W"]
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_ollama"]
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]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ollama"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --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.12.0"]
build-backend = "uv_build"
@@ -0,0 +1,150 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import Embedding, GeneratedEmbeddings
from agent_framework_ollama import OllamaEmbeddingClient, OllamaEmbeddingOptions
# region: Unit Tests
def test_ollama_embedding_construction(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test construction with explicit parameters."""
monkeypatch.setenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
client = OllamaEmbeddingClient()
assert client.model == "nomic-embed-text"
def test_ollama_embedding_construction_with_params() -> None:
"""Test construction with explicit parameters."""
with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
client = OllamaEmbeddingClient(
model="nomic-embed-text",
host="http://localhost:11434",
)
assert client.model == "nomic-embed-text"
def test_ollama_embedding_construction_missing_model_raises(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that missing model raises an error."""
monkeypatch.delenv("OLLAMA_EMBEDDING_MODEL", raising=False)
monkeypatch.delenv("OLLAMA_MODEL", raising=False)
from agent_framework.exceptions import SettingNotFoundError
with pytest.raises(SettingNotFoundError):
OllamaEmbeddingClient()
async def test_ollama_embedding_get_embeddings() -> None:
"""Test generating embeddings via the Ollama API."""
mock_response = {
"model": "nomic-embed-text",
"embeddings": [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]],
"prompt_eval_count": 10,
}
with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls:
mock_client = MagicMock()
mock_client.embed = AsyncMock(return_value=mock_response)
mock_client_cls.return_value = mock_client
client = OllamaEmbeddingClient(model="nomic-embed-text")
result = await client.get_embeddings(["hello", "world"])
assert isinstance(result, GeneratedEmbeddings)
assert len(result) == 2
assert result[0].vector == [0.1, 0.2, 0.3]
assert result[1].vector == [0.4, 0.5, 0.6]
assert result[0].model == "nomic-embed-text"
assert result.usage == {"input_token_count": 10}
mock_client.embed.assert_called_once_with(
model="nomic-embed-text",
input=["hello", "world"],
)
async def test_ollama_embedding_get_embeddings_empty_input() -> None:
"""Test generating embeddings with empty input."""
with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
client = OllamaEmbeddingClient(model="nomic-embed-text")
result = await client.get_embeddings([])
assert isinstance(result, GeneratedEmbeddings)
assert len(result) == 0
mock_client.embed.assert_not_called()
async def test_ollama_embedding_get_embeddings_with_options() -> None:
"""Test generating embeddings with custom options."""
mock_response = {
"model": "nomic-embed-text",
"embeddings": [[0.1, 0.2, 0.3]],
}
with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls:
mock_client = MagicMock()
mock_client.embed = AsyncMock(return_value=mock_response)
mock_client_cls.return_value = mock_client
client = OllamaEmbeddingClient(model="nomic-embed-text")
options: OllamaEmbeddingOptions = {
"truncate": True,
"dimensions": 512,
}
result = await client.get_embeddings(["hello"], options=options)
assert len(result) == 1
mock_client.embed.assert_called_once_with(
model="nomic-embed-text",
input=["hello"],
truncate=True,
dimensions=512,
)
async def test_ollama_embedding_get_embeddings_no_model_raises() -> None:
"""Test that missing model at call time raises ValueError."""
with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
client = OllamaEmbeddingClient(model="nomic-embed-text")
client.model = None # type: ignore[assignment]
with pytest.raises(ValueError, match="model is required"):
await client.get_embeddings(["hello"])
# region: Integration Tests
skip_if_ollama_embedding_integration_tests_disabled = pytest.mark.skipif(
os.getenv("OLLAMA_EMBEDDING_MODEL", "") in ("", "test-model"),
reason="No real Ollama embedding model provided; skipping integration tests.",
)
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_ollama_embedding_integration_tests_disabled
async def test_ollama_embedding_integration() -> None:
"""Integration test for Ollama embedding client."""
client = OllamaEmbeddingClient()
result = await client.get_embeddings(["Hello, world!", "How are you?"])
assert isinstance(result, GeneratedEmbeddings)
assert len(result) == 2
for embedding in result:
assert isinstance(embedding, Embedding)
assert isinstance(embedding.vector, list)
assert len(embedding.vector) > 0
assert all(isinstance(v, float) for v in embedding.vector)
@@ -0,0 +1,752 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from collections.abc import AsyncIterable
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import (
Agent,
BaseChatClient,
ChatResponseUpdate,
Content,
Message,
chat_middleware,
tool,
)
from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException, SettingNotFoundError
from ollama import AsyncClient
from ollama._types import ChatResponse as OllamaChatResponse
from ollama._types import Message as OllamaMessage
from openai import AsyncStream
from pydantic import BaseModel
from pytest import fixture
from agent_framework_ollama import OllamaChatClient
# region Service Setup
skip_if_azure_integration_tests_disabled = pytest.mark.skipif(
os.getenv("OLLAMA_MODEL", "") in ("", "test-model"),
reason="No real Ollama chat model provided; skipping integration tests.",
)
# 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": "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[Message]:
return []
def test_agent_accepts_ollama_chat_client(ollama_unit_test_env: dict[str, str]) -> None:
client = OllamaChatClient()
agent = Agent(client=client, instructions="test agent")
assert agent.client is client
@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
@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
@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",
)
@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",
)
@fixture
def mock_streaming_chat_completion_tool_call() -> AsyncStream[OllamaChatResponse]:
ollama_tool_call = OllamaChatResponse(
message=OllamaMessage(
content="",
role="assistant",
tool_calls=cast(Any, [{"function": {"name": "hello_world", "arguments": {"arg1": "value1"}}}]),
),
model="test",
)
stream = MagicMock(spec=AsyncStream)
stream.__aiter__.return_value = [ollama_tool_call]
return stream
@fixture
def mock_chat_completion_tool_call() -> OllamaChatResponse:
return OllamaChatResponse(
message=OllamaMessage(
content="",
role="assistant",
tool_calls=cast(Any, [{"function": {"name": "hello_world", "arguments": {"arg1": "value1"}}}]),
),
model="test",
created_at="2024-01-01T00:00:00Z",
)
@tool(approval_mode="never_require")
def hello_world(arg1: str) -> str:
return "Hello World"
@tool(approval_mode="never_require")
def greet() -> str:
"""Say hello to the world. No-arg tool for integration tests to avoid argument parsing flakiness."""
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 == ollama_unit_test_env["OLLAMA_MODEL"]
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"]
ollama_chat_client = OllamaChatClient(client=test_client)
assert ollama_chat_client.client is test_client
assert ollama_chat_client.model == ollama_unit_test_env["OLLAMA_MODEL"]
assert isinstance(ollama_chat_client, BaseChatClient)
@pytest.mark.parametrize("exclude_list", [["OLLAMA_MODEL"]], indirect=True)
def test_with_invalid_settings(ollama_unit_test_env: dict[str, str]) -> None:
with pytest.raises(SettingNotFoundError, match="Required setting 'model'"):
OllamaChatClient(
host="http://localhost:12345",
model=None,
)
def test_serialize(ollama_unit_test_env: dict[str, str]) -> None:
settings = {
"host": ollama_unit_test_env["OLLAMA_HOST"],
"model": ollama_unit_test_env["OLLAMA_MODEL"],
}
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"] == ollama_unit_test_env["OLLAMA_MODEL"]
def test_chat_middleware(ollama_unit_test_env: dict[str, str]) -> None:
@chat_middleware
async def sample_middleware(context, call_next):
await call_next()
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="test-model",
)
with pytest.raises(ChatClientInvalidRequestException):
await ollama_chat_client.get_response(messages=[])
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[Message],
mock_chat_completion_response: AsyncStream[OllamaChatResponse],
) -> None:
mock_chat.return_value = mock_chat_completion_response
chat_history.append(Message(contents=["hello world"], role="system"))
chat_history.append(Message(contents=["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_maps_done_reason_to_finish_reason(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[Message],
) -> None:
mock_chat.return_value = OllamaChatResponse(
message=OllamaMessage(content="test", role="assistant"),
model="test",
eval_count=2,
prompt_eval_count=3,
done_reason="length",
)
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(messages=chat_history)
assert result.finish_reason == "length"
assert result.usage_details == {
"input_token_count": 3,
"output_token_count": 2,
"total_token_count": 5,
}
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_leaves_unknown_done_reason_unset(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[Message],
) -> None:
mock_chat.return_value = OllamaChatResponse(
message=OllamaMessage(content="test", role="assistant"),
model="test",
done_reason="load",
)
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(messages=chat_history)
assert result.finish_reason is None
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_omits_usage_when_token_counts_are_missing(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[Message],
) -> None:
mock_chat.return_value = OllamaChatResponse(
message=OllamaMessage(content="test", role="assistant"),
model="test",
done_reason="stop",
)
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(messages=chat_history)
assert result.finish_reason == "stop"
assert not result.usage_details
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_response_format_dict(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[Message],
) -> None:
mock_chat.return_value = OllamaChatResponse(
message=OllamaMessage(content='{"answer": "test"}', role="assistant"),
model="test",
eval_count=1,
prompt_eval_count=1,
created_at="2024-01-01T00:00:00Z",
)
chat_history.append(Message(contents=["hello world"], role="system"))
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(
messages=chat_history,
options={"response_format": {"type": "object", "properties": {"answer": {"type": "string"}}}},
)
assert result.value is not None
assert isinstance(result.value, dict)
assert result.value["answer"] == "test"
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_response_format_pydantic_model(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[Message],
) -> None:
"""A Pydantic model class is converted to a JSON schema dict for Ollama's ``format``.
Ollama only accepts ``''``, ``'json'``, or a JSON-schema dict for ``format``; a model
class would fail request construction. The class is still kept for typed parsing of
the response, matching OpenAI/Foundry behavior.
"""
class Answer(BaseModel):
answer: str
mock_chat.return_value = OllamaChatResponse(
message=OllamaMessage(content='{"answer": "test"}', role="assistant"),
model="test",
eval_count=1,
prompt_eval_count=1,
created_at="2024-01-01T00:00:00Z",
)
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(messages=chat_history, options={"response_format": Answer})
# Outgoing ``format`` must be the JSON schema dict, not the model class.
assert mock_chat.await_args is not None
assert mock_chat.await_args.kwargs["format"] == Answer.model_json_schema()
# Typed parsing still works because the original model class is preserved.
assert isinstance(result.value, Answer)
assert result.value.answer == "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[Message],
mock_chat_completion_response_reasoning: AsyncStream[OllamaChatResponse],
) -> None:
mock_chat.return_value = mock_chat_completion_response_reasoning
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(messages=chat_history)
reasoning = "".join(cast("str", c.text) for c in result.messages.pop().contents if c.type == "text_reasoning")
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[Message],
) -> None:
# Simulate a failure in the Ollama client
mock_chat.side_effect = Exception("Connection error")
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
with pytest.raises(ChatClientException) 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[Message],
mock_streaming_chat_completion_response: AsyncStream[OllamaChatResponse],
) -> None:
mock_chat.return_value = mock_streaming_chat_completion_response
chat_history.append(Message(contents=["hello world"], role="system"))
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
result = ollama_client.get_response(messages=chat_history, stream=True)
async for chunk in result:
assert chunk.text == "test"
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_streaming_maps_done_reason_and_usage(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[Message],
) -> None:
response = OllamaChatResponse(
message=OllamaMessage(content="test", role="assistant"),
model="test",
done=True,
done_reason="stop",
eval_count=4,
prompt_eval_count=6,
created_at="2024-01-01T00:00:00Z",
)
stream = MagicMock(spec=AsyncStream)
stream.__aiter__.return_value = [response]
mock_chat.return_value = stream
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
result = ollama_client.get_response(messages=chat_history, stream=True)
async for _ in result:
pass
final_response = await result.get_final_response()
assert final_response.text == "test"
assert final_response.finish_reason == "stop"
assert final_response.usage_details == {
"input_token_count": 6,
"output_token_count": 4,
"total_token_count": 10,
}
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_streaming_ignores_done_reason_and_usage_before_final_chunk(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[Message],
) -> None:
response = OllamaChatResponse(
message=OllamaMessage(content="test", role="assistant"),
model="test",
done=False,
done_reason="stop",
eval_count=4,
prompt_eval_count=6,
created_at="2024-01-01T00:00:00Z",
)
stream = MagicMock(spec=AsyncStream)
stream.__aiter__.return_value = [response]
mock_chat.return_value = stream
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
result = ollama_client.get_response(messages=chat_history, stream=True)
async for _ in result:
pass
final_response = await result.get_final_response()
assert final_response.text == "test"
assert final_response.finish_reason is None
assert final_response.usage_details is None
@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[Message],
mock_streaming_chat_completion_response_reasoning: AsyncStream[OllamaChatResponse],
) -> None:
mock_chat.return_value = mock_streaming_chat_completion_response_reasoning
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
result = ollama_client.get_response(messages=chat_history, stream=True)
async for chunk in result:
reasoning = "".join(cast("str", c.text) for c in chunk.contents if c.type == "text_reasoning")
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[Message],
) -> None:
# Simulate a failure in the Ollama client for streaming
mock_chat.side_effect = Exception("Streaming connection error")
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
with pytest.raises(ChatClientException) as exc_info:
async for _ in ollama_client.get_response(messages=chat_history, stream=True):
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[Message],
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(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
result = ollama_client.get_response(messages=chat_history, stream=True, options={"tools": [hello_world]})
chunks: list[ChatResponseUpdate] = []
async for chunk in result:
chunks.append(chunk)
# Check parsed Toolcalls
assert chunks[0].contents[0].type == "function_call"
tool_call = chunks[0].contents[0]
assert tool_call.name == "hello_world"
assert tool_call.arguments == {"arg1": "value1"}
assert chunks[1].contents[0].type == "function_result"
tool_result = chunks[1].contents[0]
assert tool_result.result == "Hello World"
assert chunks[2].contents[0].type == "text"
text_result = chunks[2].contents[0]
assert text_result.text == "test"
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_with_dict_tool_passthrough(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[Message],
mock_chat_completion_response: OllamaChatResponse,
) -> None:
"""Test that dict-based tools are passed through to Ollama."""
mock_chat.return_value = mock_chat_completion_response
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
await ollama_client.get_response(
messages=chat_history,
options={
"tools": [{"type": "function", "function": {"name": "custom_tool", "parameters": {}}}],
},
)
# Verify the tool was passed through to the Ollama client
mock_chat.assert_called_once()
call_kwargs = mock_chat.call_args.kwargs
assert "tools" in call_kwargs
assert call_kwargs["tools"] == [{"type": "function", "function": {"name": "custom_tool", "parameters": {}}}]
@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[Message],
mock_chat_completion_response: OllamaChatResponse,
) -> None:
mock_chat.return_value = mock_chat_completion_response
chat_history.append(
Message(
contents=[Content.from_uri(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[Message],
mock_streaming_chat_completion_response: AsyncStream[OllamaChatResponse],
) -> None:
with pytest.raises(ChatClientInvalidRequestException):
mock_chat.return_value = mock_streaming_chat_completion_response
# Remote Uris are not supported by Ollama client
chat_history.append(
Message(
contents=[Content.from_uri(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) # type: ignore[method-assign]
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[Message],
mock_chat_completion_response: AsyncStream[OllamaChatResponse],
) -> None:
with pytest.raises(ChatClientInvalidRequestException):
mock_chat.return_value = mock_chat_completion_response
# Remote Uris are not supported by Ollama client
chat_history.append(
Message(
contents=[Content.from_uri(uri="http://example.com/image.png", media_type="image/png")],
role="user",
)
)
ollama_client = OllamaChatClient()
await ollama_client.get_response(messages=chat_history)
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_azure_integration_tests_disabled
async def test_cmc_integration_with_tool_call(
chat_history: list[Message],
) -> None:
chat_history.append(Message(contents=["Call the greet function and repeat what it says"], role="user"))
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(messages=chat_history, options={"tools": [greet]})
assert "hello" in result.text.lower() and "world" in result.text.lower()
assert result.messages[-2].contents[0].type == "function_result"
tool_result = result.messages[-2].contents[0]
assert tool_result.result == "Hello World"
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_azure_integration_tests_disabled
async def test_cmc_integration_with_chat_completion(
chat_history: list[Message],
) -> None:
chat_history.append(Message(contents=["Say Hello World"], role="user"))
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(messages=chat_history)
assert "hello" in result.text.lower()
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_azure_integration_tests_disabled
async def test_cmc_streaming_integration_with_tool_call(
chat_history: list[Message],
) -> None:
chat_history.append(Message(contents=["Call the greet function and repeat what it says"], role="user"))
ollama_client = OllamaChatClient()
result: AsyncIterable[ChatResponseUpdate] = ollama_client.get_response(
messages=chat_history, stream=True, options={"tools": [greet]}
)
chunks: list[ChatResponseUpdate] = []
async for chunk in result:
chunks.append(chunk)
for c in chunks:
if len(c.contents) > 0:
if c.contents[0].type == "function_result":
tool_result = c.contents[0]
assert tool_result.result == "Hello World"
if c.contents[0].type == "function_call":
tool_call = c.contents[0]
assert tool_call.name == "greet"
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_azure_integration_tests_disabled
async def test_cmc_streaming_integration_with_chat_completion(
chat_history: list[Message],
) -> None:
chat_history.append(Message(contents=["Say Hello World"], role="user"))
ollama_client = OllamaChatClient()
result: AsyncIterable[ChatResponseUpdate] = ollama_client.get_response(messages=chat_history, stream=True)
full_text = ""
async for chunk in result:
full_text += chunk.text
assert "hello" in full_text.lower() and "world" in full_text.lower()