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
81 changes: 40 additions & 41 deletions examples/web-search-crawl.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,56 +9,47 @@

from rich import print
Comment thread
npardal marked this conversation as resolved.

from ollama import WebCrawlResponse, WebSearchResponse, chat, web_crawl, web_search
from ollama import WebFetchResponse, WebSearchResponse, chat, web_fetch, web_search


def format_tool_results(results: Union[WebSearchResponse, WebCrawlResponse]):
def format_tool_results(
results: Union[WebSearchResponse, WebFetchResponse],
user_search: str,
):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of two optionals, just have query and make it mandatory to call the function

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's actually just remove these - the model should have context from the attached message - we can just focus on the result

output = []
if isinstance(results, WebSearchResponse):
if not results.success:
error_msg = ', '.join(results.errors) if results.errors else 'Unknown error'
return f'Web search failed: {error_msg}'

output = []
for query, search_results in results.results.items():
output.append(f'Search results for "{query}":')
for i, result in enumerate(search_results, 1):
output.append(f'{i}. {result.title}')
output.append(f' URL: {result.url}')
output.append(f' Content: {result.content}')
output.append('')

output.append(f'Search results for "{user_search}":')
for result in results.results:
output.append(f'{result.title}' if result.title else f'{result.content}')
output.append(f' URL: {result.url}')
output.append(f' Content: {result.content}')
output.append('')
return '\n'.join(output).rstrip()

elif isinstance(results, WebCrawlResponse):
if not results.success:
error_msg = ', '.join(results.errors) if results.errors else 'Unknown error'
return f'Web crawl failed: {error_msg}'

output = []
for url, crawl_results in results.results.items():
output.append(f'Crawl results for "{url}":')
for i, result in enumerate(crawl_results, 1):
output.append(f'{i}. {result.title}')
output.append(f' URL: {result.url}')
output.append(f' Content: {result.content}')
if result.links:
output.append(f' Links: {", ".join(result.links)}')
output.append('')

elif isinstance(results, WebFetchResponse):
output.append(f'Fetch results for "{user_search}":')
output.extend(
[
f'Title: {results.title}',
f'URL: {user_search}' if user_search else '',
f'Content: {results.content}',
]
)
if results.links:
output.append(f'Links: {", ".join(results.links)}')
output.append('')
return '\n'.join(output).rstrip()


# Set OLLAMA_API_KEY in the environment variable or use the headers parameter to set the authorization header
# client = Client(headers={'Authorization': 'Bearer <OLLAMA_API_KEY>'})
# client = Client(headers={'Authorization': f"Bearer {os.getenv('OLLAMA_API_KEY')}"} if api_key else None)
available_tools = {'web_search': web_search, 'web_fetch': web_fetch}

available_tools = {'web_search': web_search, 'web_crawl': web_crawl}

query = "ollama's new engine"
query = "what is ollama's new engine"
print('Query: ', query)

messages = [{'role': 'user', 'content': query}]
while True:
response = chat(model='qwen3', messages=messages, tools=[web_search, web_crawl], think=True)
response = chat(model='qwen3', messages=messages, tools=[web_search, web_fetch], think=True)
if response.message.thinking:
print('Thinking: ')
print(response.message.thinking + '\n\n')
Expand All @@ -72,12 +63,20 @@ def format_tool_results(results: Union[WebSearchResponse, WebCrawlResponse]):
for tool_call in response.message.tool_calls:
function_to_call = available_tools.get(tool_call.function.name)
if function_to_call:
result: WebSearchResponse | WebCrawlResponse = function_to_call(**tool_call.function.arguments)
print('Result from tool call name: ', tool_call.function.name, 'with arguments: ', tool_call.function.arguments)
print('Result: ', format_tool_results(result)[:200])
args = tool_call.function.arguments
result: Union[WebSearchResponse, WebFetchResponse] = function_to_call(**args)
print('Result from tool call name:', tool_call.function.name, 'with arguments:')
print(args)
print()

user_search = args.get('query', '') or args.get('url', '')
formatted_tool_results = format_tool_results(result, user_search=user_search)

print(formatted_tool_results[:300])
print()

# caps the result at ~2000 tokens
messages.append({'role': 'tool', 'content': format_tool_results(result)[: 2000 * 4], 'tool_name': tool_call.function.name})
messages.append({'role': 'tool', 'content': formatted_tool_results[: 2000 * 4], 'tool_name': tool_call.function.name})
else:
print(f'Tool {tool_call.function.name} not found')
messages.append({'role': 'tool', 'content': f'Tool {tool_call.function.name} not found', 'tool_name': tool_call.function.name})
Expand Down
6 changes: 3 additions & 3 deletions ollama/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
ShowResponse,
StatusResponse,
Tool,
WebCrawlResponse,
WebFetchResponse,
WebSearchResponse,
)

Expand All @@ -37,7 +37,7 @@
'ShowResponse',
'StatusResponse',
'Tool',
'WebCrawlResponse',
'WebFetchResponse',
'WebSearchResponse',
]

Expand All @@ -56,4 +56,4 @@
show = _client.show
ps = _client.ps
web_search = _client.web_search
web_crawl = _client.web_crawl
web_fetch = _client.web_fetch
54 changes: 26 additions & 28 deletions ollama/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@
ShowResponse,
StatusResponse,
Tool,
WebCrawlRequest,
WebCrawlResponse,
WebFetchRequest,
WebFetchResponse,
WebSearchRequest,
WebSearchResponse,
)
Expand Down Expand Up @@ -633,13 +633,13 @@ def ps(self) -> ProcessResponse:
'/api/ps',
)

def web_search(self, queries: Sequence[str], max_results: int = 3) -> WebSearchResponse:
def web_search(self, query: str, max_results: int = 3) -> WebSearchResponse:
"""
Performs a web search

Args:
queries: The queries to search for
max_results: The maximum number of results to return.
query: The query to search for
max_results: The maximum number of results to return (default: 3)

Returns:
WebSearchResponse with the search results
Expand All @@ -654,32 +654,30 @@ def web_search(self, queries: Sequence[str], max_results: int = 3) -> WebSearchR
'POST',
'https://ollama.com/api/web_search',
json=WebSearchRequest(
queries=queries,
query=query,
max_results=max_results,
).model_dump(exclude_none=True),
)

def web_crawl(self, urls: Sequence[str]) -> WebCrawlResponse:
def web_fetch(self, url: str) -> WebFetchResponse:
"""
Gets the content of web pages for the provided URLs.
Fetches the content of a web page for the provided URL.

Args:
urls: The URLs to crawl
url: The URL to fetch

Returns:
WebCrawlResponse with the crawl results
Raises:
ValueError: If OLLAMA_API_KEY environment variable is not set
WebFetchResponse with the fetched result
"""
if not self._client.headers.get('authorization', '').startswith('Bearer '):
raise ValueError('Authorization header with Bearer token is required for web fetch')

return self._request(
WebCrawlResponse,
WebFetchResponse,
'POST',
'https://ollama.com/api/web_crawl',
json=WebCrawlRequest(
urls=urls,
'https://ollama.com/api/web_fetch',
json=WebFetchRequest(
url=url,
).model_dump(exclude_none=True),
)

Expand Down Expand Up @@ -752,13 +750,13 @@ async def inner():

return cls(**(await self._request_raw(*args, **kwargs)).json())

async def websearch(self, queries: Sequence[str], max_results: int = 3) -> WebSearchResponse:
async def web_search(self, query: str, max_results: int = 3) -> WebSearchResponse:
"""
Performs a web search

Args:
queries: The queries to search for
max_results: The maximum number of results to return.
query: The query to search for
max_results: The maximum number of results to return (default: 3)

Returns:
WebSearchResponse with the search results
Expand All @@ -768,27 +766,27 @@ async def websearch(self, queries: Sequence[str], max_results: int = 3) -> WebSe
'POST',
'https://ollama.com/api/web_search',
json=WebSearchRequest(
queries=queries,
query=query,
max_results=max_results,
).model_dump(exclude_none=True),
)

async def webcrawl(self, urls: Sequence[str]) -> WebCrawlResponse:
async def web_fetch(self, url: str) -> WebFetchResponse:
"""
Gets the content of web pages for the provided URLs.
Fetches the content of a web page for the provided URL.

Args:
urls: The URLs to crawl
url: The URL to fetch

Returns:
WebCrawlResponse with the crawl results
WebFetchResponse with the fetched result
"""
return await self._request(
WebCrawlResponse,
WebFetchResponse,
'POST',
'https://ollama.com/api/web_crawl',
json=WebCrawlRequest(
urls=urls,
'https://ollama.com/api/web_fetch',
json=WebFetchRequest(
url=url,
).model_dump(exclude_none=True),
)

Expand Down
29 changes: 10 additions & 19 deletions ollama/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -542,37 +542,28 @@ class Model(SubscriptableBaseModel):


class WebSearchRequest(SubscriptableBaseModel):
queries: Sequence[str]
query: str
max_results: Optional[int] = None


class WebSearchResult(SubscriptableBaseModel):
title: str
url: str
content: str
content: Optional[str] = None
title: Optional[str] = None
url: Optional[str] = None


class WebCrawlResult(SubscriptableBaseModel):
title: str
class WebFetchRequest(SubscriptableBaseModel):
url: str
content: str
links: Optional[Sequence[str]] = None


class WebSearchResponse(SubscriptableBaseModel):
results: Mapping[str, Sequence[WebSearchResult]]
success: bool
errors: Optional[Sequence[str]] = None

results: Sequence[WebSearchResult]

class WebCrawlRequest(SubscriptableBaseModel):
urls: Sequence[str]


class WebCrawlResponse(SubscriptableBaseModel):
results: Mapping[str, Sequence[WebCrawlResult]]
success: bool
errors: Optional[Sequence[str]] = None
class WebFetchResponse(SubscriptableBaseModel):
title: Optional[str] = None
content: Optional[str] = None
links: Optional[Sequence[str]] = None


class RequestError(Exception):
Expand Down
32 changes: 16 additions & 16 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1203,29 +1203,29 @@ def test_client_web_search_requires_bearer_auth_header(monkeypatch: pytest.Monke
client = Client()

with pytest.raises(ValueError, match='Authorization header with Bearer token is required for web search'):
client.web_search(['test query'])
client.web_search('test query')


def test_client_web_crawl_requires_bearer_auth_header(monkeypatch: pytest.MonkeyPatch):
def test_client_web_fetch_requires_bearer_auth_header(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv('OLLAMA_API_KEY', raising=False)

client = Client()

with pytest.raises(ValueError, match='Authorization header with Bearer token is required for web fetch'):
client.web_crawl(['https://example.com'])
client.web_fetch('https://example.com')


def _mock_request_web_search(self, cls, method, url, json=None, **kwargs):
assert method == 'POST'
assert url == 'https://ollama.com/api/web_search'
assert json is not None and 'queries' in json and 'max_results' in json
assert json is not None and 'query' in json and 'max_results' in json
return httpxResponse(status_code=200, content='{"results": {}, "success": true}')


def _mock_request_web_crawl(self, cls, method, url, json=None, **kwargs):
def _mock_request_web_fetch(self, cls, method, url, json=None, **kwargs):
assert method == 'POST'
assert url == 'https://ollama.com/api/web_crawl'
assert json is not None and 'urls' in json
assert url == 'https://ollama.com/api/web_fetch'
assert json is not None and 'url' in json
return httpxResponse(status_code=200, content='{"results": {}, "success": true}')


Expand All @@ -1234,31 +1234,31 @@ def test_client_web_search_with_env_api_key(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(Client, '_request', _mock_request_web_search)

client = Client()
client.web_search(['what is ollama?'], max_results=2)
client.web_search('what is ollama?', max_results=2)


def test_client_web_crawl_with_env_api_key(monkeypatch: pytest.MonkeyPatch):
def test_client_web_fetch_with_env_api_key(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv('OLLAMA_API_KEY', 'test-key')
monkeypatch.setattr(Client, '_request', _mock_request_web_crawl)
monkeypatch.setattr(Client, '_request', _mock_request_web_fetch)

client = Client()
client.web_crawl(['https://example.com'])
client.web_fetch('https://example.com')


def test_client_web_search_with_explicit_bearer_header(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv('OLLAMA_API_KEY', raising=False)
monkeypatch.setattr(Client, '_request', _mock_request_web_search)

client = Client(headers={'Authorization': 'Bearer custom-token'})
client.web_search(['what is ollama?'], max_results=1)
client.web_search('what is ollama?', max_results=1)


def test_client_web_crawl_with_explicit_bearer_header(monkeypatch: pytest.MonkeyPatch):
def test_client_web_fetch_with_explicit_bearer_header(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv('OLLAMA_API_KEY', raising=False)
monkeypatch.setattr(Client, '_request', _mock_request_web_crawl)
monkeypatch.setattr(Client, '_request', _mock_request_web_fetch)

client = Client(headers={'Authorization': 'Bearer custom-token'})
client.web_crawl(['https://example.com'])
client.web_fetch('https://example.com')


def test_client_bearer_header_from_env(monkeypatch: pytest.MonkeyPatch):
Expand All @@ -1274,4 +1274,4 @@ def test_client_explicit_bearer_header_overrides_env(monkeypatch: pytest.MonkeyP

client = Client(headers={'Authorization': 'Bearer explicit-token'})
assert client._client.headers['authorization'] == 'Bearer explicit-token'
client.web_search(['override check'])
client.web_search('override check')
Loading