diff --git a/examples/create.py b/examples/create.py index 4ed8376f..10df4f8d 100755 --- a/examples/create.py +++ b/examples/create.py @@ -1,10 +1,15 @@ from ollama import Client client = Client() + +modelfile = ''' +FROM gemma3 +SYSTEM You are mario from Super Mario Bros. +''' + response = client.create( model='my-assistant', - from_='gemma3', - system='You are mario from Super Mario Bros.', + modelfile=modelfile, stream=False, ) print(response.status) diff --git a/ollama/__init__.py b/ollama/__init__.py index 92bba280..e2e7edad 100644 --- a/ollama/__init__.py +++ b/ollama/__init__.py @@ -15,6 +15,7 @@ ShowResponse, StatusResponse, Tool, + VersionResponse, WebFetchResponse, WebSearchResponse, ) @@ -37,6 +38,7 @@ 'ShowResponse', 'StatusResponse', 'Tool', + 'VersionResponse', 'WebFetchResponse', 'WebSearchResponse', ] @@ -55,5 +57,7 @@ copy = _client.copy show = _client.show ps = _client.ps +version = _client.version +check_blob = _client.check_blob web_search = _client.web_search web_fetch = _client.web_fetch diff --git a/ollama/_client.py b/ollama/_client.py index 18cb0fb4..70609f9b 100644 --- a/ollama/_client.py +++ b/ollama/_client.py @@ -67,6 +67,7 @@ ShowResponse, StatusResponse, Tool, + VersionResponse, WebFetchRequest, WebFetchResponse, WebSearchRequest, @@ -74,6 +75,88 @@ ) T = TypeVar('T') +BlobPath = Union[str, PathLike] + +SHA256_DIGEST_PREFIX = 'sha256:' + + +def _is_sha256_digest(value: str) -> bool: + if not value.startswith(SHA256_DIGEST_PREFIX): + return False + + digest = value[len(SHA256_DIGEST_PREFIX) :] + return len(digest) == 64 and all(c in '0123456789abcdefABCDEF' for c in digest) + + +def _is_existing_path(value: BlobPath) -> bool: + try: + return Path(value).is_file() + except (OSError, TypeError, ValueError): + return False + + +def _sha256_digest(path: BlobPath) -> str: + sha256sum = sha256() + with open(path, 'rb') as r: + while True: + chunk = r.read(32 * 1024) + if not chunk: + break + sha256sum.update(chunk) + + return f'{SHA256_DIGEST_PREFIX}{sha256sum.hexdigest()}' + + +async def _async_sha256_digest(path: BlobPath) -> str: + sha256sum = sha256() + async with await anyio.open_file(path, 'rb') as r: + while True: + chunk = await r.read(32 * 1024) + if not chunk: + break + sha256sum.update(chunk) + + return f'{SHA256_DIGEST_PREFIX}{sha256sum.hexdigest()}' + + +def _resolve_blob_map( + blobs: Optional[Mapping[str, BlobPath]], + upload: Callable[[BlobPath], str], +) -> Optional[Dict[str, str]]: + if not blobs: + return None + + resolved = {} + for name, value in blobs.items(): + value_str = os.fspath(value) + if _is_sha256_digest(value_str): + resolved[name] = value_str + elif _is_existing_path(value): + resolved[name] = upload(value) + else: + resolved[name] = value_str + + return resolved + + +async def _async_resolve_blob_map( + blobs: Optional[Mapping[str, BlobPath]], + upload: Callable[[BlobPath], Any], +) -> Optional[Dict[str, str]]: + if not blobs: + return None + + resolved = {} + for name, value in blobs.items(): + value_str = os.fspath(value) + if _is_sha256_digest(value_str): + resolved[name] = value_str + elif _is_existing_path(value): + resolved[name] = await upload(value) + else: + resolved[name] = value_str + + return resolved class BaseClient(contextlib.AbstractContextManager, contextlib.AbstractAsyncContextManager): @@ -93,6 +176,7 @@ def __init__( - `follow_redirects`: True - `timeout`: None `kwargs` are passed to the httpx client. + """ headers = { @@ -538,8 +622,9 @@ def create( model: str, quantize: Optional[str] = None, from_: Optional[str] = None, - files: Optional[Dict[str, str]] = None, - adapters: Optional[Dict[str, str]] = None, + modelfile: Optional[str] = None, + files: Optional[Mapping[str, BlobPath]] = None, + adapters: Optional[Mapping[str, BlobPath]] = None, template: Optional[str] = None, license: Optional[Union[str, List[str]]] = None, system: Optional[str] = None, @@ -555,8 +640,9 @@ def create( model: str, quantize: Optional[str] = None, from_: Optional[str] = None, - files: Optional[Dict[str, str]] = None, - adapters: Optional[Dict[str, str]] = None, + modelfile: Optional[str] = None, + files: Optional[Mapping[str, BlobPath]] = None, + adapters: Optional[Mapping[str, BlobPath]] = None, template: Optional[str] = None, license: Optional[Union[str, List[str]]] = None, system: Optional[str] = None, @@ -571,8 +657,9 @@ def create( model: str, quantize: Optional[str] = None, from_: Optional[str] = None, - files: Optional[Dict[str, str]] = None, - adapters: Optional[Dict[str, str]] = None, + modelfile: Optional[str] = None, + files: Optional[Mapping[str, BlobPath]] = None, + adapters: Optional[Mapping[str, BlobPath]] = None, template: Optional[str] = None, license: Optional[Union[str, List[str]]] = None, system: Optional[str] = None, @@ -595,8 +682,9 @@ def create( stream=stream, quantize=quantize, from_=from_, - files=files, - adapters=adapters, + modelfile=modelfile, + files=_resolve_blob_map(files, self.create_blob), + adapters=_resolve_blob_map(adapters, self.create_blob), license=license, template=template, system=system, @@ -606,16 +694,8 @@ def create( stream=stream, ) - def create_blob(self, path: Union[str, Path]) -> str: - sha256sum = sha256() - with open(path, 'rb') as r: - while True: - chunk = r.read(32 * 1024) - if not chunk: - break - sha256sum.update(chunk) - - digest = f'sha256:{sha256sum.hexdigest()}' + def create_blob(self, path: BlobPath) -> str: + digest = _sha256_digest(path) with open(path, 'rb') as r: self._request_raw('POST', f'/api/blobs/{digest}', content=r) @@ -671,6 +751,34 @@ def ps(self) -> ProcessResponse: '/api/ps', ) + def version(self) -> VersionResponse: + """ + Retrieve the server version. + + Returns `VersionResponse` with the running Ollama server version string. + """ + return self._request( + VersionResponse, + 'GET', + '/api/version', + ) + + def check_blob(self, digest: str) -> bool: + """ + Check whether a blob with the given digest already exists on the server. + + Uses `HEAD /api/blobs/:digest` to avoid uploading data that is already present. + + Returns `True` if the blob exists, `False` if it does not. + """ + try: + r = self._request_raw('HEAD', f'/api/blobs/{digest}') + return r.status_code == 200 + except ResponseError as e: + if e.status_code == 404: + return False + raise + def web_search(self, query: str, max_results: int = 3) -> WebSearchResponse: """ Performs a web search @@ -1171,8 +1279,9 @@ async def create( model: str, quantize: Optional[str] = None, from_: Optional[str] = None, - files: Optional[Dict[str, str]] = None, - adapters: Optional[Dict[str, str]] = None, + modelfile: Optional[str] = None, + files: Optional[Mapping[str, BlobPath]] = None, + adapters: Optional[Mapping[str, BlobPath]] = None, template: Optional[str] = None, license: Optional[Union[str, List[str]]] = None, system: Optional[str] = None, @@ -1188,8 +1297,9 @@ async def create( model: str, quantize: Optional[str] = None, from_: Optional[str] = None, - files: Optional[Dict[str, str]] = None, - adapters: Optional[Dict[str, str]] = None, + modelfile: Optional[str] = None, + files: Optional[Mapping[str, BlobPath]] = None, + adapters: Optional[Mapping[str, BlobPath]] = None, template: Optional[str] = None, license: Optional[Union[str, List[str]]] = None, system: Optional[str] = None, @@ -1204,8 +1314,9 @@ async def create( model: str, quantize: Optional[str] = None, from_: Optional[str] = None, - files: Optional[Dict[str, str]] = None, - adapters: Optional[Dict[str, str]] = None, + modelfile: Optional[str] = None, + files: Optional[Mapping[str, BlobPath]] = None, + adapters: Optional[Mapping[str, BlobPath]] = None, template: Optional[str] = None, license: Optional[Union[str, List[str]]] = None, system: Optional[str] = None, @@ -1229,8 +1340,9 @@ async def create( stream=stream, quantize=quantize, from_=from_, - files=files, - adapters=adapters, + modelfile=modelfile, + files=await _async_resolve_blob_map(files, self.create_blob), + adapters=await _async_resolve_blob_map(adapters, self.create_blob), license=license, template=template, system=system, @@ -1240,16 +1352,8 @@ async def create( stream=stream, ) - async def create_blob(self, path: Union[str, Path]) -> str: - sha256sum = sha256() - async with await anyio.open_file(path, 'rb') as r: - while True: - chunk = await r.read(32 * 1024) - if not chunk: - break - sha256sum.update(chunk) - - digest = f'sha256:{sha256sum.hexdigest()}' + async def create_blob(self, path: BlobPath) -> str: + digest = await _async_sha256_digest(path) async def upload_bytes(): async with await anyio.open_file(path, 'rb') as r: @@ -1312,6 +1416,34 @@ async def ps(self) -> ProcessResponse: '/api/ps', ) + async def version(self) -> VersionResponse: + """ + Retrieve the server version. + + Returns `VersionResponse` with the running Ollama server version string. + """ + return await self._request( + VersionResponse, + 'GET', + '/api/version', + ) + + async def check_blob(self, digest: str) -> bool: + """ + Check whether a blob with the given digest already exists on the server. + + Uses `HEAD /api/blobs/:digest` to avoid uploading data that is already present. + + Returns `True` if the blob exists, `False` if it does not. + """ + try: + r = await self._request_raw('HEAD', f'/api/blobs/{digest}') + return r.status_code == 200 + except ResponseError as e: + if e.status_code == 404: + return False + raise + def _copy_images(images: Optional[Sequence[Union[Image, Any]]]) -> Iterator[Image]: for image in images or []: diff --git a/ollama/_types.py b/ollama/_types.py index 96529d63..cb92409e 100644 --- a/ollama/_types.py +++ b/ollama/_types.py @@ -497,6 +497,7 @@ def serialize_model(self, nxt): """ quantize: Optional[str] = None from_: Optional[str] = None + modelfile: Optional[str] = None files: Optional[Dict[str, str]] = None adapters: Optional[Dict[str, str]] = None template: Optional[str] = None @@ -517,11 +518,15 @@ class ModelDetails(SubscriptableBaseModel): class ListResponse(SubscriptableBaseModel): class Model(SubscriptableBaseModel): + name: Optional[str] = None model: Optional[str] = None modified_at: Optional[datetime] = None digest: Optional[str] = None size: Optional[ByteSize] = None details: Optional[ModelDetails] = None + remote_model: Optional[str] = None + remote_host: Optional[str] = None + capabilities: Optional[Sequence[str]] = None models: Sequence[Model] 'List of models.' @@ -649,3 +654,12 @@ def __init__(self, error: str, status_code: int = -1): def __str__(self) -> str: return f'{self.error} (status code: {self.status_code})' + + +class VersionResponse(SubscriptableBaseModel): + """ + Response from the version endpoint. + """ + + version: str + 'Server version string.' diff --git a/tests/test_client.py b/tests/test_client.py index 34657513..a626411d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,6 +3,7 @@ import os import re import tempfile +from hashlib import sha256 from pathlib import Path from typing import Any @@ -792,6 +793,117 @@ def test_client_create_with_blob(httpserver: HTTPServer): assert response['status'] == 'success' +def test_client_create_uploads_file_paths(httpserver: HTTPServer, tmp_path): + content = b'gguf model content' + model_path = tmp_path / 'model.gguf' + model_path.write_bytes(content) + digest = f'sha256:{sha256(content).hexdigest()}' + + def upload_handler(request: Request): + assert request.get_data() == content + return Response(status=201) + + httpserver.expect_ordered_request( + f'/api/blobs/{digest}', + method='POST', + ).respond_with_handler(upload_handler) + + httpserver.expect_ordered_request( + '/api/create', + method='POST', + json={ + 'model': 'dummy', + 'files': {'model.gguf': digest}, + 'stream': False, + }, + ).respond_with_json({'status': 'success'}) + + client = Client(httpserver.url_for('/')) + response = client.create('dummy', files={'model.gguf': model_path}) + + assert response['status'] == 'success' + + +def test_client_create_uploads_file_path_strings(httpserver: HTTPServer, tmp_path): + content = b'safetensors content' + model_path = tmp_path / 'model.safetensors' + model_path.write_bytes(content) + digest = f'sha256:{sha256(content).hexdigest()}' + + def upload_handler(request: Request): + assert request.get_data() == content + return Response(status=200) + + httpserver.expect_ordered_request( + f'/api/blobs/{digest}', + method='POST', + ).respond_with_handler(upload_handler) + + httpserver.expect_ordered_request( + '/api/create', + method='POST', + json={ + 'model': 'dummy', + 'files': {'model.safetensors': digest}, + 'stream': False, + }, + ).respond_with_json({'status': 'success'}) + + client = Client(httpserver.url_for('/')) + response = client.create('dummy', files={'model.safetensors': str(model_path)}) + + assert response['status'] == 'success' + + +def test_client_create_uploads_adapter_paths(httpserver: HTTPServer, tmp_path): + content = b'lora adapter content' + adapter_path = tmp_path / 'adapter.gguf' + adapter_path.write_bytes(content) + digest = f'sha256:{sha256(content).hexdigest()}' + + def upload_handler(request: Request): + assert request.get_data() == content + return Response(status=201) + + httpserver.expect_ordered_request( + f'/api/blobs/{digest}', + method='POST', + ).respond_with_handler(upload_handler) + + httpserver.expect_ordered_request( + '/api/create', + method='POST', + json={ + 'model': 'dummy', + 'adapters': {'adapter.gguf': digest}, + 'stream': False, + }, + ).respond_with_json({'status': 'success'}) + + client = Client(httpserver.url_for('/')) + response = client.create('dummy', adapters={'adapter.gguf': adapter_path}) + + assert response['status'] == 'success' + + +def test_client_create_keeps_digest_values(httpserver: HTTPServer): + digest = 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + httpserver.expect_ordered_request( + '/api/create', + method='POST', + json={ + 'model': 'dummy', + 'files': {'model.gguf': digest}, + 'stream': False, + }, + ).respond_with_json({'status': 'success'}) + + client = Client(httpserver.url_for('/')) + response = client.create('dummy', files={'model.gguf': digest}) + + assert response['status'] == 'success' + + def test_client_create_with_parameters_roundtrip(httpserver: HTTPServer): httpserver.expect_ordered_request( '/api/create', @@ -845,6 +957,23 @@ def test_client_create_from_library(httpserver: HTTPServer): assert response['status'] == 'success' +def test_client_create_with_modelfile(httpserver: HTTPServer): + httpserver.expect_ordered_request( + '/api/create', + method='POST', + json={ + 'model': 'dummy', + 'modelfile': 'FROM llama3\nSYSTEM You are mario.', + 'stream': False, + }, + ).respond_with_json({'status': 'success'}) + + client = Client(httpserver.url_for('/')) + + response = client.create('dummy', modelfile='FROM llama3\nSYSTEM You are mario.') + assert response['status'] == 'success' + + def test_client_create_blob(httpserver: HTTPServer): httpserver.expect_ordered_request(re.compile('^/api/blobs/sha256[:-][0-9a-fA-F]{64}$'), method='POST').respond_with_response(Response(status=201)) @@ -879,6 +1008,48 @@ def test_client_copy(httpserver: HTTPServer): assert response['status'] == 'success' +def test_client_list(httpserver: HTTPServer): + httpserver.expect_ordered_request( + '/api/tags', + method='GET', + ).respond_with_json({ + 'models': [ + { + 'name': 'gemma3:latest', + 'model': 'gemma3:latest', + 'modified_at': '2025-05-10T08:06:48.639712Z', + 'size': 123456789, + 'digest': 'sha256:1234567890abcdef', + 'details': { + 'parent_model': '', + 'format': 'gguf', + 'family': 'gemma', + 'families': ['gemma'], + 'parameter_size': '9B', + 'quantization_level': 'Q4_K_M', + }, + 'remote_model': 'gemma3', + 'remote_host': 'https://ollama.com', + 'capabilities': ['chat', 'completion'], + } + ] + }) + + client = Client(httpserver.url_for('/')) + response = client.list() + + assert len(response['models']) == 1 + model = response['models'][0] + assert model['name'] == 'gemma3:latest' + assert model['model'] == 'gemma3:latest' + assert model['size'] == 123456789 + assert model['digest'] == 'sha256:1234567890abcdef' + assert model['details']['family'] == 'gemma' + assert model['remote_model'] == 'gemma3' + assert model['remote_host'] == 'https://ollama.com' + assert model['capabilities'] == ['chat', 'completion'] + + async def test_async_client_chat(httpserver: HTTPServer): httpserver.expect_ordered_request( '/api/chat', @@ -1169,6 +1340,86 @@ async def test_async_client_create_with_blob(httpserver: HTTPServer): assert response['status'] == 'success' +async def test_async_client_create_uploads_file_paths(httpserver: HTTPServer, tmp_path): + content = b'gguf model content' + model_path = tmp_path / 'model.gguf' + model_path.write_bytes(content) + digest = f'sha256:{sha256(content).hexdigest()}' + + def upload_handler(request: Request): + assert request.get_data() == content + return Response(status=201) + + httpserver.expect_ordered_request( + f'/api/blobs/{digest}', + method='POST', + ).respond_with_handler(upload_handler) + + httpserver.expect_ordered_request( + '/api/create', + method='POST', + json={ + 'model': 'dummy', + 'files': {'model.gguf': digest}, + 'stream': False, + }, + ).respond_with_json({'status': 'success'}) + + client = AsyncClient(httpserver.url_for('/')) + response = await client.create('dummy', files={'model.gguf': model_path}) + + assert response['status'] == 'success' + + +async def test_async_client_create_uploads_adapter_paths(httpserver: HTTPServer, tmp_path): + content = b'lora adapter content' + adapter_path = tmp_path / 'adapter.gguf' + adapter_path.write_bytes(content) + digest = f'sha256:{sha256(content).hexdigest()}' + + def upload_handler(request: Request): + assert request.get_data() == content + return Response(status=201) + + httpserver.expect_ordered_request( + f'/api/blobs/{digest}', + method='POST', + ).respond_with_handler(upload_handler) + + httpserver.expect_ordered_request( + '/api/create', + method='POST', + json={ + 'model': 'dummy', + 'adapters': {'adapter.gguf': digest}, + 'stream': False, + }, + ).respond_with_json({'status': 'success'}) + + client = AsyncClient(httpserver.url_for('/')) + response = await client.create('dummy', adapters={'adapter.gguf': adapter_path}) + + assert response['status'] == 'success' + + +async def test_async_client_create_keeps_digest_values(httpserver: HTTPServer): + digest = 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + httpserver.expect_ordered_request( + '/api/create', + method='POST', + json={ + 'model': 'dummy', + 'files': {'model.gguf': digest}, + 'stream': False, + }, + ).respond_with_json({'status': 'success'}) + + client = AsyncClient(httpserver.url_for('/')) + response = await client.create('dummy', files={'model.gguf': digest}) + + assert response['status'] == 'success' + + async def test_async_client_create_with_parameters_roundtrip(httpserver: HTTPServer): httpserver.expect_ordered_request( '/api/create', @@ -1222,6 +1473,23 @@ async def test_async_client_create_from_library(httpserver: HTTPServer): assert response['status'] == 'success' +async def test_async_client_create_with_modelfile(httpserver: HTTPServer): + httpserver.expect_ordered_request( + '/api/create', + method='POST', + json={ + 'model': 'dummy', + 'modelfile': 'FROM llama3\nSYSTEM You are mario.', + 'stream': False, + }, + ).respond_with_json({'status': 'success'}) + + client = AsyncClient(httpserver.url_for('/')) + + response = await client.create('dummy', modelfile='FROM llama3\nSYSTEM You are mario.') + assert response['status'] == 'success' + + async def test_async_client_create_blob(httpserver: HTTPServer): httpserver.expect_ordered_request(re.compile('^/api/blobs/sha256[:-][0-9a-fA-F]{64}$'), method='POST').respond_with_response(Response(status=201)) @@ -1256,6 +1524,48 @@ async def test_async_client_copy(httpserver: HTTPServer): assert response['status'] == 'success' +async def test_async_client_list(httpserver: HTTPServer): + httpserver.expect_ordered_request( + '/api/tags', + method='GET', + ).respond_with_json({ + 'models': [ + { + 'name': 'gemma3:latest', + 'model': 'gemma3:latest', + 'modified_at': '2025-05-10T08:06:48.639712Z', + 'size': 123456789, + 'digest': 'sha256:1234567890abcdef', + 'details': { + 'parent_model': '', + 'format': 'gguf', + 'family': 'gemma', + 'families': ['gemma'], + 'parameter_size': '9B', + 'quantization_level': 'Q4_K_M', + }, + 'remote_model': 'gemma3', + 'remote_host': 'https://ollama.com', + 'capabilities': ['chat', 'completion'], + } + ] + }) + + client = AsyncClient(httpserver.url_for('/')) + response = await client.list() + + assert len(response['models']) == 1 + model = response['models'][0] + assert model['name'] == 'gemma3:latest' + assert model['model'] == 'gemma3:latest' + assert model['size'] == 123456789 + assert model['digest'] == 'sha256:1234567890abcdef' + assert model['details']['family'] == 'gemma' + assert model['remote_model'] == 'gemma3' + assert model['remote_host'] == 'https://ollama.com' + assert model['capabilities'] == ['chat', 'completion'] + + def test_headers(): client = Client() assert client._client.headers['content-type'] == 'application/json' @@ -1486,3 +1796,71 @@ async def test_async_client_context_manager(): assert not client._client.is_closed assert client._client.is_closed + + +def test_client_version(httpserver: HTTPServer): + httpserver.expect_ordered_request( + '/api/version', + method='GET', + ).respond_with_json({'version': '0.12.6'}) + + client = Client(httpserver.url_for('/')) + response = client.version() + assert response['version'] == '0.12.6' + assert response.version == '0.12.6' + + +async def test_async_client_version(httpserver: HTTPServer): + httpserver.expect_ordered_request( + '/api/version', + method='GET', + ).respond_with_json({'version': '0.12.6'}) + + client = AsyncClient(httpserver.url_for('/')) + response = await client.version() + assert response['version'] == '0.12.6' + assert response.version == '0.12.6' + + +def test_client_check_blob_exists(httpserver: HTTPServer): + digest = 'sha256:' + 'a' * 64 + httpserver.expect_ordered_request( + f'/api/blobs/{digest}', + method='HEAD', + ).respond_with_response(Response(status=200)) + + client = Client(httpserver.url_for('/')) + assert client.check_blob(digest) is True + + +def test_client_check_blob_missing(httpserver: HTTPServer): + digest = 'sha256:' + 'b' * 64 + httpserver.expect_ordered_request( + f'/api/blobs/{digest}', + method='HEAD', + ).respond_with_response(Response(status=404)) + + client = Client(httpserver.url_for('/')) + assert client.check_blob(digest) is False + + +async def test_async_client_check_blob_exists(httpserver: HTTPServer): + digest = 'sha256:' + 'a' * 64 + httpserver.expect_ordered_request( + f'/api/blobs/{digest}', + method='HEAD', + ).respond_with_response(Response(status=200)) + + client = AsyncClient(httpserver.url_for('/')) + assert (await client.check_blob(digest)) is True + + +async def test_async_client_check_blob_missing(httpserver: HTTPServer): + digest = 'sha256:' + 'b' * 64 + httpserver.expect_ordered_request( + f'/api/blobs/{digest}', + method='HEAD', + ).respond_with_response(Response(status=404)) + + client = AsyncClient(httpserver.url_for('/')) + assert (await client.check_blob(digest)) is False