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
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:
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user