Skip to content

Commit 4d0b81b

Browse files
authored
client: add web search and web crawl capabilities (#578)
1 parent a1d04f0 commit 4d0b81b

File tree

4 files changed

+208
-0
lines changed

4 files changed

+208
-0
lines changed

examples/web-search-crawl.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# /// script
2+
# requires-python = ">=3.11"
3+
# dependencies = [
4+
# "rich",
5+
# ]
6+
# ///
7+
import os
8+
from typing import Union
9+
10+
from rich import print
11+
12+
from ollama import Client, WebCrawlResponse, WebSearchResponse
13+
14+
15+
def format_tool_results(results: Union[WebSearchResponse, WebCrawlResponse]):
16+
if isinstance(results, WebSearchResponse):
17+
if not results.success:
18+
error_msg = ', '.join(results.errors) if results.errors else 'Unknown error'
19+
return f'Web search failed: {error_msg}'
20+
21+
output = []
22+
for query, search_results in results.results.items():
23+
output.append(f'Search results for "{query}":')
24+
for i, result in enumerate(search_results, 1):
25+
output.append(f'{i}. {result.title}')
26+
output.append(f' URL: {result.url}')
27+
output.append(f' Content: {result.content}')
28+
output.append('')
29+
30+
return '\n'.join(output).rstrip()
31+
32+
elif isinstance(results, WebCrawlResponse):
33+
if not results.success:
34+
error_msg = ', '.join(results.errors) if results.errors else 'Unknown error'
35+
return f'Web crawl failed: {error_msg}'
36+
37+
output = []
38+
for url, crawl_results in results.results.items():
39+
output.append(f'Crawl results for "{url}":')
40+
for i, result in enumerate(crawl_results, 1):
41+
output.append(f'{i}. {result.title}')
42+
output.append(f' URL: {result.url}')
43+
output.append(f' Content: {result.content}')
44+
if result.links:
45+
output.append(f' Links: {", ".join(result.links)}')
46+
output.append('')
47+
48+
return '\n'.join(output).rstrip()
49+
50+
51+
client = Client(headers={'Authorization': (os.getenv('OLLAMA_API_KEY'))})
52+
available_tools = {'web_search': client.web_search, 'web_crawl': client.web_crawl}
53+
54+
query = "ollama's new engine"
55+
print('Query: ', query)
56+
57+
messages = [{'role': 'user', 'content': query}]
58+
while True:
59+
response = client.chat(model='qwen3', messages=messages, tools=[client.web_search, client.web_crawl], think=True)
60+
if response.message.thinking:
61+
print('Thinking: ')
62+
print(response.message.thinking + '\n\n')
63+
if response.message.content:
64+
print('Content: ')
65+
print(response.message.content + '\n')
66+
67+
messages.append(response.message)
68+
69+
if response.message.tool_calls:
70+
for tool_call in response.message.tool_calls:
71+
function_to_call = available_tools.get(tool_call.function.name)
72+
if function_to_call:
73+
result: WebSearchResponse | WebCrawlResponse = function_to_call(**tool_call.function.arguments)
74+
print('Result from tool call name: ', tool_call.function.name, 'with arguments: ', tool_call.function.arguments)
75+
print('Result: ', format_tool_results(result)[:200])
76+
77+
# caps the result at ~2000 tokens
78+
messages.append({'role': 'tool', 'content': format_tool_results(result)[: 2000 * 4], 'tool_name': tool_call.function.name})
79+
else:
80+
print(f'Tool {tool_call.function.name} not found')
81+
messages.append({'role': 'tool', 'content': f'Tool {tool_call.function.name} not found', 'tool_name': tool_call.function.name})
82+
else:
83+
# no more tool calls, we can stop the loop
84+
break

ollama/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
ShowResponse,
1616
StatusResponse,
1717
Tool,
18+
WebCrawlResponse,
19+
WebSearchResponse,
1820
)
1921

2022
__all__ = [
@@ -35,6 +37,8 @@
3537
'ShowResponse',
3638
'StatusResponse',
3739
'Tool',
40+
'WebCrawlResponse',
41+
'WebSearchResponse',
3842
]
3943

4044
_client = Client()
@@ -51,3 +55,5 @@
5155
copy = _client.copy
5256
show = _client.show
5357
ps = _client.ps
58+
websearch = _client.web_search
59+
webcrawl = _client.web_crawl

ollama/_client.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@
6666
ShowResponse,
6767
StatusResponse,
6868
Tool,
69+
WebCrawlRequest,
70+
WebCrawlResponse,
71+
WebSearchRequest,
72+
WebSearchResponse,
6973
)
7074

7175
T = TypeVar('T')
@@ -624,6 +628,46 @@ def ps(self) -> ProcessResponse:
624628
'/api/ps',
625629
)
626630

631+
def web_search(self, queries: Sequence[str], max_results: int = 3) -> WebSearchResponse:
632+
"""
633+
Performs a web search
634+
635+
Args:
636+
queries: The queries to search for
637+
max_results: The maximum number of results to return.
638+
639+
Returns:
640+
WebSearchResponse with the search results
641+
"""
642+
return self._request(
643+
WebSearchResponse,
644+
'POST',
645+
'https://ollama.com/api/web_search',
646+
json=WebSearchRequest(
647+
queries=queries,
648+
max_results=max_results,
649+
).model_dump(exclude_none=True),
650+
)
651+
652+
def web_crawl(self, urls: Sequence[str]) -> WebCrawlResponse:
653+
"""
654+
Gets the content of web pages for the provided URLs.
655+
656+
Args:
657+
urls: The URLs to crawl
658+
659+
Returns:
660+
WebCrawlResponse with the crawl results
661+
"""
662+
return self._request(
663+
WebCrawlResponse,
664+
'POST',
665+
'https://ollama.com/api/web_crawl',
666+
json=WebCrawlRequest(
667+
urls=urls,
668+
).model_dump(exclude_none=True),
669+
)
670+
627671

628672
class AsyncClient(BaseClient):
629673
def __init__(self, host: Optional[str] = None, **kwargs) -> None:
@@ -693,6 +737,46 @@ async def inner():
693737

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

740+
async def websearch(self, queries: Sequence[str], max_results: int = 3) -> WebSearchResponse:
741+
"""
742+
Performs a web search
743+
744+
Args:
745+
queries: The queries to search for
746+
max_results: The maximum number of results to return.
747+
748+
Returns:
749+
WebSearchResponse with the search results
750+
"""
751+
return await self._request(
752+
WebSearchResponse,
753+
'POST',
754+
'https://ollama.com/api/web_search',
755+
json=WebSearchRequest(
756+
queries=queries,
757+
max_results=max_results,
758+
).model_dump(exclude_none=True),
759+
)
760+
761+
async def webcrawl(self, urls: Sequence[str]) -> WebCrawlResponse:
762+
"""
763+
Gets the content of web pages for the provided URLs.
764+
765+
Args:
766+
urls: The URLs to crawl
767+
768+
Returns:
769+
WebCrawlResponse with the crawl results
770+
"""
771+
return await self._request(
772+
WebCrawlResponse,
773+
'POST',
774+
'https://ollama.com/api/web_crawl',
775+
json=WebCrawlRequest(
776+
urls=urls,
777+
).model_dump(exclude_none=True),
778+
)
779+
696780
@overload
697781
async def generate(
698782
self,

ollama/_types.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,40 @@ class Model(SubscriptableBaseModel):
541541
models: Sequence[Model]
542542

543543

544+
class WebSearchRequest(SubscriptableBaseModel):
545+
queries: Sequence[str]
546+
max_results: Optional[int] = None
547+
548+
549+
class WebSearchResult(SubscriptableBaseModel):
550+
title: str
551+
url: str
552+
content: str
553+
554+
555+
class WebCrawlResult(SubscriptableBaseModel):
556+
title: str
557+
url: str
558+
content: str
559+
links: Optional[Sequence[str]] = None
560+
561+
562+
class WebSearchResponse(SubscriptableBaseModel):
563+
results: Mapping[str, Sequence[WebSearchResult]]
564+
success: bool
565+
errors: Optional[Sequence[str]] = None
566+
567+
568+
class WebCrawlRequest(SubscriptableBaseModel):
569+
urls: Sequence[str]
570+
571+
572+
class WebCrawlResponse(SubscriptableBaseModel):
573+
results: Mapping[str, Sequence[WebCrawlResult]]
574+
success: bool
575+
errors: Optional[Sequence[str]] = None
576+
577+
544578
class RequestError(Exception):
545579
"""
546580
Common class for request errors.

0 commit comments

Comments
 (0)