Skip to content

Commit cd157f9

Browse files
committed
client: add websearch and webcrawl capabilities
1 parent 9f41447 commit cd157f9

File tree

4 files changed

+177
-0
lines changed

4 files changed

+177
-0
lines changed

examples/websearch.py

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

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.websearch
59+
webcrawl = _client.webcrawl

ollama/_client.py

Lines changed: 46 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')
@@ -102,6 +106,8 @@ def __init__(
102106
'Content-Type': 'application/json',
103107
'Accept': 'application/json',
104108
'User-Agent': f'ollama-python/{__version__} ({platform.machine()} {platform.system().lower()}) Python/{platform.python_version()}',
109+
# TODO: this is to make the client feel good
110+
# 'Authorization': f'Bearer {(headers or {}).get("Authorization") or os.getenv("OLLAMA_API_KEY")}' if (headers or {}).get("Authorization") or os.getenv("OLLAMA_API_KEY") else None,
105111
}.items()
106112
},
107113
**kwargs,
@@ -622,6 +628,46 @@ def ps(self) -> ProcessResponse:
622628
'/api/ps',
623629
)
624630

631+
def websearch(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 webcrawl(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+
625671

626672
class AsyncClient(BaseClient):
627673
def __init__(self, host: Optional[str] = None, **kwargs) -> None:

ollama/_types.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,51 @@ class Model(SubscriptableBaseModel):
538538
models: Sequence[Model]
539539

540540

541+
class WebSearchRequest(SubscriptableBaseModel):
542+
queries: Sequence[str]
543+
max_results: Optional[int] = None
544+
545+
546+
class SearchResult(SubscriptableBaseModel):
547+
title: str
548+
url: str
549+
content: str
550+
551+
552+
class CrawlResult(SubscriptableBaseModel):
553+
title: str
554+
url: str
555+
content: str
556+
links: Optional[Sequence[str]] = None
557+
558+
559+
class SearchResultContent(SubscriptableBaseModel):
560+
snippet: str
561+
full_text: str
562+
563+
564+
class WebSearchResponse(SubscriptableBaseModel):
565+
results: Mapping[str, Sequence[SearchResult]]
566+
success: bool
567+
errors: Optional[Sequence[str]] = None
568+
569+
570+
class WebCrawlRequest(SubscriptableBaseModel):
571+
urls: Sequence[str]
572+
573+
574+
class CrawlResultContent(SubscriptableBaseModel):
575+
# provides the first 200 characters of the full text
576+
snippet: str
577+
full_text: str
578+
579+
580+
class WebCrawlResponse(SubscriptableBaseModel):
581+
results: Mapping[str, Sequence[CrawlResult]]
582+
success: bool
583+
errors: Optional[Sequence[str]] = None
584+
585+
541586
class RequestError(Exception):
542587
"""
543588
Common class for request errors.

0 commit comments

Comments
 (0)