From 43bf5e23a94838f495f93ef9381f5f1ce7ca30e6 Mon Sep 17 00:00:00 2001 From: srinivasBJ Date: Tue, 23 Jun 2026 01:48:11 +0530 Subject: [PATCH 1/3] types: align ListResponse.Model fields with server API Add missing fields to ListResponse.Model that the server's GET /api/tags endpoint already returns but the Python client was silently dropping: - name: human-readable model name - remote_model: upstream model identifier for remote models - remote_host: hostname of the remote model provider - capabilities: list of model capabilities (chat, completion, etc.) All new fields are Optional to preserve backward compatibility with older server versions. Includes unit tests for Client.list() and AsyncClient.list() that verify full deserialization of the updated response schema. Resolves ollama/ollama-python#561 --- ollama/_types.py | 5 ++ tests/test_client.py | 118 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/ollama/_types.py b/ollama/_types.py index 96529d63..264bd4e8 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.' diff --git a/tests/test_client.py b/tests/test_client.py index 34657513..e38d3028 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -845,6 +845,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 +896,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', @@ -1222,6 +1281,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 +1332,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' From 286cd06c4aef75d121611a0ec00fdb19fb80ed6d Mon Sep 17 00:00:00 2001 From: srinivasBJ Date: Tue, 23 Jun 2026 01:50:00 +0530 Subject: [PATCH 2/3] client: add modelfile parameter to create() Support passing a Modelfile string directly to the create endpoint, restoring functionality that was available in earlier versions of the API but missing from the current Python client. The modelfile parameter accepts a multi-line string containing FROM, SYSTEM, PARAMETER, and other Modelfile directives. This is forwarded as-is to the server's POST /api/create endpoint. Update the create.py example to demonstrate Modelfile-based model creation. Resolves ollama/ollama-python#664 --- examples/create.py | 9 +++++++-- ollama/_client.py | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) 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/_client.py b/ollama/_client.py index 18cb0fb4..71e7374f 100644 --- a/ollama/_client.py +++ b/ollama/_client.py @@ -538,6 +538,7 @@ def create( model: str, 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, @@ -555,6 +556,7 @@ def create( model: str, 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, @@ -571,6 +573,7 @@ def create( model: str, 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, @@ -595,6 +598,7 @@ def create( stream=stream, quantize=quantize, from_=from_, + modelfile=modelfile, files=files, adapters=adapters, license=license, @@ -1171,6 +1175,7 @@ async def create( model: str, 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, @@ -1188,6 +1193,7 @@ async def create( model: str, 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, @@ -1204,6 +1210,7 @@ async def create( model: str, 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, @@ -1229,6 +1236,7 @@ async def create( stream=stream, quantize=quantize, from_=from_, + modelfile=modelfile, files=files, adapters=adapters, license=license, From 7eccb99d94571743512212d932a979d99a35c821 Mon Sep 17 00:00:00 2001 From: srinivasBJ Date: Tue, 23 Jun 2026 02:32:09 +0530 Subject: [PATCH 3/3] client: upload local create blobs --- ollama/__init__.py | 4 + ollama/_client.py | 196 ++++++++++++++++++++++++++------ ollama/_types.py | 9 ++ tests/test_client.py | 264 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 435 insertions(+), 38 deletions(-) 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 71e7374f..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 = { @@ -539,8 +623,8 @@ def create( quantize: Optional[str] = None, from_: Optional[str] = None, modelfile: Optional[str] = None, - files: Optional[Dict[str, str]] = None, - adapters: Optional[Dict[str, 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, @@ -557,8 +641,8 @@ def create( quantize: Optional[str] = None, from_: Optional[str] = None, modelfile: Optional[str] = None, - files: Optional[Dict[str, str]] = None, - adapters: Optional[Dict[str, 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, @@ -574,8 +658,8 @@ def create( quantize: Optional[str] = None, from_: Optional[str] = None, modelfile: Optional[str] = None, - files: Optional[Dict[str, str]] = None, - adapters: Optional[Dict[str, 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, @@ -599,8 +683,8 @@ def create( quantize=quantize, from_=from_, modelfile=modelfile, - files=files, - adapters=adapters, + files=_resolve_blob_map(files, self.create_blob), + adapters=_resolve_blob_map(adapters, self.create_blob), license=license, template=template, system=system, @@ -610,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) @@ -675,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 @@ -1176,8 +1280,8 @@ async def create( quantize: Optional[str] = None, from_: Optional[str] = None, modelfile: Optional[str] = None, - files: Optional[Dict[str, str]] = None, - adapters: Optional[Dict[str, 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, @@ -1194,8 +1298,8 @@ async def create( quantize: Optional[str] = None, from_: Optional[str] = None, modelfile: Optional[str] = None, - files: Optional[Dict[str, str]] = None, - adapters: Optional[Dict[str, 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, @@ -1211,8 +1315,8 @@ async def create( quantize: Optional[str] = None, from_: Optional[str] = None, modelfile: Optional[str] = None, - files: Optional[Dict[str, str]] = None, - adapters: Optional[Dict[str, 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, @@ -1237,8 +1341,8 @@ async def create( quantize=quantize, from_=from_, modelfile=modelfile, - files=files, - adapters=adapters, + 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, @@ -1248,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: @@ -1320,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 264bd4e8..cb92409e 100644 --- a/ollama/_types.py +++ b/ollama/_types.py @@ -654,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 e38d3028..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', @@ -925,7 +1037,7 @@ def test_client_list(httpserver: HTTPServer): client = Client(httpserver.url_for('/')) response = client.list() - + assert len(response['models']) == 1 model = response['models'][0] assert model['name'] == 'gemma3:latest' @@ -1228,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', @@ -1361,7 +1553,7 @@ async def test_async_client_list(httpserver: HTTPServer): client = AsyncClient(httpserver.url_for('/')) response = await client.list() - + assert len(response['models']) == 1 model = response['models'][0] assert model['name'] == 'gemma3:latest' @@ -1604,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