From f1aa404ad8428e41de36ee227089993259ad5894 Mon Sep 17 00:00:00 2001 From: ParthSareen Date: Wed, 17 Sep 2025 16:55:09 -0700 Subject: [PATCH 1/5] client: add websearch and webcrawl capabilities --- examples/websearch.py | 80 +++++++++++++++++++++++++++++++++++++++++++ ollama/__init__.py | 6 ++++ ollama/_client.py | 46 +++++++++++++++++++++++++ ollama/_types.py | 45 ++++++++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 examples/websearch.py diff --git a/examples/websearch.py b/examples/websearch.py new file mode 100644 index 00000000..08568bb8 --- /dev/null +++ b/examples/websearch.py @@ -0,0 +1,80 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "rich", +# ] +# /// +from rich import print +import os +from ollama import Client, WebCrawlResponse, WebSearchResponse + + +def format_tool_results(results: WebSearchResponse | WebCrawlResponse): + match results: + case 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('') + + return '\n'.join(output).rstrip() + + case 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('') + + return '\n'.join(output).rstrip() + + +client = Client(headers={'Authorization': (os.getenv('OLLAMA_API_KEY'))}) +available_tools = {'websearch': client.websearch, 'webcrawl': client.webcrawl} + +query = "ollama's new engine" +print('Query: ', query) + +messages = [{'role': 'user', 'content': query}] +while True: + response = client.chat(model='qwen3', messages=messages, tools=[client.websearch, client.webcrawl], think=True) + if response.message.thinking: + print('Thinking: ') + print(response.message.thinking + '\n\n') + if response.message.content: + print('Content: ') + print(response.message.content + '\n') + + messages.append(response.message) + + if response.message.tool_calls: + 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]) + messages.append({'role': 'tool', 'content': format_tool_results(result), '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}) + else: + # no more tool calls, we can stop the loop + break diff --git a/ollama/__init__.py b/ollama/__init__.py index afe8ce71..006bddb6 100644 --- a/ollama/__init__.py +++ b/ollama/__init__.py @@ -15,6 +15,8 @@ ShowResponse, StatusResponse, Tool, + WebCrawlResponse, + WebSearchResponse, ) __all__ = [ @@ -35,6 +37,8 @@ 'ShowResponse', 'StatusResponse', 'Tool', + 'WebCrawlResponse', + 'WebSearchResponse', ] _client = Client() @@ -51,3 +55,5 @@ copy = _client.copy show = _client.show ps = _client.ps +websearch = _client.websearch +webcrawl = _client.webcrawl diff --git a/ollama/_client.py b/ollama/_client.py index 0a85a74a..1df0c91e 100644 --- a/ollama/_client.py +++ b/ollama/_client.py @@ -66,6 +66,10 @@ ShowResponse, StatusResponse, Tool, + WebCrawlRequest, + WebCrawlResponse, + WebSearchRequest, + WebSearchResponse, ) T = TypeVar('T') @@ -102,6 +106,8 @@ def __init__( 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': f'ollama-python/{__version__} ({platform.machine()} {platform.system().lower()}) Python/{platform.python_version()}', + # TODO: this is to make the client feel good + # '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, }.items() }, **kwargs, @@ -622,6 +628,46 @@ def ps(self) -> ProcessResponse: '/api/ps', ) + def websearch(self, queries: Sequence[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. + + Returns: + WebSearchResponse with the search results + """ + return self._request( + WebSearchResponse, + 'POST', + 'https://ollama.com/api/web_search', + json=WebSearchRequest( + queries=queries, + max_results=max_results, + ).model_dump(exclude_none=True), + ) + + def webcrawl(self, urls: Sequence[str]) -> WebCrawlResponse: + """ + Gets the content of web pages for the provided URLs. + + Args: + urls: The URLs to crawl + + Returns: + WebCrawlResponse with the crawl results + """ + return self._request( + WebCrawlResponse, + 'POST', + 'https://ollama.com/api/web_crawl', + json=WebCrawlRequest( + urls=urls, + ).model_dump(exclude_none=True), + ) + class AsyncClient(BaseClient): def __init__(self, host: Optional[str] = None, **kwargs) -> None: diff --git a/ollama/_types.py b/ollama/_types.py index 04822875..5535fb51 100644 --- a/ollama/_types.py +++ b/ollama/_types.py @@ -538,6 +538,51 @@ class Model(SubscriptableBaseModel): models: Sequence[Model] +class WebSearchRequest(SubscriptableBaseModel): + queries: Sequence[str] + max_results: Optional[int] = None + + +class SearchResult(SubscriptableBaseModel): + title: str + url: str + content: str + + +class CrawlResult(SubscriptableBaseModel): + title: str + url: str + content: str + links: Optional[Sequence[str]] = None + + +class SearchResultContent(SubscriptableBaseModel): + snippet: str + full_text: str + + +class WebSearchResponse(SubscriptableBaseModel): + results: Mapping[str, Sequence[SearchResult]] + success: bool + errors: Optional[Sequence[str]] = None + + +class WebCrawlRequest(SubscriptableBaseModel): + urls: Sequence[str] + + +class CrawlResultContent(SubscriptableBaseModel): + # provides the first 200 characters of the full text + snippet: str + full_text: str + + +class WebCrawlResponse(SubscriptableBaseModel): + results: Mapping[str, Sequence[CrawlResult]] + success: bool + errors: Optional[Sequence[str]] = None + + class RequestError(Exception): """ Common class for request errors. From 0667e18240d89de5d1f795bb56697b3edd2aeb99 Mon Sep 17 00:00:00 2001 From: ParthSareen Date: Wed, 17 Sep 2025 17:58:29 -0700 Subject: [PATCH 2/5] fix lint --- examples/websearch.py | 68 +++++++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/examples/websearch.py b/examples/websearch.py index 08568bb8..fa54b6d7 100644 --- a/examples/websearch.py +++ b/examples/websearch.py @@ -4,46 +4,48 @@ # "rich", # ] # /// -from rich import print import os +from typing import Union + +from rich import print + from ollama import Client, WebCrawlResponse, WebSearchResponse -def format_tool_results(results: WebSearchResponse | WebCrawlResponse): - match results: - case WebSearchResponse(): - if not results.success: - error_msg = ', '.join(results.errors) if results.errors else 'Unknown error' - return f'Web search failed: {error_msg}' +def format_tool_results(results: Union[WebSearchResponse, WebCrawlResponse]): + 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 = [] + 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('') - return '\n'.join(output).rstrip() + return '\n'.join(output).rstrip() - case WebCrawlResponse(): - if not results.success: - error_msg = ', '.join(results.errors) if results.errors else 'Unknown error' - return f'Web crawl failed: {error_msg}' + 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('') + 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('') - return '\n'.join(output).rstrip() + return '\n'.join(output).rstrip() client = Client(headers={'Authorization': (os.getenv('OLLAMA_API_KEY'))}) @@ -71,7 +73,9 @@ def format_tool_results(results: WebSearchResponse | WebCrawlResponse): 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]) - messages.append({'role': 'tool', 'content': format_tool_results(result), 'tool_name': tool_call.function.name}) + + # caps the result at ~2000 tokens + messages.append({'role': 'tool', 'content': format_tool_results(result)[: 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}) From 5abbd025be1c61d13e7746f25965591e1bfc1434 Mon Sep 17 00:00:00 2001 From: ParthSareen Date: Wed, 17 Sep 2025 18:45:37 -0700 Subject: [PATCH 3/5] add funcs to async client --- ollama/_client.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/ollama/_client.py b/ollama/_client.py index 1df0c91e..61d6e4d6 100644 --- a/ollama/_client.py +++ b/ollama/_client.py @@ -737,6 +737,46 @@ async def inner(): return cls(**(await self._request_raw(*args, **kwargs)).json()) + async def websearch(self, queries: Sequence[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. + + Returns: + WebSearchResponse with the search results + """ + return await self._request( + WebSearchResponse, + 'POST', + 'https://ollama.com/api/web_search', + json=WebSearchRequest( + queries=queries, + max_results=max_results, + ).model_dump(exclude_none=True), + ) + + async def webcrawl(self, urls: Sequence[str]) -> WebCrawlResponse: + """ + Gets the content of web pages for the provided URLs. + + Args: + urls: The URLs to crawl + + Returns: + WebCrawlResponse with the crawl results + """ + return await self._request( + WebCrawlResponse, + 'POST', + 'https://ollama.com/api/web_crawl', + json=WebCrawlRequest( + urls=urls, + ).model_dump(exclude_none=True), + ) + @overload async def generate( self, From 45525a360d918e158e9edbfda0c17c216147de5f Mon Sep 17 00:00:00 2001 From: ParthSareen Date: Wed, 17 Sep 2025 19:01:13 -0700 Subject: [PATCH 4/5] update type prefixing --- ollama/_types.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/ollama/_types.py b/ollama/_types.py index 5535fb51..2fb30a34 100644 --- a/ollama/_types.py +++ b/ollama/_types.py @@ -543,26 +543,21 @@ class WebSearchRequest(SubscriptableBaseModel): max_results: Optional[int] = None -class SearchResult(SubscriptableBaseModel): +class WebSearchResult(SubscriptableBaseModel): title: str url: str content: str -class CrawlResult(SubscriptableBaseModel): +class WebCrawlResult(SubscriptableBaseModel): title: str url: str content: str links: Optional[Sequence[str]] = None -class SearchResultContent(SubscriptableBaseModel): - snippet: str - full_text: str - - class WebSearchResponse(SubscriptableBaseModel): - results: Mapping[str, Sequence[SearchResult]] + results: Mapping[str, Sequence[WebSearchResult]] success: bool errors: Optional[Sequence[str]] = None @@ -571,14 +566,8 @@ class WebCrawlRequest(SubscriptableBaseModel): urls: Sequence[str] -class CrawlResultContent(SubscriptableBaseModel): - # provides the first 200 characters of the full text - snippet: str - full_text: str - - class WebCrawlResponse(SubscriptableBaseModel): - results: Mapping[str, Sequence[CrawlResult]] + results: Mapping[str, Sequence[WebCrawlResult]] success: bool errors: Optional[Sequence[str]] = None From 679bb9e95043fb5cfda42bad2ff5eaf852eca3a7 Mon Sep 17 00:00:00 2001 From: ParthSareen Date: Thu, 18 Sep 2025 10:53:30 -0700 Subject: [PATCH 5/5] update naming --- examples/{websearch.py => web-search-crawl.py} | 4 ++-- ollama/__init__.py | 4 ++-- ollama/_client.py | 6 ++---- 3 files changed, 6 insertions(+), 8 deletions(-) rename examples/{websearch.py => web-search-crawl.py} (95%) diff --git a/examples/websearch.py b/examples/web-search-crawl.py similarity index 95% rename from examples/websearch.py rename to examples/web-search-crawl.py index fa54b6d7..30f9be4f 100644 --- a/examples/websearch.py +++ b/examples/web-search-crawl.py @@ -49,14 +49,14 @@ def format_tool_results(results: Union[WebSearchResponse, WebCrawlResponse]): client = Client(headers={'Authorization': (os.getenv('OLLAMA_API_KEY'))}) -available_tools = {'websearch': client.websearch, 'webcrawl': client.webcrawl} +available_tools = {'web_search': client.web_search, 'web_crawl': client.web_crawl} query = "ollama's new engine" print('Query: ', query) messages = [{'role': 'user', 'content': query}] while True: - response = client.chat(model='qwen3', messages=messages, tools=[client.websearch, client.webcrawl], think=True) + response = client.chat(model='qwen3', messages=messages, tools=[client.web_search, client.web_crawl], think=True) if response.message.thinking: print('Thinking: ') print(response.message.thinking + '\n\n') diff --git a/ollama/__init__.py b/ollama/__init__.py index 006bddb6..85d8bce7 100644 --- a/ollama/__init__.py +++ b/ollama/__init__.py @@ -55,5 +55,5 @@ copy = _client.copy show = _client.show ps = _client.ps -websearch = _client.websearch -webcrawl = _client.webcrawl +websearch = _client.web_search +webcrawl = _client.web_crawl diff --git a/ollama/_client.py b/ollama/_client.py index 61d6e4d6..3cc41b85 100644 --- a/ollama/_client.py +++ b/ollama/_client.py @@ -106,8 +106,6 @@ def __init__( 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': f'ollama-python/{__version__} ({platform.machine()} {platform.system().lower()}) Python/{platform.python_version()}', - # TODO: this is to make the client feel good - # '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, }.items() }, **kwargs, @@ -628,7 +626,7 @@ def ps(self) -> ProcessResponse: '/api/ps', ) - def websearch(self, queries: Sequence[str], max_results: int = 3) -> WebSearchResponse: + def web_search(self, queries: Sequence[str], max_results: int = 3) -> WebSearchResponse: """ Performs a web search @@ -649,7 +647,7 @@ def websearch(self, queries: Sequence[str], max_results: int = 3) -> WebSearchRe ).model_dump(exclude_none=True), ) - def webcrawl(self, urls: Sequence[str]) -> WebCrawlResponse: + def web_crawl(self, urls: Sequence[str]) -> WebCrawlResponse: """ Gets the content of web pages for the provided URLs.