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