fix(ollama): use litellm's ollama_chat provider for native tool-calling (#2494)

The Ollama requester declared litellm_provider: ollama, which routes
every request through litellm's legacy /api/generate-based
OllamaConfig. That config's get_supported_openai_params() does not
include "tools"/"tool_choice" at all, so an Ollama-hosted model in a
local-agent pipeline could never receive a structured tool definition
or return a structured tool_calls response - it could only try to
express a tool call as free text (typically inside its own <think>
reasoning), which LangBot then has no way to execute.

litellm's "ollama_chat" provider targets Ollama's modern /api/chat
endpoint instead, which correctly forwards tools/tool_choice and
correctly surfaces the model's native message.tool_calls field.
Verified against a real local Ollama 0.33.2 instance with the exact
system prompt, RAG-augmented user message, and tool set a live
pipeline sends.

Two follow-on fixes needed because the Ollama requester definition is
shared by LLM and text-embedding models:

- get_reasoning_capabilities: match family in ('ollama', 'ollama_chat')
  so the reasoning-level UI still works for this provider.
- scan_models: retry {base_url}/v1/models on a 404 from {base_url}/models,
  since Ollama's base_url is a bare host (must not include /v1 - that
  would break OllamaChatConfig.get_complete_url, which appends /api/chat
  to it directly), unlike most other OpenAI-compatible providers whose
  base_url already ends in /v1.
- invoke_embedding: litellm's embedding routing has no "ollama_chat"
  case, only "ollama". Build the embedding model name with an explicit
  custom_llm_provider="ollama" override when the requester is configured
  for ollama_chat, so embedding models (e.g. bge-m3) keep working.

Co-authored-by: zx90316 <zx90316@users.noreply.github.com>
This commit is contained in:
CWT
2026-09-01 22:36:35 +08:00
committed by GitHub
parent 5ca30133a3
commit 601c6975ea
2 changed files with 16 additions and 3 deletions
@@ -573,7 +573,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
levels = ['provider_default', 'disabled', 'enabled'] levels = ['provider_default', 'disabled', 'enabled']
elif family == 'doubao': elif family == 'doubao':
levels = ['provider_default', 'disabled', 'low', 'medium', 'high'] levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
elif family == 'ollama': elif family in ('ollama', 'ollama_chat'):
levels = ['provider_default'] levels = ['provider_default']
levels.append('disabled') levels.append('disabled')
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name: if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
@@ -1345,7 +1345,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
extra_args: dict[str, typing.Any] = {}, extra_args: dict[str, typing.Any] = {},
) -> tuple[list[list[float]], dict]: ) -> tuple[list[list[float]], dict]:
"""Invoke embedding and return vectors with usage info.""" """Invoke embedding and return vectors with usage info."""
model_name = self._build_litellm_model_name(model.model_entity.name) # litellm's embedding routing has no "ollama_chat" branch (that provider
# exists only for /api/chat completions) — embeddings still go through
# the plain "ollama" provider. Requesters configured for ollama_chat
# (to get native tool-calling on the chat path) must fall back to
# "ollama" here specifically, or embedding calls raise "Unmapped LLM
# provider for this endpoint".
embedding_provider = 'ollama' if self._get_custom_llm_provider() == 'ollama_chat' else None
model_name = self._build_litellm_model_name(model.model_entity.name, embedding_provider)
api_key = model.provider.token_mgr.get_token() api_key = model.provider.token_mgr.get_token()
args = { args = {
@@ -1541,6 +1548,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
event_hooks=httpclient.httpx_response_limit_hooks(), event_hooks=httpclient.httpx_response_limit_hooks(),
) as client: ) as client:
response = await client.get(models_url, headers=headers) response = await client.get(models_url, headers=headers)
if response.status_code == 404 and not base_url.rstrip('/').endswith('/v1'):
# Some OpenAI-compatible servers (notably a bare Ollama host,
# e.g. http://host:11434) expose the model list under /v1/models
# rather than /models. Providers whose configured base_url
# already ends in /v1 keep their original (working) URL.
response = await client.get(f'{base_url}/v1/models', headers=headers)
response.raise_for_status() response.raise_for_status()
payload = await httpclient.parse_json_response(response) payload = await httpclient.parse_json_response(response)
@@ -7,7 +7,7 @@ metadata:
zh_Hans: Ollama zh_Hans: Ollama
icon: ollama.svg icon: ollama.svg
spec: spec:
litellm_provider: ollama litellm_provider: ollama_chat
config: config:
- name: base_url - name: base_url
label: label: