Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions python/packages/ollama/agent_framework_ollama/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
Message,
ResponseStream,
UsageDetails,
validate_tool_mode,
)
from agent_framework._settings import load_settings
from agent_framework._telemetry import mark_feature_used
Expand Down Expand Up @@ -97,7 +98,8 @@ class OllamaChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], to
(converted to its JSON schema) for structured output.

# Options not supported in Ollama:
tool_choice: Ollama only supports auto tool choice.
tool_choice: Only ``auto`` and ``none`` are supported (``none`` omits the tools).
``required`` and ``allowed_tools`` raise ``ChatClientInvalidRequestException``.
allow_multiple_tool_calls: Not configurable.
user: Not supported.
store: Not supported.
Expand Down Expand Up @@ -216,9 +218,6 @@ class OllamaChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], to
"""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."""

Expand Down Expand Up @@ -414,7 +413,7 @@ def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, An
messages = prepend_instructions_to_messages(list(messages), instructions, role="system")

# Keys to exclude from processing
exclude_keys = {"instructions", "tool_choice"}
exclude_keys = {"instructions", "tool_choice", "tools"}

# Build run_options and model_options separately
run_options: dict[str, Any] = {}
Expand Down Expand Up @@ -460,8 +459,21 @@ def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, An
run_options["model"] = self.model

# tools
# Ollama has no tool_choice parameter. Only "auto" (send the tools) and "none" (don't send them)
# can be honored, so anything else is rejected instead of silently behaving like "auto".
tool_mode = validate_tool_mode(options.get("tool_choice"))
Comment thread
ankit2235 marked this conversation as resolved.
if tool_mode is not None:
if tool_mode.get("mode") == "required":
raise ChatClientInvalidRequestException(
"Ollama does not support tool_choice mode 'required'. Use 'auto' or 'none'."
)
Comment thread
ankit2235 marked this conversation as resolved.
if "allowed_tools" in tool_mode:
raise ChatClientInvalidRequestException(
"Ollama does not support tool_choice 'allowed_tools'. Pass only the tools you want to allow."
)
tools = options.get("tools")
if tools is not None and (prepared_tools := self._prepare_tools_for_ollama(tools)):
tools_disabled = tool_mode is not None and tool_mode.get("mode") == "none"
if tools is not None and not tools_disabled and (prepared_tools := self._prepare_tools_for_ollama(tools)):
run_options["tools"] = prepared_tools

return run_options
Expand Down
81 changes: 80 additions & 1 deletion python/packages/ollama/tests/test_ollama_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@
chat_middleware,
tool,
)
from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException, SettingNotFoundError
from agent_framework.exceptions import (
ChatClientException,
ChatClientInvalidRequestException,
ContentError,
SettingNotFoundError,
)
from ollama import AsyncClient
from ollama._types import ChatResponse as OllamaChatResponse
from ollama._types import Message as OllamaMessage
Expand Down Expand Up @@ -910,6 +915,80 @@ def test_prepare_options_single_stop_string_becomes_list(ollama_unit_test_env: d
assert request["options"]["stop"] == ["END"]


@pytest.mark.parametrize("tool_choice", ["none", {"mode": "none"}])
def test_prepare_options_tool_choice_none_omits_tools(ollama_unit_test_env: dict[str, str], tool_choice: Any) -> None:
Comment thread
ankit2235 marked this conversation as resolved.
"""Ollama has no tool_choice parameter, so "none" is honored by not offering the tools."""
client = OllamaChatClient()
messages = [Message(role="user", contents=[Content.from_text(text="hello")])]

request = client._prepare_options(messages, {"tools": [hello_world], "tool_choice": tool_choice})

assert "tools" not in request
assert "tool_choice" not in request


@pytest.mark.parametrize("tool_choice", [None, "auto", {"mode": "auto"}])
def test_prepare_options_auto_tool_choice_keeps_tools(ollama_unit_test_env: dict[str, str], tool_choice: Any) -> None:
"""No tool_choice or "auto" sends the tools to Ollama."""
client = OllamaChatClient()
messages = [Message(role="user", contents=[Content.from_text(text="hello")])]

request = client._prepare_options(messages, {"tools": [hello_world], "tool_choice": tool_choice})

assert request["tools"] == [hello_world.to_json_schema_spec()]
assert "tool_choice" not in request


@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_function_invocation_limit_final_request_omits_tools(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[Message],
mock_chat_completion_tool_call: OllamaChatResponse,
mock_chat_completion_response: OllamaChatResponse,
) -> None:
"""After the function invocation limit, the final request must not offer tools to Ollama."""
mock_chat.side_effect = [mock_chat_completion_tool_call, mock_chat_completion_response]
chat_history.append(Message(contents=["hello world"], role="user"))

ollama_client = OllamaChatClient()
ollama_client.function_invocation_configuration["max_iterations"] = 1
result = await ollama_client.get_response(messages=chat_history, options={"tools": [hello_world]})

assert mock_chat.call_count == 2
assert "tools" in mock_chat.call_args_list[0].kwargs
assert "tools" not in mock_chat.call_args_list[1].kwargs
assert result.text == "test"


def test_prepare_options_invalid_tool_choice_raises(ollama_unit_test_env: dict[str, str]) -> None:
"""An invalid tool_choice is rejected instead of being silently ignored."""
client = OllamaChatClient()
messages = [Message(role="user", contents=[Content.from_text(text="hello")])]

with pytest.raises(ContentError):
client._prepare_options(messages, {"tools": [hello_world], "tool_choice": "bogus"})


@pytest.mark.parametrize(
"tool_choice",
[
"required",
{"mode": "required"},
{"mode": "required", "required_function_name": "hello_world"},
{"mode": "auto", "allowed_tools": ["hello_world"]},
{"mode": "required", "allowed_tools": ["hello_world"]},
],
)
def test_prepare_options_unsupported_tool_choice_raises(ollama_unit_test_env: dict[str, str], tool_choice: Any) -> None:
"""Ollama can't enforce "required" or "allowed_tools", so they raise instead of acting like "auto"."""
client = OllamaChatClient()
messages = [Message(role="user", contents=[Content.from_text(text="hello")])]

with pytest.raises(ChatClientInvalidRequestException):
client._prepare_options(messages, {"tools": [hello_world], "tool_choice": tool_choice})


async def test_cmc_streaming_closes_sdk_stream_when_consumer_stops_early(ollama_unit_test_env: dict[str, str]) -> None:
"""Stopping early must close the Ollama SDK stream instead of leaving it to garbage collection."""
closed = False
Expand Down
Loading