Compare commits

..

1 Commits

Author SHA1 Message Date
ParthSareen 4390741023 more wip 2025-09-17 11:01:58 -07:00
6 changed files with 162 additions and 149 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
- uses: actions/setup-python@v5
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
- uses: actions/setup-python@v5
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
+11
View File
@@ -1,4 +1,8 @@
from ollama._client import AsyncClient, Client
from ollama._browser import (
Browser
)
from ollama._types import (
ChatResponse,
EmbeddingsResponse,
@@ -15,6 +19,8 @@ from ollama._types import (
ShowResponse,
StatusResponse,
Tool,
WebSearchResponse,
WebCrawlResponse,
)
__all__ = [
@@ -35,6 +41,9 @@ __all__ = [
'ShowResponse',
'StatusResponse',
'Tool',
'WebSearchResponse',
'WebCrawlResponse',
'Browser',
]
_client = Client()
@@ -51,3 +60,5 @@ list = _client.list
copy = _client.copy
show = _client.show
ps = _client.ps
websearch = _client.websearch
webcrawl = _client.webcrawl
-100
View File
@@ -1,100 +0,0 @@
import base64
import os
import time
from pathlib import Path
from typing import Optional
from cryptography.hazmat.primitives import serialization
class OllamaAuth:
def __init__(self, key_path: Optional[str] = None):
"""Initialize the OllamaAuth class.
Args:
key_path: Optional path to the private key file. If not provided,
defaults to ~/.ollama/id_ed25519
"""
if key_path is None:
home = str(Path.home())
self.key_path = os.path.join(home, '.ollama', 'id_ed25519')
else:
# Expand ~ and environment variables in the path
self.key_path = os.path.expanduser(os.path.expandvars(key_path))
def load_private_key(self):
"""Read and load the private key.
Returns:
The loaded Ed25519 private key.
Raises:
FileNotFoundError: If the key file doesn't exist
ValueError: If the key file is invalid
"""
try:
with open(self.key_path, 'rb') as f:
private_key_data = f.read()
private_key = serialization.load_ssh_private_key(
private_key_data,
password=None,
)
return private_key
except FileNotFoundError:
raise FileNotFoundError(f"Could not find Ollama private key at {self.key_path}. Please generate one using: ssh-keygen -t ed25519 -f ~/.ollama/id_ed25519 -N ''")
except Exception as e:
raise ValueError(f'Invalid private key at {self.key_path}: {e!s}')
def get_public_key_b64(self, private_key):
"""Get the base64 encoded public key.
Args:
private_key: The Ed25519 private key
Returns:
Base64 encoded public key string
"""
# Get the public key in OpenSSH format and extract the second field (base64-encoded key)
public_key = private_key.public_key()
openssh_pub = (
public_key.public_bytes(
encoding=serialization.Encoding.OpenSSH,
format=serialization.PublicFormat.OpenSSH,
)
.decode('utf-8')
.strip()
)
parts = openssh_pub.split(' ')
if len(parts) < 2:
raise ValueError('Malformed OpenSSH public key')
public_key_b64 = parts[1]
return public_key_b64
def sign_request(self, method: str, path: str):
"""Sign an HTTP request.
Args:
method: The HTTP method (e.g. 'GET', 'POST')
path: The request path (e.g. '/api/chat')
Returns:
A tuple of (auth_token, timestamp) where auth_token is the
authorization header value and timestamp is the request timestamp.
Raises:
FileNotFoundError: If the key file doesn't exist
ValueError: If the key file is invalid
"""
timestamp = str(int(time.time()))
path_with_ts = f'{path}&ts={timestamp}' if '?' in path else f'{path}?ts={timestamp}'
challenge = f'{method},{path_with_ts}'
private_key = self.load_private_key()
signature = private_key.sign(challenge.encode())
public_key_b64 = self.get_public_key_b64(private_key)
auth_token = f'{public_key_b64}:{base64.b64encode(signature).decode("utf-8")}'
return auth_token, timestamp
+52 -44
View File
@@ -25,7 +25,6 @@ from typing import (
import anyio
from pydantic.json_schema import JsonSchemaValue
from ollama._auth import OllamaAuth
from ollama._utils import convert_function_to_tool
if sys.version_info < (3, 9):
@@ -67,6 +66,10 @@ from ollama._types import (
ShowResponse,
StatusResponse,
Tool,
WebCrawlRequest,
WebCrawlResponse,
WebSearchRequest,
WebSearchResponse,
)
T = TypeVar('T')
@@ -81,7 +84,6 @@ class BaseClient:
follow_redirects: bool = True,
timeout: Any = None,
headers: Optional[Mapping[str, str]] = None,
auth_key_path: Optional[str] = None,
**kwargs,
) -> None:
"""
@@ -89,10 +91,9 @@ class BaseClient:
except for the following:
- `follow_redirects`: True
- `timeout`: None
- `auth_key_path`: Optional path to the ed25519 private key for authentication
`kwargs` are passed to the httpx client.
"""
self._auth = OllamaAuth(auth_key_path)
self._client = client(
base_url=_parse_host(host or os.getenv('OLLAMA_HOST')),
follow_redirects=follow_redirects,
@@ -105,32 +106,13 @@ class BaseClient:
'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,
)
def _prepare_request(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
if self._auth:
url = str(self._client.build_request(method, path).url)
parsed = urllib.parse.urlparse(url)
full_path = parsed.path
if parsed.query:
full_path = f'{full_path}?{parsed.query}'
auth_token, timestamp = self._auth.sign_request(method, full_path)
if 'headers' not in kwargs:
kwargs['headers'] = {}
kwargs['headers']['Authorization'] = auth_token
if '?' in path:
path = f'{path}&ts={timestamp}'
else:
path = f'{path}?ts={timestamp}'
return {'method': method, 'url': path, **kwargs}
CONNECTION_ERROR_MESSAGE = 'Failed to connect to Ollama. Please check that Ollama is downloaded, running and accessible. https://ollama.com/download'
@@ -179,18 +161,14 @@ class Client(BaseClient):
def _request(
self,
cls: Type[T],
method: str,
path: str,
*,
*args,
stream: bool = False,
**kwargs,
) -> Union[T, Iterator[T]]:
request_params = self._prepare_request(method, path, **kwargs)
if stream:
def inner():
with self._client.stream(**request_params) as r:
with self._client.stream(*args, **kwargs) as r:
try:
r.raise_for_status()
except httpx.HTTPStatusError as e:
@@ -205,7 +183,7 @@ class Client(BaseClient):
return inner()
return cls(**self._request_raw(**request_params).json())
return cls(**self._request_raw(*args, **kwargs).json())
@overload
def generate(
@@ -391,7 +369,6 @@ class Client(BaseClient):
truncate: Optional[bool] = None,
options: Optional[Union[Mapping[str, Any], Options]] = None,
keep_alive: Optional[Union[float, str]] = None,
dimensions: Optional[int] = None,
) -> EmbedResponse:
return self._request(
EmbedResponse,
@@ -403,7 +380,6 @@ class Client(BaseClient):
truncate=truncate,
options=options,
keep_alive=keep_alive,
dimensions=dimensions,
).model_dump(exclude_none=True),
)
@@ -652,6 +628,45 @@ class Client(BaseClient):
'/api/ps',
)
def websearch(self, query: str, max_results: int = 3) -> WebSearchResponse:
"""
Perform a web search using ollama.com.
Args:
query: The query 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=[query],
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:
@@ -697,19 +712,14 @@ class AsyncClient(BaseClient):
async def _request(
self,
cls: Type[T],
method: str,
path: str,
*,
*args,
stream: bool = False,
**kwargs,
) -> Union[T, AsyncIterator[T]]:
"""Make a request with optional authentication."""
request_params = self._prepare_request(method, path, **kwargs)
if stream:
async def inner():
async with self._client.stream(**request_params) as r:
async with self._client.stream(*args, **kwargs) as r:
try:
r.raise_for_status()
except httpx.HTTPStatusError as e:
@@ -724,7 +734,7 @@ class AsyncClient(BaseClient):
return inner()
return cls(**(await self._request_raw(**request_params)).json())
return cls(**(await self._request_raw(*args, **kwargs)).json())
@overload
async def generate(
@@ -910,7 +920,6 @@ class AsyncClient(BaseClient):
truncate: Optional[bool] = None,
options: Optional[Union[Mapping[str, Any], Options]] = None,
keep_alive: Optional[Union[float, str]] = None,
dimensions: Optional[int] = None,
) -> EmbedResponse:
return await self._request(
EmbedResponse,
@@ -922,7 +931,6 @@ class AsyncClient(BaseClient):
truncate=truncate,
options=options,
keep_alive=keep_alive,
dimensions=dimensions,
).model_dump(exclude_none=True),
)
+97 -3
View File
@@ -382,9 +382,6 @@ class EmbedRequest(BaseRequest):
keep_alive: Optional[Union[float, str]] = None
dimensions: Optional[int] = None
'Dimensions truncates the output embedding to the specified dimension.'
class EmbedResponse(BaseGenerateResponse):
"""
@@ -541,6 +538,103 @@ class ProcessResponse(SubscriptableBaseModel):
models: Sequence[Model]
class WebSearchRequest(SubscriptableBaseModel):
queries: Sequence[str]
max_results: Optional[int] = None
class SearchResult(SubscriptableBaseModel):
title: str
url: str
content: str
metadata: Optional['SearchResultMetadata'] = None
class CrawlResult(SubscriptableBaseModel):
title: str
url: str
content: str
links: Optional[Sequence[str]] = None
metadata: Optional['CrawlResultMetadata'] = None
class SearchResultContent(SubscriptableBaseModel):
snippet: str
full_text: str
class SearchResultMetadata(SubscriptableBaseModel):
published_date: Optional[str] = None
author: Optional[str] = None
class WebSearchResponse(SubscriptableBaseModel):
results: Mapping[str, Sequence[SearchResult]]
success: bool
errors: Optional[Sequence[str]] = None
def __str__(self) -> str:
if not self.success:
error_msg = ', '.join(self.errors) if self.errors else 'Unknown error'
return f'Web search failed: {error_msg}'
output = []
for query, search_results in self.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}')
if result.metadata and result.metadata.published_date:
output.append(f' Published: {result.metadata.published_date}')
if result.metadata and result.metadata.author:
output.append(f' Author: {result.metadata.author}')
output.append('')
return '\n'.join(output).rstrip()
class WebCrawlRequest(SubscriptableBaseModel):
urls: Sequence[str]
class CrawlResultContent(SubscriptableBaseModel):
# provides the first 200 characters of the full text
snippet: str
full_text: str
class CrawlResultMetadata(SubscriptableBaseModel):
published_date: Optional[str] = None
author: Optional[str] = None
class WebCrawlResponse(SubscriptableBaseModel):
results: Mapping[str, Sequence[CrawlResult]]
success: bool
errors: Optional[Sequence[str]] = None
def __str__(self) -> str:
if not self.success:
error_msg = ', '.join(self.errors) if self.errors else 'Unknown error'
return f'Web crawl failed: {error_msg}'
output = []
for url, crawl_results in self.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)}')
if result.metadata and result.metadata.published_date:
output.append(f' Published: {result.metadata.published_date}')
if result.metadata and result.metadata.author:
output.append(f' Author: {result.metadata.author}')
output.append('')
return '\n'.join(output).rstrip()
class RequestError(Exception):
"""
Common class for request errors.