From 4923f30a4fd9b96e3051571832582e662a1a9e45 Mon Sep 17 00:00:00 2001 From: Michael Yang Date: Wed, 20 Dec 2023 15:54:10 -0800 Subject: [PATCH 01/10] add .gitignore --- .gitignore | 160 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68bc17f --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ From 717abfbaede1f517133509016421bd409f0b1472 Mon Sep 17 00:00:00 2001 From: Michael Yang Date: Wed, 20 Dec 2023 12:10:02 -0800 Subject: [PATCH 02/10] add ci tests/lint --- .github/workflows/test.yaml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/test.yaml diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..cc3a2d5 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,32 @@ +name: test + +on: + pull_request: + +jobs: + test: + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pipx install poetry + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: poetry + - run: poetry install --with=dev + - run: poetry run ruff --output-format=github . + - run: poetry run pytest . --junitxml=junit/test-results-${{ matrix.python-version }}.xml --cov=ollama --cov-report=xml --cov-report=html + - name: check poetry.lock is up-to-date + run: poetry check --lock + - name: check requirements.txt is up-to-date + run: | + poetry export >requirements.txt + git diff --exit-code requirements.txt + - uses: actions/upload-artifact@v3 + with: + name: pytest-results-${{ matrix.python-version }} + path: junit/test-results-${{ matrix.python-version }}.xml + if: ${{ always() }} From 877ca407b65177e33be45ca211b966b89fa4a236 Mon Sep 17 00:00:00 2001 From: Michael Yang Date: Wed, 20 Dec 2023 12:39:15 -0800 Subject: [PATCH 03/10] fix lint --- ollama/__init__.py | 9 +++++++++ ollama/client.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/ollama/__init__.py b/ollama/__init__.py index 8e7dc22..99b0adf 100644 --- a/ollama/__init__.py +++ b/ollama/__init__.py @@ -2,29 +2,38 @@ _default_client = Client() + def generate(*args, **kwargs): return _default_client.generate(*args, **kwargs) + def chat(*args, **kwargs): return _default_client.chat(*args, **kwargs) + def pull(*args, **kwargs): return _default_client.pull(*args, **kwargs) + def push(*args, **kwargs): return _default_client.push(*args, **kwargs) + def create(*args, **kwargs): return _default_client.create(*args, **kwargs) + def delete(*args, **kwargs): return _default_client.delete(*args, **kwargs) + def list(*args, **kwargs): return _default_client.list(*args, **kwargs) + def copy(*args, **kwargs): return _default_client.copy(*args, **kwargs) + def show(*args, **kwargs): return _default_client.show(*args, **kwargs) diff --git a/ollama/client.py b/ollama/client.py index 578757f..d80fd56 100644 --- a/ollama/client.py +++ b/ollama/client.py @@ -137,9 +137,9 @@ def show(self, model): def _encode_image(image): ''' _encode_images takes a list of images and returns a generator of base64 encoded images. - if the image is a bytes object, it is assumed to be the raw bytes of an image. if the image is a string, it is assumed to be a path to a file. if the image is a Path object, it is assumed to be a path to a file. + if the image is a bytes object, it is assumed to be the raw bytes of an image. if the image is a file-like object, it is assumed to be a container to the raw bytes of an image. ''' From 21578e3c5ebe244e2d0c64fc7b5d97382526b781 Mon Sep 17 00:00:00 2001 From: Michael Yang Date: Wed, 20 Dec 2023 12:41:54 -0800 Subject: [PATCH 04/10] add model to kwargs --- ollama/client.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ollama/client.py b/ollama/client.py index d80fd56..0065695 100644 --- a/ollama/client.py +++ b/ollama/client.py @@ -33,7 +33,10 @@ def stream(self, method, url, **kwargs): raise Exception(e) yield part - def generate(self, model, prompt='', system='', template='', context=None, stream=False, raw=False, format='', images=None, options=None): + def generate(self, model='', prompt='', system='', template='', context=None, stream=False, raw=False, format='', images=None, options=None): + if not model: + raise Exception('must provide a model') + fn = self.stream if stream else self._request_json return fn('POST', '/api/generate', json={ 'model': model, @@ -48,7 +51,10 @@ def generate(self, model, prompt='', system='', template='', context=None, strea 'options': options or {}, }) - def chat(self, model, messages=None, stream=False, format='', options=None): + def chat(self, model='', messages=None, stream=False, format='', options=None): + if not model: + raise Exception('must provide a model') + for message in messages or []: if not isinstance(message, dict): raise TypeError('messages must be a list of strings') From 20db23d932be603bd078c7aa456c7abf1e52bfb8 Mon Sep 17 00:00:00 2001 From: Michael Yang Date: Wed, 20 Dec 2023 15:28:23 -0800 Subject: [PATCH 05/10] async client --- ollama/__init__.py | 17 +- ollama/_client.py | 337 ++++++++++++++++++++++ ollama/client.py | 188 ------------- ollama/client_test.py | 292 ------------------- poetry.lock | 20 +- pyproject.toml | 1 + tests/test_client.py | 636 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1009 insertions(+), 482 deletions(-) create mode 100644 ollama/_client.py delete mode 100644 ollama/client.py delete mode 100644 ollama/client_test.py create mode 100644 tests/test_client.py diff --git a/ollama/__init__.py b/ollama/__init__.py index 99b0adf..c6fe6d4 100644 --- a/ollama/__init__.py +++ b/ollama/__init__.py @@ -1,4 +1,19 @@ -from ollama.client import Client +from ._client import Client, AsyncClient + +__all__ = [ + 'Client', + 'AsyncClient', + 'generate', + 'chat', + 'pull', + 'push', + 'create', + 'delete', + 'list', + 'copy', + 'show', +] + _default_client = Client() diff --git a/ollama/_client.py b/ollama/_client.py new file mode 100644 index 0000000..3882ac3 --- /dev/null +++ b/ollama/_client.py @@ -0,0 +1,337 @@ +import io +import json +import httpx +from pathlib import Path +from hashlib import sha256 +from base64 import b64encode + + +class BaseClient: + + def __init__(self, client, base_url='http://127.0.0.1:11434'): + self._client = client(base_url=base_url, follow_redirects=True, timeout=None) + + +class Client(BaseClient): + + def __init__(self, base='http://localhost:11434'): + super().__init__(httpx.Client, base) + + def _request(self, method, url, **kwargs): + response = self._client.request(method, url, **kwargs) + response.raise_for_status() + return response + + def _request_json(self, method, url, **kwargs): + return self._request(method, url, **kwargs).json() + + def _stream(self, method, url, **kwargs): + with self._client.stream(method, url, **kwargs) as r: + for line in r.iter_lines(): + part = json.loads(line) + if e := part.get('error'): + raise Exception(e) + yield part + + def generate(self, model='', prompt='', system='', template='', context=None, stream=False, raw=False, format='', images=None, options=None): + if not model: + raise Exception('must provide a model') + + fn = self._stream if stream else self._request_json + return fn('POST', '/api/generate', json={ + 'model': model, + 'prompt': prompt, + 'system': system, + 'template': template, + 'context': context or [], + 'stream': stream, + 'raw': raw, + 'images': [_encode_image(image) for image in images or []], + 'format': format, + 'options': options or {}, + }) + + def chat(self, model='', messages=None, stream=False, format='', options=None): + if not model: + raise Exception('must provide a model') + + for message in messages or []: + if not isinstance(message, dict): + raise TypeError('messages must be a list of strings') + if not (role := message.get('role')) or role not in ['system', 'user', 'assistant']: + raise Exception('messages must contain a role and it must be one of "system", "user", or "assistant"') + if not message.get('content'): + raise Exception('messages must contain content') + if images := message.get('images'): + message['images'] = [_encode_image(image) for image in images] + + fn = self._stream if stream else self._request_json + return fn('POST', '/api/chat', json={ + 'model': model, + 'messages': messages, + 'stream': stream, + 'format': format, + 'options': options or {}, + }) + + def pull(self, model, insecure=False, stream=False): + fn = self._stream if stream else self._request_json + return fn('POST', '/api/pull', json={ + 'model': model, + 'insecure': insecure, + 'stream': stream, + }) + + def push(self, model, insecure=False, stream=False): + fn = self._stream if stream else self._request_json + return fn('POST', '/api/push', json={ + 'model': model, + 'insecure': insecure, + 'stream': stream, + }) + + def create(self, model, path=None, modelfile=None, stream=False): + if (path := _as_path(path)) and path.exists(): + modelfile = self._parse_modelfile(path.read_text(), base=path.parent) + elif modelfile: + modelfile = self._parse_modelfile(modelfile) + else: + raise Exception('must provide either path or modelfile') + + fn = self._stream if stream else self._request_json + return fn('POST', '/api/create', json={ + 'model': model, + 'modelfile': modelfile, + 'stream': stream, + }) + + def _parse_modelfile(self, modelfile, base=None): + base = Path.cwd() if base is None else base + + out = io.StringIO() + for line in io.StringIO(modelfile): + command, _, args = line.partition(' ') + if command.upper() in ['FROM', 'ADAPTER']: + path = Path(args).expanduser() + path = path if path.is_absolute() else base / path + if path.exists(): + args = f'@{self._create_blob(path)}' + + print(command, args, file=out) + return out.getvalue() + + def _create_blob(self, path): + 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()}' + + try: + self._request('HEAD', f'/api/blobs/{digest}') + except httpx.HTTPStatusError as e: + if e.response.status_code != 404: + raise + + with open(path, 'rb') as r: + self._request('PUT', f'/api/blobs/{digest}', content=r) + + return digest + + def delete(self, model): + response = self._request_json('DELETE', '/api/delete', json={'model': model}) + return {'status': 'success' if response.status_code == 200 else 'error'} + + def list(self): + return self._request_json('GET', '/api/tags').get('models', []) + + def copy(self, source, target): + response = self._request_json('POST', '/api/copy', json={'source': source, 'destination': target}) + return {'status': 'success' if response.status_code == 200 else 'error'} + + def show(self, model): + return self._request_json('GET', '/api/show', json={'model': model}) + + +class AsyncClient(BaseClient): + + def __init__(self, base='http://localhost:11434'): + super().__init__(httpx.AsyncClient, base) + + async def _request(self, method, url, **kwargs): + response = await self._client.request(method, url, **kwargs) + response.raise_for_status() + return response + + async def _request_json(self, method, url, **kwargs): + response = await self._request(method, url, **kwargs) + return response.json() + + async def _stream(self, method, url, **kwargs): + async def inner(): + async with self._client.stream(method, url, **kwargs) as r: + async for line in r.aiter_lines(): + part = json.loads(line) + if e := part.get('error'): + raise Exception(e) + yield part + return inner() + + async def generate(self, model='', prompt='', system='', template='', context=None, stream=False, raw=False, format='', images=None, options=None): + if not model: + raise Exception('must provide a model') + + fn = self._stream if stream else self._request_json + return await fn('POST', '/api/generate', json={ + 'model': model, + 'prompt': prompt, + 'system': system, + 'template': template, + 'context': context or [], + 'stream': stream, + 'raw': raw, + 'images': [_encode_image(image) for image in images or []], + 'format': format, + 'options': options or {}, + }) + + async def chat(self, model='', messages=None, stream=False, format='', options=None): + if not model: + raise Exception('must provide a model') + + for message in messages or []: + if not isinstance(message, dict): + raise TypeError('messages must be a list of strings') + if not (role := message.get('role')) or role not in ['system', 'user', 'assistant']: + raise Exception('messages must contain a role and it must be one of "system", "user", or "assistant"') + if not message.get('content'): + raise Exception('messages must contain content') + if images := message.get('images'): + message['images'] = [_encode_image(image) for image in images] + + fn = self._stream if stream else self._request_json + return await fn('POST', '/api/chat', json={ + 'model': model, + 'messages': messages, + 'stream': stream, + 'format': format, + 'options': options or {}, + }) + + async def pull(self, model, insecure=False, stream=False): + fn = self._stream if stream else self._request_json + return await fn('POST', '/api/pull', json={ + 'model': model, + 'insecure': insecure, + 'stream': stream, + }) + + async def push(self, model, insecure=False, stream=False): + fn = self._stream if stream else self._request_json + return await fn('POST', '/api/push', json={ + 'model': model, + 'insecure': insecure, + 'stream': stream, + }) + + async def create(self, model, path=None, modelfile=None, stream=False): + if (path := _as_path(path)) and path.exists(): + modelfile = await self._parse_modelfile(path.read_text(), base=path.parent) + elif modelfile: + modelfile = await self._parse_modelfile(modelfile) + else: + raise Exception('must provide either path or modelfile') + + fn = self._stream if stream else self._request_json + return await fn('POST', '/api/create', json={ + 'model': model, + 'modelfile': modelfile, + 'stream': stream, + }) + + async def _parse_modelfile(self, modelfile, base=None): + base = Path.cwd() if base is None else base + + out = io.StringIO() + for line in io.StringIO(modelfile): + command, _, args = line.partition(' ') + if command.upper() in ['FROM', 'ADAPTER']: + path = Path(args).expanduser() + path = path if path.is_absolute() else base / path + if path.exists(): + args = f'@{await self._create_blob(path)}' + + print(command, args, file=out) + return out.getvalue() + + async def _create_blob(self, path): + 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()}' + + try: + await self._request('HEAD', f'/api/blobs/{digest}') + except httpx.HTTPStatusError as e: + if e.response.status_code != 404: + raise + + async def upload_bytes(): + with open(path, 'rb') as r: + while True: + chunk = r.read(32*1024) + if not chunk: + break + yield chunk + + await self._request('PUT', f'/api/blobs/{digest}', content=upload_bytes()) + + return digest + + async def delete(self, model): + response = await self._request_json('DELETE', '/api/delete', json={'model': model}) + return {'status': 'success' if response.status_code == 200 else 'error'} + + async def list(self): + response = await self._request_json('GET', '/api/tags') + return response.get('models', []) + + async def copy(self, source, target): + response = await self._request_json('POST', '/api/copy', json={'source': source, 'destination': target}) + return {'status': 'success' if response.status_code == 200 else 'error'} + + async def show(self, model): + return await self._request_json('GET', '/api/show', json={'model': model}) + + +def _encode_image(image): + if p := _as_path(image): + b64 = b64encode(p.read_bytes()) + elif b := _as_bytesio(image): + b64 = b64encode(b.read()) + else: + raise Exception('images must be a list of bytes, path-like objects, or file-like objects') + + return b64.decode('utf-8') + + +def _as_path(s): + if isinstance(s, str) or isinstance(s, Path): + return Path(s) + return None + +def _as_bytesio(s): + if isinstance(s, io.BytesIO): + return s + elif isinstance(s, bytes): + return io.BytesIO(s) + return None diff --git a/ollama/client.py b/ollama/client.py deleted file mode 100644 index 0065695..0000000 --- a/ollama/client.py +++ /dev/null @@ -1,188 +0,0 @@ -import io -import json -import httpx -from pathlib import Path -from hashlib import sha256 -from base64 import b64encode - - -class BaseClient: - - def __init__(self, client, base_url='http://127.0.0.1:11434'): - self._client = client(base_url=base_url, follow_redirects=True, timeout=None) - - -class Client(BaseClient): - - def __init__(self, base='http://localhost:11434'): - super().__init__(httpx.Client, base) - - def _request(self, method, url, **kwargs): - response = self._client.request(method, url, **kwargs) - response.raise_for_status() - return response - - def _request_json(self, method, url, **kwargs): - return self._request(method, url, **kwargs).json() - - def stream(self, method, url, **kwargs): - with self._client.stream(method, url, **kwargs) as r: - for line in r.iter_lines(): - part = json.loads(line) - if e := part.get('error'): - raise Exception(e) - yield part - - def generate(self, model='', prompt='', system='', template='', context=None, stream=False, raw=False, format='', images=None, options=None): - if not model: - raise Exception('must provide a model') - - fn = self.stream if stream else self._request_json - return fn('POST', '/api/generate', json={ - 'model': model, - 'prompt': prompt, - 'system': system, - 'template': template, - 'context': context or [], - 'stream': stream, - 'raw': raw, - 'images': [_encode_image(image) for image in images or []], - 'format': format, - 'options': options or {}, - }) - - def chat(self, model='', messages=None, stream=False, format='', options=None): - if not model: - raise Exception('must provide a model') - - for message in messages or []: - if not isinstance(message, dict): - raise TypeError('messages must be a list of strings') - if not (role := message.get('role')) or role not in ['system', 'user', 'assistant']: - raise Exception('messages must contain a role and it must be one of "system", "user", or "assistant"') - if not message.get('content'): - raise Exception('messages must contain content') - if images := message.get('images'): - message['images'] = [_encode_image(image) for image in images] - - fn = self.stream if stream else self._request_json - return fn('POST', '/api/chat', json={ - 'model': model, - 'messages': messages, - 'stream': stream, - 'format': format, - 'options': options or {}, - }) - - def pull(self, model, insecure=False, stream=False): - fn = self.stream if stream else self._request_json - return fn('POST', '/api/pull', json={ - 'model': model, - 'insecure': insecure, - 'stream': stream, - }) - - def push(self, model, insecure=False, stream=False): - fn = self.stream if stream else self._request_json - return fn('POST', '/api/push', json={ - 'model': model, - 'insecure': insecure, - 'stream': stream, - }) - - def create(self, model, path=None, modelfile=None, stream=False): - if (path := _as_path(path)) and path.exists(): - modelfile = _parse_modelfile(path.read_text(), self.create_blob, base=path.parent) - elif modelfile: - modelfile = _parse_modelfile(modelfile, self.create_blob) - else: - raise Exception('must provide either path or modelfile') - - fn = self.stream if stream else self._request_json - return fn('POST', '/api/create', json={ - 'model': model, - 'modelfile': modelfile, - 'stream': stream, - }) - - - def create_blob(self, path): - 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()}' - - try: - self._request('HEAD', f'/api/blobs/{digest}') - except httpx.HTTPError: - with open(path, 'rb') as r: - self._request('PUT', f'/api/blobs/{digest}', content=r) - - return digest - - def delete(self, model): - response = self._request_json('DELETE', '/api/delete', json={'model': model}) - return {'status': 'success' if response.status_code == 200 else 'error'} - - def list(self): - return self._request_json('GET', '/api/tags').get('models', []) - - def copy(self, source, target): - response = self._request_json('POST', '/api/copy', json={'source': source, 'destination': target}) - return {'status': 'success' if response.status_code == 200 else 'error'} - - def show(self, model): - return self._request_json('GET', '/api/show', json={'model': model}).json() - - -def _encode_image(image): - ''' - _encode_images takes a list of images and returns a generator of base64 encoded images. - if the image is a string, it is assumed to be a path to a file. - if the image is a Path object, it is assumed to be a path to a file. - if the image is a bytes object, it is assumed to be the raw bytes of an image. - if the image is a file-like object, it is assumed to be a container to the raw bytes of an image. - ''' - - if p := _as_path(image): - b64 = b64encode(p.read_bytes()) - elif b := _as_bytesio(image): - b64 = b64encode(b.read()) - else: - raise Exception('images must be a list of bytes, path-like objects, or file-like objects') - - return b64.decode('utf-8') - - -def _parse_modelfile(modelfile, cb, base=None): - base = Path.cwd() if base is None else base - - out = io.StringIO() - for line in io.StringIO(modelfile): - command, _, args = line.partition(' ') - if command.upper() in ['FROM', 'ADAPTER']: - path = Path(args).expanduser() - path = path if path.is_absolute() else base / path - if path.exists(): - args = f'@{cb(path)}' - - print(command, args, file=out) - return out.getvalue() - - -def _as_path(s): - if isinstance(s, str) or isinstance(s, Path): - return Path(s) - return None - -def _as_bytesio(s): - if isinstance(s, io.BytesIO): - return s - elif isinstance(s, bytes): - return io.BytesIO(s) - return None diff --git a/ollama/client_test.py b/ollama/client_test.py deleted file mode 100644 index cb563dc..0000000 --- a/ollama/client_test.py +++ /dev/null @@ -1,292 +0,0 @@ -import pytest -import os -import io -import types -import tempfile -from pathlib import Path -from ollama.client import Client -from pytest_httpserver import HTTPServer, URIPattern -from werkzeug.wrappers import Response -from PIL import Image - - -class PrefixPattern(URIPattern): - def __init__(self, prefix: str): - self.prefix = prefix - - def match(self, uri): - return uri.startswith(self.prefix) - - -def test_client_chat(httpserver: HTTPServer): - httpserver.expect_ordered_request('/api/chat', method='POST', json={ - 'model': 'dummy', - 'messages': [{'role': 'user', 'content': 'Why is the sky blue?'}], - 'stream': False, - 'format': '', - 'options': {}, - }).respond_with_json({}) - - client = Client(httpserver.url_for('/')) - response = client.chat('dummy', messages=[{'role': 'user', 'content': 'Why is the sky blue?'}]) - assert isinstance(response, dict) - - -def test_client_chat_stream(httpserver: HTTPServer): - httpserver.expect_ordered_request('/api/chat', method='POST', json={ - 'model': 'dummy', - 'messages': [{'role': 'user', 'content': 'Why is the sky blue?'}], - 'stream': True, - 'format': '', - 'options': {}, - }).respond_with_json({}) - - client = Client(httpserver.url_for('/')) - response = client.chat('dummy', messages=[{'role': 'user', 'content': 'Why is the sky blue?'}], stream=True) - assert isinstance(response, types.GeneratorType) - - -def test_client_chat_images(httpserver: HTTPServer): - httpserver.expect_ordered_request('/api/chat', method='POST', json={ - 'model': 'dummy', - 'messages': [ - { - 'role': 'user', - 'content': 'Why is the sky blue?', - 'images': ['iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC'], - }, - ], - 'stream': False, - 'format': '', - 'options': {}, - }).respond_with_json({}) - - client = Client(httpserver.url_for('/')) - - with io.BytesIO() as b: - Image.new('RGB', (1, 1)).save(b, 'PNG') - response = client.chat('dummy', messages=[{'role': 'user', 'content': 'Why is the sky blue?', 'images': [b.getvalue()]}]) - assert isinstance(response, dict) - - -def test_client_generate(httpserver: HTTPServer): - httpserver.expect_ordered_request('/api/generate', method='POST', json={ - 'model': 'dummy', - 'prompt': 'Why is the sky blue?', - 'system': '', - 'template': '', - 'context': [], - 'stream': False, - 'raw': False, - 'images': [], - 'format': '', - 'options': {}, - }).respond_with_json({}) - - client = Client(httpserver.url_for('/')) - response = client.generate('dummy', 'Why is the sky blue?') - assert isinstance(response, dict) - - -def test_client_generate_stream(httpserver: HTTPServer): - httpserver.expect_ordered_request('/api/generate', method='POST', json={ - 'model': 'dummy', - 'prompt': 'Why is the sky blue?', - 'system': '', - 'template': '', - 'context': [], - 'stream': True, - 'raw': False, - 'images': [], - 'format': '', - 'options': {}, - }).respond_with_json({}) - - client = Client(httpserver.url_for('/')) - response = client.generate('dummy', 'Why is the sky blue?', stream=True) - assert isinstance(response, types.GeneratorType) - - -def test_client_generate_images(httpserver: HTTPServer): - httpserver.expect_ordered_request('/api/generate', method='POST', json={ - 'model': 'dummy', - 'prompt': 'Why is the sky blue?', - 'system': '', - 'template': '', - 'context': [], - 'stream': False, - 'raw': False, - 'images': ['iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC'], - 'format': '', - 'options': {}, - }).respond_with_json({}) - - client = Client(httpserver.url_for('/')) - - with tempfile.NamedTemporaryFile() as temp: - Image.new('RGB', (1, 1)).save(temp, 'PNG') - response = client.generate('dummy', 'Why is the sky blue?', images=[temp.name]) - assert isinstance(response, dict) - - -def test_client_pull(httpserver: HTTPServer): - httpserver.expect_ordered_request('/api/pull', method='POST', json={ - 'model': 'dummy', - 'insecure': False, - 'stream': False, - }).respond_with_json({}) - - client = Client(httpserver.url_for('/')) - response = client.pull('dummy') - assert isinstance(response, dict) - - -def test_client_pull_stream(httpserver: HTTPServer): - httpserver.expect_ordered_request('/api/pull', method='POST', json={ - 'model': 'dummy', - 'insecure': False, - 'stream': True, - }).respond_with_json({}) - - client = Client(httpserver.url_for('/')) - response = client.pull('dummy', stream=True) - assert isinstance(response, types.GeneratorType) - - -def test_client_push(httpserver: HTTPServer): - httpserver.expect_ordered_request('/api/push', method='POST', json={ - 'model': 'dummy', - 'insecure': False, - 'stream': False, - }).respond_with_json({}) - - client = Client(httpserver.url_for('/')) - response = client.push('dummy') - assert isinstance(response, dict) - - -def test_client_push_stream(httpserver: HTTPServer): - httpserver.expect_ordered_request('/api/push', method='POST', json={ - 'model': 'dummy', - 'insecure': False, - 'stream': True, - }).respond_with_json({}) - - client = Client(httpserver.url_for('/')) - response = client.push('dummy', stream=True) - assert isinstance(response, types.GeneratorType) - - -def test_client_create_path(httpserver: HTTPServer): - httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200)) - httpserver.expect_ordered_request('/api/create', method='POST', json={ - 'model': 'dummy', - 'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n', - 'stream': False, - }).respond_with_json({}) - - client = Client(httpserver.url_for('/')) - - with tempfile.NamedTemporaryFile() as modelfile: - with tempfile.NamedTemporaryFile() as blob: - modelfile.write(f'FROM {blob.name}'.encode('utf-8')) - modelfile.flush() - - response = client.create('dummy', path=modelfile.name) - assert isinstance(response, dict) - - -def test_client_create_path_relative(httpserver: HTTPServer): - httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200)) - httpserver.expect_ordered_request('/api/create', method='POST', json={ - 'model': 'dummy', - 'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n', - 'stream': False, - }).respond_with_json({}) - - client = Client(httpserver.url_for('/')) - - with tempfile.NamedTemporaryFile() as modelfile: - with tempfile.NamedTemporaryFile(dir=Path(modelfile.name).parent) as blob: - modelfile.write(f'FROM {Path(blob.name).name}'.encode('utf-8')) - modelfile.flush() - - response = client.create('dummy', path=modelfile.name) - assert isinstance(response, dict) - - -@pytest.fixture -def userhomedir(): - with tempfile.TemporaryDirectory() as temp: - home = os.getenv('HOME', '') - os.environ['HOME'] = temp - yield Path(temp) - os.environ['HOME'] = home - - -def test_client_create_path_user_home(httpserver: HTTPServer, userhomedir): - httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200)) - httpserver.expect_ordered_request('/api/create', method='POST', json={ - 'model': 'dummy', - 'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n', - 'stream': False, - }).respond_with_json({}) - - client = Client(httpserver.url_for('/')) - - with tempfile.NamedTemporaryFile() as modelfile: - with tempfile.NamedTemporaryFile(dir=userhomedir) as blob: - modelfile.write(f'FROM ~/{Path(blob.name).name}'.encode('utf-8')) - modelfile.flush() - - response = client.create('dummy', path=modelfile.name) - assert isinstance(response, dict) - - -def test_client_create_modelfile(httpserver: HTTPServer): - httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200)) - httpserver.expect_ordered_request('/api/create', method='POST', json={ - 'model': 'dummy', - 'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n', - 'stream': False, - }).respond_with_json({}) - - client = Client(httpserver.url_for('/')) - - with tempfile.NamedTemporaryFile() as blob: - response = client.create('dummy', modelfile=f'FROM {blob.name}') - assert isinstance(response, dict) - - -def test_client_create_from_library(httpserver: HTTPServer): - httpserver.expect_ordered_request('/api/create', method='POST', json={ - 'model': 'dummy', - 'modelfile': 'FROM llama2\n', - 'stream': False, - }).respond_with_json({}) - - client = Client(httpserver.url_for('/')) - - response = client.create('dummy', modelfile='FROM llama2') - assert isinstance(response, dict) - - -def test_client_create_blob(httpserver: HTTPServer): - httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=404)) - httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='PUT').respond_with_response(Response(status=201)) - - client = Client(httpserver.url_for('/')) - - with tempfile.NamedTemporaryFile() as blob: - response = client.create_blob(blob.name) - assert response == 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' - - -def test_client_create_blob_exists(httpserver: HTTPServer): - httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200)) - - client = Client(httpserver.url_for('/')) - - with tempfile.NamedTemporaryFile() as blob: - response = client.create_blob(blob.name) - assert response == 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' diff --git a/poetry.lock b/poetry.lock index a61b83c..3db7b00 100644 --- a/poetry.lock +++ b/poetry.lock @@ -387,6 +387,24 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.23.2" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-asyncio-0.23.2.tar.gz", hash = "sha256:c16052382554c7b22d48782ab3438d5b10f8cf7a4bdcae7f0f67f097d95beecc"}, + {file = "pytest_asyncio-0.23.2-py3-none-any.whl", hash = "sha256:ea9021364e32d58f0be43b91c6233fb8d2224ccef2398d6837559e587682808f"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "pytest-cov" version = "4.1.0" @@ -498,4 +516,4 @@ watchdog = ["watchdog (>=2.3)"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "b9f64e1a5795a417d2dbff7286360f8d3f8f10fdfa9580411940d144c2561e92" +content-hash = "9416a897c95d3c80cf1bfd3cc61cd19f0143c9bd0bc7c219fcb31ee27c497c9d" diff --git a/pyproject.toml b/pyproject.toml index 5f3858a..c41e346 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ httpx = "^0.25.2" [tool.poetry.group.dev.dependencies] pytest = "^7.4.3" +pytest-asyncio = "^0.23.2" pytest-cov = "^4.1.0" pytest-httpserver = "^1.0.8" pillow = "^10.1.0" diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..31b3160 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,636 @@ +import os +import io +import json +import types +import pytest +import tempfile +from pathlib import Path +from pytest_httpserver import HTTPServer, URIPattern +from werkzeug.wrappers import Request, Response +from PIL import Image + +from ollama._client import Client, AsyncClient + + +class PrefixPattern(URIPattern): + def __init__(self, prefix: str): + self.prefix = prefix + + def match(self, uri): + return uri.startswith(self.prefix) + + +def test_client_chat(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/chat', method='POST', json={ + 'model': 'dummy', + 'messages': [{'role': 'user', 'content': 'Why is the sky blue?'}], + 'stream': False, + 'format': '', + 'options': {}, + }).respond_with_json({ + 'model': 'dummy', + 'message': { + 'role': 'assistant', + 'content': "I don't know.", + }, + }) + + client = Client(httpserver.url_for('/')) + response = client.chat('dummy', messages=[{'role': 'user', 'content': 'Why is the sky blue?'}]) + assert response['model'] == 'dummy' + assert response['message']['role'] == 'assistant' + assert response['message']['content'] == "I don't know." + + +def test_client_chat_stream(httpserver: HTTPServer): + def stream_handler(_: Request): + def generate(): + for message in ['I ', "don't ", 'know.']: + yield json.dumps({ + 'model': 'dummy', + 'message': { + 'role': 'assistant', + 'content': message, + }, + }) + '\n' + return Response(generate()) + + httpserver.expect_ordered_request('/api/chat', method='POST', json={ + 'model': 'dummy', + 'messages': [{'role': 'user', 'content': 'Why is the sky blue?'}], + 'stream': True, + 'format': '', + 'options': {}, + }).respond_with_handler(stream_handler) + + client = Client(httpserver.url_for('/')) + response = client.chat('dummy', messages=[{'role': 'user', 'content': 'Why is the sky blue?'}], stream=True) + for part in response: + assert part['message']['role'] in 'assistant' + assert part['message']['content'] in ['I ', "don't ", 'know.'] + + +def test_client_chat_images(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/chat', method='POST', json={ + 'model': 'dummy', + 'messages': [ + { + 'role': 'user', + 'content': 'Why is the sky blue?', + 'images': ['iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC'], + }, + ], + 'stream': False, + 'format': '', + 'options': {}, + }).respond_with_json({ + 'model': 'dummy', + 'message': { + 'role': 'assistant', + 'content': "I don't know.", + }, + }) + + client = Client(httpserver.url_for('/')) + + with io.BytesIO() as b: + Image.new('RGB', (1, 1)).save(b, 'PNG') + response = client.chat('dummy', messages=[{'role': 'user', 'content': 'Why is the sky blue?', 'images': [b.getvalue()]}]) + assert response['model'] == 'dummy' + assert response['message']['role'] == 'assistant' + assert response['message']['content'] == "I don't know." + + +def test_client_generate(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/generate', method='POST', json={ + 'model': 'dummy', + 'prompt': 'Why is the sky blue?', + 'system': '', + 'template': '', + 'context': [], + 'stream': False, + 'raw': False, + 'images': [], + 'format': '', + 'options': {}, + }).respond_with_json({ + 'model': 'dummy', + 'response': 'Because it is.', + }) + + client = Client(httpserver.url_for('/')) + response = client.generate('dummy', 'Why is the sky blue?') + assert response['model'] == 'dummy' + assert response['response'] == 'Because it is.' + + +def test_client_generate_stream(httpserver: HTTPServer): + def stream_handler(_: Request): + def generate(): + for message in ['Because ', 'it ', 'is.']: + yield json.dumps({ + 'model': 'dummy', + 'response': message, + }) + '\n' + return Response(generate()) + + httpserver.expect_ordered_request('/api/generate', method='POST', json={ + 'model': 'dummy', + 'prompt': 'Why is the sky blue?', + 'system': '', + 'template': '', + 'context': [], + 'stream': True, + 'raw': False, + 'images': [], + 'format': '', + 'options': {}, + }).respond_with_handler(stream_handler) + + client = Client(httpserver.url_for('/')) + response = client.generate('dummy', 'Why is the sky blue?', stream=True) + for part in response: + assert part['model'] == 'dummy' + assert part['response'] in ['Because ', 'it ', 'is.'] + + +def test_client_generate_images(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/generate', method='POST', json={ + 'model': 'dummy', + 'prompt': 'Why is the sky blue?', + 'system': '', + 'template': '', + 'context': [], + 'stream': False, + 'raw': False, + 'images': ['iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC'], + 'format': '', + 'options': {}, + }).respond_with_json({ + 'model': 'dummy', + 'response': 'Because it is.', + }) + + client = Client(httpserver.url_for('/')) + + with tempfile.NamedTemporaryFile() as temp: + Image.new('RGB', (1, 1)).save(temp, 'PNG') + response = client.generate('dummy', 'Why is the sky blue?', images=[temp.name]) + assert response['model'] == 'dummy' + assert response['response'] == 'Because it is.' + + +def test_client_pull(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/pull', method='POST', json={ + 'model': 'dummy', + 'insecure': False, + 'stream': False, + }).respond_with_json({ + 'status': 'success', + }) + + client = Client(httpserver.url_for('/')) + response = client.pull('dummy') + assert response['status'] == 'success' + + +def test_client_pull_stream(httpserver: HTTPServer): + def stream_handler(_: Request): + def generate(): + yield json.dumps({'status': 'pulling manifest'}) + '\n' + yield json.dumps({'status': 'verifying sha256 digest'}) + '\n' + yield json.dumps({'status': 'writing manifest'}) + '\n' + yield json.dumps({'status': 'removing any unused layers'}) + '\n' + yield json.dumps({'status': 'success'}) + '\n' + return Response(generate()) + + httpserver.expect_ordered_request('/api/pull', method='POST', json={ + 'model': 'dummy', + 'insecure': False, + 'stream': True, + }).respond_with_json({}) + + client = Client(httpserver.url_for('/')) + response = client.pull('dummy', stream=True) + assert isinstance(response, types.GeneratorType) + + +def test_client_push(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/push', method='POST', json={ + 'model': 'dummy', + 'insecure': False, + 'stream': False, + }).respond_with_json({}) + + client = Client(httpserver.url_for('/')) + response = client.push('dummy') + assert isinstance(response, dict) + + +def test_client_push_stream(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/push', method='POST', json={ + 'model': 'dummy', + 'insecure': False, + 'stream': True, + }).respond_with_json({}) + + client = Client(httpserver.url_for('/')) + response = client.push('dummy', stream=True) + assert isinstance(response, types.GeneratorType) + + +def test_client_create_path(httpserver: HTTPServer): + httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200)) + httpserver.expect_ordered_request('/api/create', method='POST', json={ + 'model': 'dummy', + 'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n', + 'stream': False, + }).respond_with_json({}) + + client = Client(httpserver.url_for('/')) + + with tempfile.NamedTemporaryFile() as modelfile: + with tempfile.NamedTemporaryFile() as blob: + modelfile.write(f'FROM {blob.name}'.encode('utf-8')) + modelfile.flush() + + response = client.create('dummy', path=modelfile.name) + assert isinstance(response, dict) + + +def test_client_create_path_relative(httpserver: HTTPServer): + httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200)) + httpserver.expect_ordered_request('/api/create', method='POST', json={ + 'model': 'dummy', + 'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n', + 'stream': False, + }).respond_with_json({}) + + client = Client(httpserver.url_for('/')) + + with tempfile.NamedTemporaryFile() as modelfile: + with tempfile.NamedTemporaryFile(dir=Path(modelfile.name).parent) as blob: + modelfile.write(f'FROM {Path(blob.name).name}'.encode('utf-8')) + modelfile.flush() + + response = client.create('dummy', path=modelfile.name) + assert isinstance(response, dict) + + +@pytest.fixture +def userhomedir(): + with tempfile.TemporaryDirectory() as temp: + home = os.getenv('HOME', '') + os.environ['HOME'] = temp + yield Path(temp) + os.environ['HOME'] = home + + +def test_client_create_path_user_home(httpserver: HTTPServer, userhomedir): + httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200)) + httpserver.expect_ordered_request('/api/create', method='POST', json={ + 'model': 'dummy', + 'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n', + 'stream': False, + }).respond_with_json({}) + + client = Client(httpserver.url_for('/')) + + with tempfile.NamedTemporaryFile() as modelfile: + with tempfile.NamedTemporaryFile(dir=userhomedir) as blob: + modelfile.write(f'FROM ~/{Path(blob.name).name}'.encode('utf-8')) + modelfile.flush() + + response = client.create('dummy', path=modelfile.name) + assert isinstance(response, dict) + + +def test_client_create_modelfile(httpserver: HTTPServer): + httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200)) + httpserver.expect_ordered_request('/api/create', method='POST', json={ + 'model': 'dummy', + 'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n', + 'stream': False, + }).respond_with_json({}) + + client = Client(httpserver.url_for('/')) + + with tempfile.NamedTemporaryFile() as blob: + response = client.create('dummy', modelfile=f'FROM {blob.name}') + assert isinstance(response, dict) + + +def test_client_create_from_library(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/create', method='POST', json={ + 'model': 'dummy', + 'modelfile': 'FROM llama2\n', + 'stream': False, + }).respond_with_json({}) + + client = Client(httpserver.url_for('/')) + + response = client.create('dummy', modelfile='FROM llama2') + assert isinstance(response, dict) + + +def test_client_create_blob(httpserver: HTTPServer): + httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=404)) + httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='PUT').respond_with_response(Response(status=201)) + + client = Client(httpserver.url_for('/')) + + with tempfile.NamedTemporaryFile() as blob: + response = client._create_blob(blob.name) + assert response == 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + + +def test_client_create_blob_exists(httpserver: HTTPServer): + httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200)) + + client = Client(httpserver.url_for('/')) + + with tempfile.NamedTemporaryFile() as blob: + response = client._create_blob(blob.name) + assert response == 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + + +@pytest.mark.asyncio +async def test_async_client_chat(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/chat', method='POST', json={ + 'model': 'dummy', + 'messages': [{'role': 'user', 'content': 'Why is the sky blue?'}], + 'stream': False, + 'format': '', + 'options': {}, + }).respond_with_json({}) + + client = AsyncClient(httpserver.url_for('/')) + response = await client.chat('dummy', messages=[{'role': 'user', 'content': 'Why is the sky blue?'}]) + assert isinstance(response, dict) + + +@pytest.mark.asyncio +async def test_async_client_chat_stream(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/chat', method='POST', json={ + 'model': 'dummy', + 'messages': [{'role': 'user', 'content': 'Why is the sky blue?'}], + 'stream': True, + 'format': '', + 'options': {}, + }).respond_with_json({}) + + client = AsyncClient(httpserver.url_for('/')) + response = await client.chat('dummy', messages=[{'role': 'user', 'content': 'Why is the sky blue?'}], stream=True) + assert isinstance(response, types.AsyncGeneratorType) + + +@pytest.mark.asyncio +async def test_async_client_chat_images(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/chat', method='POST', json={ + 'model': 'dummy', + 'messages': [ + { + 'role': 'user', + 'content': 'Why is the sky blue?', + 'images': ['iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC'], + }, + ], + 'stream': False, + 'format': '', + 'options': {}, + }).respond_with_json({}) + + client = AsyncClient(httpserver.url_for('/')) + + with io.BytesIO() as b: + Image.new('RGB', (1, 1)).save(b, 'PNG') + response = await client.chat('dummy', messages=[{'role': 'user', 'content': 'Why is the sky blue?', 'images': [b.getvalue()]}]) + assert isinstance(response, dict) + + +@pytest.mark.asyncio +async def test_async_client_generate(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/generate', method='POST', json={ + 'model': 'dummy', + 'prompt': 'Why is the sky blue?', + 'system': '', + 'template': '', + 'context': [], + 'stream': False, + 'raw': False, + 'images': [], + 'format': '', + 'options': {}, + }).respond_with_json({}) + + client = AsyncClient(httpserver.url_for('/')) + response = await client.generate('dummy', 'Why is the sky blue?') + assert isinstance(response, dict) + + +@pytest.mark.asyncio +async def test_async_client_generate_stream(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/generate', method='POST', json={ + 'model': 'dummy', + 'prompt': 'Why is the sky blue?', + 'system': '', + 'template': '', + 'context': [], + 'stream': True, + 'raw': False, + 'images': [], + 'format': '', + 'options': {}, + }).respond_with_json({}) + + client = AsyncClient(httpserver.url_for('/')) + response = await client.generate('dummy', 'Why is the sky blue?', stream=True) + assert isinstance(response, types.AsyncGeneratorType) + + +@pytest.mark.asyncio +async def test_async_client_generate_images(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/generate', method='POST', json={ + 'model': 'dummy', + 'prompt': 'Why is the sky blue?', + 'system': '', + 'template': '', + 'context': [], + 'stream': False, + 'raw': False, + 'images': ['iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC'], + 'format': '', + 'options': {}, + }).respond_with_json({}) + + client = AsyncClient(httpserver.url_for('/')) + + with tempfile.NamedTemporaryFile() as temp: + Image.new('RGB', (1, 1)).save(temp, 'PNG') + response = await client.generate('dummy', 'Why is the sky blue?', images=[temp.name]) + assert isinstance(response, dict) + + +@pytest.mark.asyncio +async def test_async_client_pull(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/pull', method='POST', json={ + 'model': 'dummy', + 'insecure': False, + 'stream': False, + }).respond_with_json({}) + + client = AsyncClient(httpserver.url_for('/')) + response = await client.pull('dummy') + assert isinstance(response, dict) + + +@pytest.mark.asyncio +async def test_async_client_pull_stream(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/pull', method='POST', json={ + 'model': 'dummy', + 'insecure': False, + 'stream': True, + }).respond_with_json({}) + + client = AsyncClient(httpserver.url_for('/')) + response = await client.pull('dummy', stream=True) + assert isinstance(response, types.AsyncGeneratorType) + + +@pytest.mark.asyncio +async def test_async_client_push(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/push', method='POST', json={ + 'model': 'dummy', + 'insecure': False, + 'stream': False, + }).respond_with_json({}) + + client = AsyncClient(httpserver.url_for('/')) + response = await client.push('dummy') + assert isinstance(response, dict) + + +@pytest.mark.asyncio +async def test_async_client_push_stream(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/push', method='POST', json={ + 'model': 'dummy', + 'insecure': False, + 'stream': True, + }).respond_with_json({}) + + client = AsyncClient(httpserver.url_for('/')) + response = await client.push('dummy', stream=True) + assert isinstance(response, types.AsyncGeneratorType) + + +@pytest.mark.asyncio +async def test_async_client_create_path(httpserver: HTTPServer): + httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200)) + httpserver.expect_ordered_request('/api/create', method='POST', json={ + 'model': 'dummy', + 'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n', + 'stream': False, + }).respond_with_json({}) + + client = AsyncClient(httpserver.url_for('/')) + + with tempfile.NamedTemporaryFile() as modelfile: + with tempfile.NamedTemporaryFile() as blob: + modelfile.write(f'FROM {blob.name}'.encode('utf-8')) + modelfile.flush() + + response = await client.create('dummy', path=modelfile.name) + assert isinstance(response, dict) + + +@pytest.mark.asyncio +async def test_async_client_create_path_relative(httpserver: HTTPServer): + httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200)) + httpserver.expect_ordered_request('/api/create', method='POST', json={ + 'model': 'dummy', + 'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n', + 'stream': False, + }).respond_with_json({}) + + client = AsyncClient(httpserver.url_for('/')) + + with tempfile.NamedTemporaryFile() as modelfile: + with tempfile.NamedTemporaryFile(dir=Path(modelfile.name).parent) as blob: + modelfile.write(f'FROM {Path(blob.name).name}'.encode('utf-8')) + modelfile.flush() + + response = await client.create('dummy', path=modelfile.name) + assert isinstance(response, dict) + + +@pytest.mark.asyncio +async def test_async_client_create_path_user_home(httpserver: HTTPServer, userhomedir): + httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200)) + httpserver.expect_ordered_request('/api/create', method='POST', json={ + 'model': 'dummy', + 'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n', + 'stream': False, + }).respond_with_json({}) + + client = AsyncClient(httpserver.url_for('/')) + + with tempfile.NamedTemporaryFile() as modelfile: + with tempfile.NamedTemporaryFile(dir=userhomedir) as blob: + modelfile.write(f'FROM ~/{Path(blob.name).name}'.encode('utf-8')) + modelfile.flush() + + response = await client.create('dummy', path=modelfile.name) + assert isinstance(response, dict) + + +@pytest.mark.asyncio +async def test_async_client_create_modelfile(httpserver: HTTPServer): + httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200)) + httpserver.expect_ordered_request('/api/create', method='POST', json={ + 'model': 'dummy', + 'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n', + 'stream': False, + }).respond_with_json({}) + + client = AsyncClient(httpserver.url_for('/')) + + with tempfile.NamedTemporaryFile() as blob: + response = await client.create('dummy', modelfile=f'FROM {blob.name}') + assert isinstance(response, dict) + + +@pytest.mark.asyncio +async def test_async_client_create_from_library(httpserver: HTTPServer): + httpserver.expect_ordered_request('/api/create', method='POST', json={ + 'model': 'dummy', + 'modelfile': 'FROM llama2\n', + 'stream': False, + }).respond_with_json({}) + + client = AsyncClient(httpserver.url_for('/')) + + response = await client.create('dummy', modelfile='FROM llama2') + assert isinstance(response, dict) + + +@pytest.mark.asyncio +async def test_async_client_create_blob(httpserver: HTTPServer): + httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=404)) + httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='PUT').respond_with_response(Response(status=201)) + + client = AsyncClient(httpserver.url_for('/')) + + with tempfile.NamedTemporaryFile() as blob: + response = await client._create_blob(blob.name) + assert response == 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + + +@pytest.mark.asyncio +async def test_async_client_create_blob_exists(httpserver: HTTPServer): + httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200)) + + client = AsyncClient(httpserver.url_for('/')) + + with tempfile.NamedTemporaryFile() as blob: + response = await client._create_blob(blob.name) + assert response == 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' From 83295d9c2d90061f7d0e8ff8ae5da47460e1a6b4 Mon Sep 17 00:00:00 2001 From: Michael Yang Date: Wed, 20 Dec 2023 16:27:54 -0800 Subject: [PATCH 06/10] add infill example --- examples/simple-fill-in-middle/main.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 examples/simple-fill-in-middle/main.py diff --git a/examples/simple-fill-in-middle/main.py b/examples/simple-fill-in-middle/main.py new file mode 100644 index 0000000..ca14d05 --- /dev/null +++ b/examples/simple-fill-in-middle/main.py @@ -0,0 +1,22 @@ +from ollama import generate + +prefix = '''def remove_non_ascii(s: str) -> str: + """ ''' + +suffix = ''' + return result +''' + + +response = generate( + model='codellama:7b-code', + prompt=f'
 {prefix} {suffix} ',
+  options={
+    'num_predict': 128,
+    'temperature': 0,
+    'top_p': 0.9,
+    'stop': [''],
+  },
+)
+
+print(response['response'])

From 6f5565914f1e76536afcd1e21e0b8bb11208e4fe Mon Sep 17 00:00:00 2001
From: Michael Yang 
Date: Wed, 20 Dec 2023 14:53:38 -0800
Subject: [PATCH 07/10] update README.md

---
 README.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 70 insertions(+)

diff --git a/README.md b/README.md
index e69de29..0183092 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,70 @@
+# Ollama Python Library
+
+The Ollama Python library provides the easiest way to integrate your Python 3 project with [Ollama](https://github.com/jmorganca/ollama).
+
+## Getting Started
+
+Requires Python 3.8 or higher.
+
+```sh
+pip install ollama
+```
+
+A global default client is provided for convenience and can be used in the same way as the synchronous client.
+
+```python
+import ollama
+response = ollama.chat(model='llama2', messages=[{'role': 'user', 'content': 'Why is the sky blue?'}])
+```
+
+```python
+import ollama
+message = {'role': 'user', 'content': 'Why is the sky blue?'}
+for part in ollama.chat(model='llama2', messages=[message], stream=True):
+  print(part['message']['content'], end='', flush=True)
+```
+
+
+### Using the Synchronous Client
+
+```python
+from ollama import Client
+message = {'role': 'user', 'content': 'Why is the sky blue?'}
+response = Client().chat(model='llama2', messages=[message])
+```
+
+Response streaming can be enabled by setting `stream=True`. This modifies the function to return a Python generator where each part is an object in the stream.
+
+```python
+from ollama import Client
+message = {'role': 'user', 'content': 'Why is the sky blue?'}
+for part in Client().chat(model='llama2', messages=[message], stream=True):
+  print(part['message']['content'], end='', flush=True)
+```
+
+### Using the Asynchronous Client
+
+```python
+import asyncio
+from ollama import AsyncClient
+
+async def chat():
+  message = {'role': 'user', 'content': 'Why is the sky blue?'}
+  response = await AsyncClient().chat(model='llama2', messages=[message])
+
+asyncio.run(chat())
+```
+
+Similar to the synchronous client, setting `stream=True` modifies the function to return a Python asynchronous generator.
+
+```python
+import asyncio
+from ollama import AsyncClient
+
+async def chat():
+  message = {'role': 'user', 'content': 'Why is the sky blue?'}
+  async for part in await AsyncClient().chat(model='llama2', messages=[message], stream=True):
+    print(part['message']['content'], end='', flush=True)
+
+asyncio.run(chat())
+```

From dabcca6a1f69e888eb055a44876144f04a2e0ffe Mon Sep 17 00:00:00 2001
From: Michael Yang 
Date: Thu, 21 Dec 2023 09:46:05 -0800
Subject: [PATCH 08/10] type hints

---
 ollama/__init__.py |   4 +-
 ollama/_client.py  | 166 +++++++++++++++++++++++++++++++++------------
 ollama/_types.py   |  52 ++++++++++++++
 3 files changed, 179 insertions(+), 43 deletions(-)
 create mode 100644 ollama/_types.py

diff --git a/ollama/__init__.py b/ollama/__init__.py
index c6fe6d4..a66f1d0 100644
--- a/ollama/__init__.py
+++ b/ollama/__init__.py
@@ -1,8 +1,10 @@
-from ._client import Client, AsyncClient
+from ollama._client import Client, AsyncClient, Message, Options
 
 __all__ = [
   'Client',
   'AsyncClient',
+  'Message',
+  'Options',
   'generate',
   'chat',
   'pull',
diff --git a/ollama/_client.py b/ollama/_client.py
index 3882ac3..9e5ee32 100644
--- a/ollama/_client.py
+++ b/ollama/_client.py
@@ -1,31 +1,42 @@
 import io
 import json
 import httpx
+from os import PathLike
 from pathlib import Path
 from hashlib import sha256
 from base64 import b64encode
 
+from typing import Any, AnyStr, Union, Optional, List, Mapping
+
+import sys
+if sys.version_info < (3, 9):
+  from typing import Iterator, AsyncIterator
+else:
+  from collections.abc import Iterator, AsyncIterator
+
+from ollama._types import Message, Options
+
 
 class BaseClient:
 
-  def __init__(self, client, base_url='http://127.0.0.1:11434'):
+  def __init__(self, client, base_url='http://127.0.0.1:11434') -> None:
     self._client = client(base_url=base_url, follow_redirects=True, timeout=None)
 
 
 class Client(BaseClient):
 
-  def __init__(self, base='http://localhost:11434'):
+  def __init__(self, base='http://localhost:11434') -> None:
     super().__init__(httpx.Client, base)
 
-  def _request(self, method, url, **kwargs):
+  def _request(self, method: str, url: str, **kwargs) -> httpx.Response:
     response = self._client.request(method, url, **kwargs)
     response.raise_for_status()
     return response
 
-  def _request_json(self, method, url, **kwargs):
+  def _request_json(self, method: str, url: str, **kwargs) -> Mapping[str, Any]:
     return self._request(method, url, **kwargs).json()
 
-  def _stream(self, method, url, **kwargs):
+  def _stream(self, method: str, url: str, **kwargs) -> Iterator[Mapping[str, Any]]:
     with self._client.stream(method, url, **kwargs) as r:
       for line in r.iter_lines():
         part = json.loads(line)
@@ -33,7 +44,19 @@ def _stream(self, method, url, **kwargs):
           raise Exception(e)
         yield part
 
-  def generate(self, model='', prompt='', system='', template='', context=None, stream=False, raw=False, format='', images=None, options=None):
+  def generate(
+      self,
+      model: str = '',
+      prompt: str = '',
+      system: str = '',
+      template: str = '',
+      context: Optional[List[int]] = None,
+      stream: bool = False,
+      raw: bool = False,
+      format: str = '',
+      images: Optional[List[AnyStr]] = None,
+      options: Optional[Options] = None,
+    ) -> Union[Mapping[str, Any], Iterator[Mapping[str, Any]]]:
     if not model:
       raise Exception('must provide a model')
 
@@ -51,7 +74,14 @@ def generate(self, model='', prompt='', system='', template='', context=None, st
       'options': options or {},
     })
 
-  def chat(self, model='', messages=None, stream=False, format='', options=None):
+  def chat(
+      self,
+      model: str = '',
+      messages: Optional[List[Message]] = None,
+      stream: bool = False,
+      format: str = '',
+      options: Optional[Options] = None,
+    ) -> Union[Mapping[str, Any], Iterator[Mapping[str, Any]]]:
     if not model:
       raise Exception('must provide a model')
 
@@ -74,7 +104,12 @@ def chat(self, model='', messages=None, stream=False, format='', options=None):
       'options': options or {},
     })
 
-  def pull(self, model, insecure=False, stream=False):
+  def pull(
+      self,
+      model: str,
+      insecure: bool = False,
+      stream: bool = False,
+    ) -> Union[Mapping[str, Any], Iterator[Mapping[str, Any]]]:
     fn = self._stream if stream else self._request_json
     return fn('POST', '/api/pull', json={
       'model': model,
@@ -82,7 +117,12 @@ def pull(self, model, insecure=False, stream=False):
       'stream': stream,
     })
 
-  def push(self, model, insecure=False, stream=False):
+  def push(
+      self,
+      model: str,
+      insecure: bool = False,
+      stream: bool = False,
+    ) -> Union[Mapping[str, Any], Iterator[Mapping[str, Any]]]:
     fn = self._stream if stream else self._request_json
     return fn('POST', '/api/push', json={
       'model': model,
@@ -90,9 +130,15 @@ def push(self, model, insecure=False, stream=False):
       'stream': stream,
     })
 
-  def create(self, model, path=None, modelfile=None, stream=False):
-    if (path := _as_path(path)) and path.exists():
-      modelfile = self._parse_modelfile(path.read_text(), base=path.parent)
+  def create(
+      self,
+      model: str,
+      path: Optional[Union[str, PathLike]] = None,
+      modelfile: Optional[str] = None,
+      stream: bool = False,
+    ) -> Union[Mapping[str, Any], Iterator[Mapping[str, Any]]]:
+    if (realpath := _as_path(path)) and realpath.exists():
+      modelfile = self._parse_modelfile(realpath.read_text(), base=realpath.parent)
     elif modelfile:
       modelfile = self._parse_modelfile(modelfile)
     else:
@@ -105,7 +151,7 @@ def create(self, model, path=None, modelfile=None, stream=False):
       'stream': stream,
     })
 
-  def _parse_modelfile(self, modelfile, base=None):
+  def _parse_modelfile(self, modelfile: str, base: Optional[Path] = None) -> str:
     base = Path.cwd() if base is None else base
 
     out = io.StringIO()
@@ -120,7 +166,7 @@ def _parse_modelfile(self, modelfile, base=None):
       print(command, args, file=out)
     return out.getvalue()
 
-  def _create_blob(self, path):
+  def _create_blob(self, path: Union[str, Path]) -> str:
     sha256sum = sha256()
     with open(path, 'rb') as r:
       while True:
@@ -142,36 +188,36 @@ def _create_blob(self, path):
 
     return digest
 
-  def delete(self, model):
-    response = self._request_json('DELETE', '/api/delete', json={'model': model})
+  def delete(self, model: str) -> Mapping[str, Any]:
+    response = self._request('DELETE', '/api/delete', json={'model': model})
     return {'status': 'success' if response.status_code == 200 else 'error'}
 
-  def list(self):
+  def list(self) -> Mapping[str, Any]:
     return self._request_json('GET', '/api/tags').get('models', [])
 
-  def copy(self, source, target):
-    response = self._request_json('POST', '/api/copy', json={'source': source, 'destination': target})
+  def copy(self, source: str, target: str) -> Mapping[str, Any]:
+    response = self._request('POST', '/api/copy', json={'source': source, 'destination': target})
     return {'status': 'success' if response.status_code == 200 else 'error'}
 
-  def show(self, model):
+  def show(self, model: str) -> Mapping[str, Any]:
     return self._request_json('GET', '/api/show', json={'model': model})
 
 
 class AsyncClient(BaseClient):
 
-  def __init__(self, base='http://localhost:11434'):
+  def __init__(self, base='http://localhost:11434') -> None:
     super().__init__(httpx.AsyncClient, base)
 
-  async def _request(self, method, url, **kwargs):
+  async def _request(self, method: str, url: str, **kwargs) -> httpx.Response:
     response = await self._client.request(method, url, **kwargs)
     response.raise_for_status()
     return response
 
-  async def _request_json(self, method, url, **kwargs):
+  async def _request_json(self, method: str, url: str, **kwargs) -> Mapping[str, Any]:
     response = await self._request(method, url, **kwargs)
     return response.json()
 
-  async def _stream(self, method, url, **kwargs):
+  async def _stream(self, method: str, url: str, **kwargs) -> AsyncIterator[Mapping[str, Any]]:
     async def inner():
       async with self._client.stream(method, url, **kwargs) as r:
         async for line in r.aiter_lines():
@@ -181,7 +227,19 @@ async def inner():
           yield part
     return inner()
 
-  async def generate(self, model='', prompt='', system='', template='', context=None, stream=False, raw=False, format='', images=None, options=None):
+  async def generate(
+      self,
+      model: str = '',
+      prompt: str = '',
+      system: str = '',
+      template: str = '',
+      context: Optional[List[int]] = None,
+      stream: bool = False,
+      raw: bool = False,
+      format: str = '',
+      images: Optional[List[AnyStr]] = None,
+      options: Optional[Options] = None,
+    ) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]:
     if not model:
       raise Exception('must provide a model')
 
@@ -199,7 +257,14 @@ async def generate(self, model='', prompt='', system='', template='', context=No
       'options': options or {},
     })
 
-  async def chat(self, model='', messages=None, stream=False, format='', options=None):
+  async def chat(
+      self,
+      model: str = '',
+      messages: Optional[List[Message]] = None,
+      stream: bool = False,
+      format: str = '',
+      options: Optional[Options] = None,
+    ) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]:
     if not model:
       raise Exception('must provide a model')
 
@@ -222,7 +287,12 @@ async def chat(self, model='', messages=None, stream=False, format='', options=N
       'options': options or {},
     })
 
-  async def pull(self, model, insecure=False, stream=False):
+  async def pull(
+      self,
+      model: str,
+      insecure: bool = False,
+      stream: bool = False,
+    ) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]:
     fn = self._stream if stream else self._request_json
     return await fn('POST', '/api/pull', json={
       'model': model,
@@ -230,7 +300,12 @@ async def pull(self, model, insecure=False, stream=False):
       'stream': stream,
     })
 
-  async def push(self, model, insecure=False, stream=False):
+  async def push(
+      self,
+      model: str,
+      insecure: bool = False,
+      stream: bool = False,
+    ) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]:
     fn = self._stream if stream else self._request_json
     return await fn('POST', '/api/push', json={
       'model': model,
@@ -238,9 +313,15 @@ async def push(self, model, insecure=False, stream=False):
       'stream': stream,
     })
 
-  async def create(self, model, path=None, modelfile=None, stream=False):
-    if (path := _as_path(path)) and path.exists():
-      modelfile = await self._parse_modelfile(path.read_text(), base=path.parent)
+  async def create(
+      self,
+      model: str,
+      path: Optional[Union[str, PathLike]] = None,
+      modelfile: Optional[str] = None,
+      stream: bool = False,
+    ) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]:
+    if (realpath := _as_path(path)) and realpath.exists():
+      modelfile = await self._parse_modelfile(realpath.read_text(), base=realpath.parent)
     elif modelfile:
       modelfile = await self._parse_modelfile(modelfile)
     else:
@@ -253,7 +334,7 @@ async def create(self, model, path=None, modelfile=None, stream=False):
       'stream': stream,
     })
 
-  async def _parse_modelfile(self, modelfile, base=None):
+  async def _parse_modelfile(self, modelfile: str, base: Optional[Path] = None) -> str:
     base = Path.cwd() if base is None else base
 
     out = io.StringIO()
@@ -268,7 +349,7 @@ async def _parse_modelfile(self, modelfile, base=None):
       print(command, args, file=out)
     return out.getvalue()
 
-  async def _create_blob(self, path):
+  async def _create_blob(self, path: Union[str, Path]) -> str:
     sha256sum = sha256()
     with open(path, 'rb') as r:
       while True:
@@ -297,23 +378,23 @@ async def upload_bytes():
 
     return digest
 
-  async def delete(self, model):
-    response = await self._request_json('DELETE', '/api/delete', json={'model': model})
+  async def delete(self, model: str) -> Mapping[str, Any]:
+    response = await self._request('DELETE', '/api/delete', json={'model': model})
     return {'status': 'success' if response.status_code == 200 else 'error'}
 
-  async def list(self):
+  async def list(self) -> Mapping[str, Any]:
     response = await self._request_json('GET', '/api/tags')
     return response.get('models', [])
 
-  async def copy(self, source, target):
-    response = await self._request_json('POST', '/api/copy', json={'source': source, 'destination': target})
+  async def copy(self, source: str, target: str) -> Mapping[str, Any]:
+    response = await self._request('POST', '/api/copy', json={'source': source, 'destination': target})
     return {'status': 'success' if response.status_code == 200 else 'error'}
 
-  async def show(self, model):
+  async def show(self, model: str) -> Mapping[str, Any]:
     return await self._request_json('GET', '/api/show', json={'model': model})
 
 
-def _encode_image(image):
+def _encode_image(image) -> str:
   if p := _as_path(image):
     b64 = b64encode(p.read_bytes())
   elif b := _as_bytesio(image):
@@ -324,12 +405,13 @@ def _encode_image(image):
   return b64.decode('utf-8')
 
 
-def _as_path(s):
+def _as_path(s: Optional[Union[str, PathLike]]) -> Union[Path, None]:
   if isinstance(s, str) or isinstance(s, Path):
     return Path(s)
   return None
 
-def _as_bytesio(s):
+
+def _as_bytesio(s: Any) -> Union[io.BytesIO, None]:
   if isinstance(s, io.BytesIO):
     return s
   elif isinstance(s, bytes):
diff --git a/ollama/_types.py b/ollama/_types.py
new file mode 100644
index 0000000..7fe3bf0
--- /dev/null
+++ b/ollama/_types.py
@@ -0,0 +1,52 @@
+from typing import Any, TypedDict, List
+
+import sys
+if sys.version_info < (3, 11):
+  from typing_extensions import NotRequired
+else:
+  from typing import NotRequired
+
+
+class Message(TypedDict):
+  role: str
+  content: str
+  images: NotRequired[List[Any]]
+
+
+class Options(TypedDict, total=False):
+  # load time options
+  numa: bool
+  num_ctx: int
+  num_batch: int
+  num_gqa: int
+  num_gpu: int
+  main_gpu: int
+  low_vram: bool
+  f16_kv: bool
+  logits_all: bool
+  vocab_only: bool
+  use_mmap: bool
+  use_mlock: bool
+  embedding_only: bool
+  rope_frequency_base: float
+  rope_frequency_scale: float
+  num_thread: int
+
+  # runtime options
+  num_keep: int
+  seed: int
+  num_predict: int
+  top_k: int
+  top_p: float
+  tfs_z: float
+  typical_p: float
+  repeat_last_n: int
+  temperature: float
+  repeat_penalty: float
+  presence_penalty: float
+  frequency_penalty: float
+  mirostat: int
+  mirostat_tau: float
+  mirostat_eta: float
+  penalize_newline: bool
+  stop: List[str]

From 9d93f70806a09e105f70fa2afed0ce693d32b039 Mon Sep 17 00:00:00 2001
From: Michael Yang 
Date: Thu, 21 Dec 2023 12:25:10 -0800
Subject: [PATCH 09/10] publish workflow

---
 .github/workflows/publish.yaml | 24 ++++++++++++++++++++++++
 pyproject.toml                 | 13 ++++++++-----
 2 files changed, 32 insertions(+), 5 deletions(-)
 create mode 100644 .github/workflows/publish.yaml

diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
new file mode 100644
index 0000000..a3728ca
--- /dev/null
+++ b/.github/workflows/publish.yaml
@@ -0,0 +1,24 @@
+name: publish
+
+on:
+  release:
+    types:
+      - created
+
+jobs:
+  publish:
+    runs-on: ubuntu-latest
+    environment: release
+    permissions:
+      id-token: write
+    steps:
+      - uses: actions/checkout@v4
+      - run: pipx install poetry
+      - uses: actions/setup-python@v5
+        with:
+          cache: poetry
+      - run: |
+          poetry version -- ${GIT_REF_NAME#v}
+          poetry build
+      - uses: pypa/gh-action-pypi-publish@release/v1
+      - run: gh release upload $GIT_REF_NAME dist/*
diff --git a/pyproject.toml b/pyproject.toml
index c41e346..be5ddac 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,9 +1,12 @@
 [tool.poetry]
 name = "ollama"
-version = "0.1.0"
+version = "0.0.0"
 description = "The official Python client for Ollama."
 authors = ["Ollama "]
+license = "MIT"
 readme = "README.md"
+homepage = "https://ollama.ai"
+repository = "https://github.com/jmorganca/ollama-python"
 
 [tool.poetry.dependencies]
 python = "^3.8"
@@ -17,6 +20,10 @@ pytest-httpserver = "^1.0.8"
 pillow = "^10.1.0"
 ruff = "^0.1.8"
 
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
+
 [tool.ruff]
 indent-width = 2
 
@@ -27,7 +34,3 @@ indent-style = "space"
 [tool.ruff.lint]
 select = ["E", "F", "B"]
 ignore = ["E501"]
-
-[build-system]
-requires = ["poetry-core"]
-build-backend = "poetry.core.masonry.api"

From 47c934c74b083fa650a0b5246b601f2beccc7ebf Mon Sep 17 00:00:00 2001
From: Michael Yang 
Date: Thu, 21 Dec 2023 14:21:02 -0800
Subject: [PATCH 10/10] format

---
 examples/simple-fill-in-middle/main.py |   4 +-
 ollama/_client.py                      | 327 +++++++------
 ollama/_types.py                       |   1 +
 pyproject.toml                         |   1 +
 tests/test_client.py                   | 635 +++++++++++++++----------
 5 files changed, 576 insertions(+), 392 deletions(-)

diff --git a/examples/simple-fill-in-middle/main.py b/examples/simple-fill-in-middle/main.py
index ca14d05..67d7a74 100644
--- a/examples/simple-fill-in-middle/main.py
+++ b/examples/simple-fill-in-middle/main.py
@@ -3,9 +3,9 @@
 prefix = '''def remove_non_ascii(s: str) -> str:
     """ '''
 
-suffix = '''
+suffix = """
     return result
-'''
+"""
 
 
 response = generate(
diff --git a/ollama/_client.py b/ollama/_client.py
index 9e5ee32..d0fa30f 100644
--- a/ollama/_client.py
+++ b/ollama/_client.py
@@ -9,6 +9,7 @@
 from typing import Any, AnyStr, Union, Optional, List, Mapping
 
 import sys
+
 if sys.version_info < (3, 9):
   from typing import Iterator, AsyncIterator
 else:
@@ -18,13 +19,11 @@
 
 
 class BaseClient:
-
   def __init__(self, client, base_url='http://127.0.0.1:11434') -> None:
     self._client = client(base_url=base_url, follow_redirects=True, timeout=None)
 
 
 class Client(BaseClient):
-
   def __init__(self, base='http://localhost:11434') -> None:
     super().__init__(httpx.Client, base)
 
@@ -45,43 +44,47 @@ def _stream(self, method: str, url: str, **kwargs) -> Iterator[Mapping[str, Any]
         yield part
 
   def generate(
-      self,
-      model: str = '',
-      prompt: str = '',
-      system: str = '',
-      template: str = '',
-      context: Optional[List[int]] = None,
-      stream: bool = False,
-      raw: bool = False,
-      format: str = '',
-      images: Optional[List[AnyStr]] = None,
-      options: Optional[Options] = None,
-    ) -> Union[Mapping[str, Any], Iterator[Mapping[str, Any]]]:
+    self,
+    model: str = '',
+    prompt: str = '',
+    system: str = '',
+    template: str = '',
+    context: Optional[List[int]] = None,
+    stream: bool = False,
+    raw: bool = False,
+    format: str = '',
+    images: Optional[List[AnyStr]] = None,
+    options: Optional[Options] = None,
+  ) -> Union[Mapping[str, Any], Iterator[Mapping[str, Any]]]:
     if not model:
       raise Exception('must provide a model')
 
     fn = self._stream if stream else self._request_json
-    return fn('POST', '/api/generate', json={
-      'model': model,
-      'prompt': prompt,
-      'system': system,
-      'template': template,
-      'context': context or [],
-      'stream': stream,
-      'raw': raw,
-      'images': [_encode_image(image) for image in images or []],
-      'format': format,
-      'options': options or {},
-    })
+    return fn(
+      'POST',
+      '/api/generate',
+      json={
+        'model': model,
+        'prompt': prompt,
+        'system': system,
+        'template': template,
+        'context': context or [],
+        'stream': stream,
+        'raw': raw,
+        'images': [_encode_image(image) for image in images or []],
+        'format': format,
+        'options': options or {},
+      },
+    )
 
   def chat(
-      self,
-      model: str = '',
-      messages: Optional[List[Message]] = None,
-      stream: bool = False,
-      format: str = '',
-      options: Optional[Options] = None,
-    ) -> Union[Mapping[str, Any], Iterator[Mapping[str, Any]]]:
+    self,
+    model: str = '',
+    messages: Optional[List[Message]] = None,
+    stream: bool = False,
+    format: str = '',
+    options: Optional[Options] = None,
+  ) -> Union[Mapping[str, Any], Iterator[Mapping[str, Any]]]:
     if not model:
       raise Exception('must provide a model')
 
@@ -96,47 +99,59 @@ def chat(
         message['images'] = [_encode_image(image) for image in images]
 
     fn = self._stream if stream else self._request_json
-    return fn('POST', '/api/chat', json={
-      'model': model,
-      'messages': messages,
-      'stream': stream,
-      'format': format,
-      'options': options or {},
-    })
+    return fn(
+      'POST',
+      '/api/chat',
+      json={
+        'model': model,
+        'messages': messages,
+        'stream': stream,
+        'format': format,
+        'options': options or {},
+      },
+    )
 
   def pull(
-      self,
-      model: str,
-      insecure: bool = False,
-      stream: bool = False,
-    ) -> Union[Mapping[str, Any], Iterator[Mapping[str, Any]]]:
+    self,
+    model: str,
+    insecure: bool = False,
+    stream: bool = False,
+  ) -> Union[Mapping[str, Any], Iterator[Mapping[str, Any]]]:
     fn = self._stream if stream else self._request_json
-    return fn('POST', '/api/pull', json={
-      'model': model,
-      'insecure': insecure,
-      'stream': stream,
-    })
+    return fn(
+      'POST',
+      '/api/pull',
+      json={
+        'model': model,
+        'insecure': insecure,
+        'stream': stream,
+      },
+    )
 
   def push(
-      self,
-      model: str,
-      insecure: bool = False,
-      stream: bool = False,
-    ) -> Union[Mapping[str, Any], Iterator[Mapping[str, Any]]]:
+    self,
+    model: str,
+    insecure: bool = False,
+    stream: bool = False,
+  ) -> Union[Mapping[str, Any], Iterator[Mapping[str, Any]]]:
     fn = self._stream if stream else self._request_json
-    return fn('POST', '/api/push', json={
-      'model': model,
-      'insecure': insecure,
-      'stream': stream,
-    })
+    return fn(
+      'POST',
+      '/api/push',
+      json={
+        'model': model,
+        'insecure': insecure,
+        'stream': stream,
+      },
+    )
 
   def create(
-      self,
-      model: str,
-      path: Optional[Union[str, PathLike]] = None,
-      modelfile: Optional[str] = None,
-      stream: bool = False,
-    ) -> Union[Mapping[str, Any], Iterator[Mapping[str, Any]]]:
+    self,
+    model: str,
+    path: Optional[Union[str, PathLike]] = None,
+    modelfile: Optional[str] = None,
+    stream: bool = False,
+  ) -> Union[Mapping[str, Any], Iterator[Mapping[str, Any]]]:
     if (realpath := _as_path(path)) and realpath.exists():
       modelfile = self._parse_modelfile(realpath.read_text(), base=realpath.parent)
     elif modelfile:
@@ -145,11 +160,15 @@ def create(
       raise Exception('must provide either path or modelfile')
 
     fn = self._stream if stream else self._request_json
-    return fn('POST', '/api/create', json={
-      'model': model,
-      'modelfile': modelfile,
-      'stream': stream,
-    })
+    return fn(
+      'POST',
+      '/api/create',
+      json={
+        'model': model,
+        'modelfile': modelfile,
+        'stream': stream,
+      },
+    )
 
   def _parse_modelfile(self, modelfile: str, base: Optional[Path] = None) -> str:
     base = Path.cwd() if base is None else base
@@ -170,7 +189,7 @@ def _create_blob(self, path: Union[str, Path]) -> str:
     sha256sum = sha256()
     with open(path, 'rb') as r:
       while True:
-        chunk = r.read(32*1024)
+        chunk = r.read(32 * 1024)
         if not chunk:
           break
         sha256sum.update(chunk)
@@ -204,7 +223,6 @@ def show(self, model: str) -> Mapping[str, Any]:
 
 
 class AsyncClient(BaseClient):
-
   def __init__(self, base='http://localhost:11434') -> None:
     super().__init__(httpx.AsyncClient, base)
 
@@ -225,46 +243,51 @@ async def inner():
           if e := part.get('error'):
             raise Exception(e)
           yield part
+
     return inner()
 
   async def generate(
-      self,
-      model: str = '',
-      prompt: str = '',
-      system: str = '',
-      template: str = '',
-      context: Optional[List[int]] = None,
-      stream: bool = False,
-      raw: bool = False,
-      format: str = '',
-      images: Optional[List[AnyStr]] = None,
-      options: Optional[Options] = None,
-    ) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]:
+    self,
+    model: str = '',
+    prompt: str = '',
+    system: str = '',
+    template: str = '',
+    context: Optional[List[int]] = None,
+    stream: bool = False,
+    raw: bool = False,
+    format: str = '',
+    images: Optional[List[AnyStr]] = None,
+    options: Optional[Options] = None,
+  ) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]:
     if not model:
       raise Exception('must provide a model')
 
     fn = self._stream if stream else self._request_json
-    return await fn('POST', '/api/generate', json={
-      'model': model,
-      'prompt': prompt,
-      'system': system,
-      'template': template,
-      'context': context or [],
-      'stream': stream,
-      'raw': raw,
-      'images': [_encode_image(image) for image in images or []],
-      'format': format,
-      'options': options or {},
-    })
+    return await fn(
+      'POST',
+      '/api/generate',
+      json={
+        'model': model,
+        'prompt': prompt,
+        'system': system,
+        'template': template,
+        'context': context or [],
+        'stream': stream,
+        'raw': raw,
+        'images': [_encode_image(image) for image in images or []],
+        'format': format,
+        'options': options or {},
+      },
+    )
 
   async def chat(
-      self,
-      model: str = '',
-      messages: Optional[List[Message]] = None,
-      stream: bool = False,
-      format: str = '',
-      options: Optional[Options] = None,
-    ) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]:
+    self,
+    model: str = '',
+    messages: Optional[List[Message]] = None,
+    stream: bool = False,
+    format: str = '',
+    options: Optional[Options] = None,
+  ) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]:
     if not model:
       raise Exception('must provide a model')
 
@@ -279,47 +302,59 @@ async def chat(
         message['images'] = [_encode_image(image) for image in images]
 
     fn = self._stream if stream else self._request_json
-    return await fn('POST', '/api/chat', json={
-      'model': model,
-      'messages': messages,
-      'stream': stream,
-      'format': format,
-      'options': options or {},
-    })
+    return await fn(
+      'POST',
+      '/api/chat',
+      json={
+        'model': model,
+        'messages': messages,
+        'stream': stream,
+        'format': format,
+        'options': options or {},
+      },
+    )
 
   async def pull(
-      self,
-      model: str,
-      insecure: bool = False,
-      stream: bool = False,
-    ) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]:
+    self,
+    model: str,
+    insecure: bool = False,
+    stream: bool = False,
+  ) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]:
     fn = self._stream if stream else self._request_json
-    return await fn('POST', '/api/pull', json={
-      'model': model,
-      'insecure': insecure,
-      'stream': stream,
-    })
+    return await fn(
+      'POST',
+      '/api/pull',
+      json={
+        'model': model,
+        'insecure': insecure,
+        'stream': stream,
+      },
+    )
 
   async def push(
-      self,
-      model: str,
-      insecure: bool = False,
-      stream: bool = False,
-    ) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]:
+    self,
+    model: str,
+    insecure: bool = False,
+    stream: bool = False,
+  ) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]:
     fn = self._stream if stream else self._request_json
-    return await fn('POST', '/api/push', json={
-      'model': model,
-      'insecure': insecure,
-      'stream': stream,
-    })
+    return await fn(
+      'POST',
+      '/api/push',
+      json={
+        'model': model,
+        'insecure': insecure,
+        'stream': stream,
+      },
+    )
 
   async def create(
-      self,
-      model: str,
-      path: Optional[Union[str, PathLike]] = None,
-      modelfile: Optional[str] = None,
-      stream: bool = False,
-    ) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]:
+    self,
+    model: str,
+    path: Optional[Union[str, PathLike]] = None,
+    modelfile: Optional[str] = None,
+    stream: bool = False,
+  ) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]:
     if (realpath := _as_path(path)) and realpath.exists():
       modelfile = await self._parse_modelfile(realpath.read_text(), base=realpath.parent)
     elif modelfile:
@@ -328,11 +363,15 @@ async def create(
       raise Exception('must provide either path or modelfile')
 
     fn = self._stream if stream else self._request_json
-    return await fn('POST', '/api/create', json={
-      'model': model,
-      'modelfile': modelfile,
-      'stream': stream,
-    })
+    return await fn(
+      'POST',
+      '/api/create',
+      json={
+        'model': model,
+        'modelfile': modelfile,
+        'stream': stream,
+      },
+    )
 
   async def _parse_modelfile(self, modelfile: str, base: Optional[Path] = None) -> str:
     base = Path.cwd() if base is None else base
@@ -353,7 +392,7 @@ async def _create_blob(self, path: Union[str, Path]) -> str:
     sha256sum = sha256()
     with open(path, 'rb') as r:
       while True:
-        chunk = r.read(32*1024)
+        chunk = r.read(32 * 1024)
         if not chunk:
           break
         sha256sum.update(chunk)
@@ -369,7 +408,7 @@ async def _create_blob(self, path: Union[str, Path]) -> str:
       async def upload_bytes():
         with open(path, 'rb') as r:
           while True:
-            chunk = r.read(32*1024)
+            chunk = r.read(32 * 1024)
             if not chunk:
               break
             yield chunk
diff --git a/ollama/_types.py b/ollama/_types.py
index 7fe3bf0..d263269 100644
--- a/ollama/_types.py
+++ b/ollama/_types.py
@@ -1,6 +1,7 @@
 from typing import Any, TypedDict, List
 
 import sys
+
 if sys.version_info < (3, 11):
   from typing_extensions import NotRequired
 else:
diff --git a/pyproject.toml b/pyproject.toml
index be5ddac..76b5e15 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,6 +25,7 @@ requires = ["poetry-core"]
 build-backend = "poetry.core.masonry.api"
 
 [tool.ruff]
+line-length = 999
 indent-width = 2
 
 [tool.ruff.format]
diff --git a/tests/test_client.py b/tests/test_client.py
index 31b3160..fe151dc 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -21,19 +21,25 @@ def match(self, uri):
 
 
 def test_client_chat(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/chat', method='POST', json={
-    'model': 'dummy',
-    'messages': [{'role': 'user', 'content': 'Why is the sky blue?'}],
-    'stream': False,
-    'format': '',
-    'options': {},
-  }).respond_with_json({
-     'model': 'dummy',
-     'message': {
-       'role': 'assistant',
-       'content': "I don't know.",
-     },
-  })
+  httpserver.expect_ordered_request(
+    '/api/chat',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'messages': [{'role': 'user', 'content': 'Why is the sky blue?'}],
+      'stream': False,
+      'format': '',
+      'options': {},
+    },
+  ).respond_with_json(
+    {
+      'model': 'dummy',
+      'message': {
+        'role': 'assistant',
+        'content': "I don't know.",
+      },
+    }
+  )
 
   client = Client(httpserver.url_for('/'))
   response = client.chat('dummy', messages=[{'role': 'user', 'content': 'Why is the sky blue?'}])
@@ -46,22 +52,32 @@ def test_client_chat_stream(httpserver: HTTPServer):
   def stream_handler(_: Request):
     def generate():
       for message in ['I ', "don't ", 'know.']:
-        yield json.dumps({
-          'model': 'dummy',
-          'message': {
-            'role': 'assistant',
-            'content': message,
-          },
-        }) + '\n'
+        yield (
+          json.dumps(
+            {
+              'model': 'dummy',
+              'message': {
+                'role': 'assistant',
+                'content': message,
+              },
+            }
+          )
+          + '\n'
+        )
+
     return Response(generate())
 
-  httpserver.expect_ordered_request('/api/chat', method='POST', json={
-    'model': 'dummy',
-    'messages': [{'role': 'user', 'content': 'Why is the sky blue?'}],
-    'stream': True,
-    'format': '',
-    'options': {},
-  }).respond_with_handler(stream_handler)
+  httpserver.expect_ordered_request(
+    '/api/chat',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'messages': [{'role': 'user', 'content': 'Why is the sky blue?'}],
+      'stream': True,
+      'format': '',
+      'options': {},
+    },
+  ).respond_with_handler(stream_handler)
 
   client = Client(httpserver.url_for('/'))
   response = client.chat('dummy', messages=[{'role': 'user', 'content': 'Why is the sky blue?'}], stream=True)
@@ -71,25 +87,31 @@ def generate():
 
 
 def test_client_chat_images(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/chat', method='POST', json={
-    'model': 'dummy',
-    'messages': [
-      {
-        'role': 'user',
-        'content': 'Why is the sky blue?',
-        'images': ['iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC'],
+  httpserver.expect_ordered_request(
+    '/api/chat',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'messages': [
+        {
+          'role': 'user',
+          'content': 'Why is the sky blue?',
+          'images': ['iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC'],
+        },
+      ],
+      'stream': False,
+      'format': '',
+      'options': {},
+    },
+  ).respond_with_json(
+    {
+      'model': 'dummy',
+      'message': {
+        'role': 'assistant',
+        'content': "I don't know.",
       },
-    ],
-    'stream': False,
-    'format': '',
-    'options': {},
-  }).respond_with_json({
-     'model': 'dummy',
-     'message': {
-       'role': 'assistant',
-       'content': "I don't know.",
-     },
-  })
+    }
+  )
 
   client = Client(httpserver.url_for('/'))
 
@@ -102,21 +124,27 @@ def test_client_chat_images(httpserver: HTTPServer):
 
 
 def test_client_generate(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/generate', method='POST', json={
-    'model': 'dummy',
-    'prompt': 'Why is the sky blue?',
-    'system': '',
-    'template': '',
-    'context': [],
-    'stream': False,
-    'raw': False,
-    'images': [],
-    'format': '',
-    'options': {},
-  }).respond_with_json({
-    'model': 'dummy',
-    'response': 'Because it is.',
-  })
+  httpserver.expect_ordered_request(
+    '/api/generate',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'prompt': 'Why is the sky blue?',
+      'system': '',
+      'template': '',
+      'context': [],
+      'stream': False,
+      'raw': False,
+      'images': [],
+      'format': '',
+      'options': {},
+    },
+  ).respond_with_json(
+    {
+      'model': 'dummy',
+      'response': 'Because it is.',
+    }
+  )
 
   client = Client(httpserver.url_for('/'))
   response = client.generate('dummy', 'Why is the sky blue?')
@@ -128,24 +156,34 @@ def test_client_generate_stream(httpserver: HTTPServer):
   def stream_handler(_: Request):
     def generate():
       for message in ['Because ', 'it ', 'is.']:
-        yield json.dumps({
-          'model': 'dummy',
-          'response': message,
-        }) + '\n'
+        yield (
+          json.dumps(
+            {
+              'model': 'dummy',
+              'response': message,
+            }
+          )
+          + '\n'
+        )
+
     return Response(generate())
 
-  httpserver.expect_ordered_request('/api/generate', method='POST', json={
-    'model': 'dummy',
-    'prompt': 'Why is the sky blue?',
-    'system': '',
-    'template': '',
-    'context': [],
-    'stream': True,
-    'raw': False,
-    'images': [],
-    'format': '',
-    'options': {},
-  }).respond_with_handler(stream_handler)
+  httpserver.expect_ordered_request(
+    '/api/generate',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'prompt': 'Why is the sky blue?',
+      'system': '',
+      'template': '',
+      'context': [],
+      'stream': True,
+      'raw': False,
+      'images': [],
+      'format': '',
+      'options': {},
+    },
+  ).respond_with_handler(stream_handler)
 
   client = Client(httpserver.url_for('/'))
   response = client.generate('dummy', 'Why is the sky blue?', stream=True)
@@ -155,21 +193,27 @@ def generate():
 
 
 def test_client_generate_images(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/generate', method='POST', json={
-    'model': 'dummy',
-    'prompt': 'Why is the sky blue?',
-    'system': '',
-    'template': '',
-    'context': [],
-    'stream': False,
-    'raw': False,
-    'images': ['iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC'],
-    'format': '',
-    'options': {},
-  }).respond_with_json({
-    'model': 'dummy',
-    'response': 'Because it is.',
-  })
+  httpserver.expect_ordered_request(
+    '/api/generate',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'prompt': 'Why is the sky blue?',
+      'system': '',
+      'template': '',
+      'context': [],
+      'stream': False,
+      'raw': False,
+      'images': ['iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC'],
+      'format': '',
+      'options': {},
+    },
+  ).respond_with_json(
+    {
+      'model': 'dummy',
+      'response': 'Because it is.',
+    }
+  )
 
   client = Client(httpserver.url_for('/'))
 
@@ -181,13 +225,19 @@ def test_client_generate_images(httpserver: HTTPServer):
 
 
 def test_client_pull(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/pull', method='POST', json={
-    'model': 'dummy',
-    'insecure': False,
-    'stream': False,
-  }).respond_with_json({
-    'status': 'success',
-  })
+  httpserver.expect_ordered_request(
+    '/api/pull',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'insecure': False,
+      'stream': False,
+    },
+  ).respond_with_json(
+    {
+      'status': 'success',
+    }
+  )
 
   client = Client(httpserver.url_for('/'))
   response = client.pull('dummy')
@@ -202,13 +252,18 @@ def generate():
       yield json.dumps({'status': 'writing manifest'}) + '\n'
       yield json.dumps({'status': 'removing any unused layers'}) + '\n'
       yield json.dumps({'status': 'success'}) + '\n'
+
     return Response(generate())
 
-  httpserver.expect_ordered_request('/api/pull', method='POST', json={
-    'model': 'dummy',
-    'insecure': False,
-    'stream': True,
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/pull',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'insecure': False,
+      'stream': True,
+    },
+  ).respond_with_json({})
 
   client = Client(httpserver.url_for('/'))
   response = client.pull('dummy', stream=True)
@@ -216,11 +271,15 @@ def generate():
 
 
 def test_client_push(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/push', method='POST', json={
-    'model': 'dummy',
-    'insecure': False,
-    'stream': False,
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/push',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'insecure': False,
+      'stream': False,
+    },
+  ).respond_with_json({})
 
   client = Client(httpserver.url_for('/'))
   response = client.push('dummy')
@@ -228,11 +287,15 @@ def test_client_push(httpserver: HTTPServer):
 
 
 def test_client_push_stream(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/push', method='POST', json={
-    'model': 'dummy',
-    'insecure': False,
-    'stream': True,
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/push',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'insecure': False,
+      'stream': True,
+    },
+  ).respond_with_json({})
 
   client = Client(httpserver.url_for('/'))
   response = client.push('dummy', stream=True)
@@ -241,11 +304,15 @@ def test_client_push_stream(httpserver: HTTPServer):
 
 def test_client_create_path(httpserver: HTTPServer):
   httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200))
-  httpserver.expect_ordered_request('/api/create', method='POST', json={
-    'model': 'dummy',
-    'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n',
-    'stream': False,
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/create',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n',
+      'stream': False,
+    },
+  ).respond_with_json({})
 
   client = Client(httpserver.url_for('/'))
 
@@ -260,11 +327,15 @@ def test_client_create_path(httpserver: HTTPServer):
 
 def test_client_create_path_relative(httpserver: HTTPServer):
   httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200))
-  httpserver.expect_ordered_request('/api/create', method='POST', json={
-    'model': 'dummy',
-    'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n',
-    'stream': False,
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/create',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n',
+      'stream': False,
+    },
+  ).respond_with_json({})
 
   client = Client(httpserver.url_for('/'))
 
@@ -288,11 +359,15 @@ def userhomedir():
 
 def test_client_create_path_user_home(httpserver: HTTPServer, userhomedir):
   httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200))
-  httpserver.expect_ordered_request('/api/create', method='POST', json={
-    'model': 'dummy',
-    'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n',
-    'stream': False,
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/create',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n',
+      'stream': False,
+    },
+  ).respond_with_json({})
 
   client = Client(httpserver.url_for('/'))
 
@@ -307,11 +382,15 @@ def test_client_create_path_user_home(httpserver: HTTPServer, userhomedir):
 
 def test_client_create_modelfile(httpserver: HTTPServer):
   httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200))
-  httpserver.expect_ordered_request('/api/create', method='POST', json={
-    'model': 'dummy',
-    'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n',
-    'stream': False,
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/create',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n',
+      'stream': False,
+    },
+  ).respond_with_json({})
 
   client = Client(httpserver.url_for('/'))
 
@@ -321,11 +400,15 @@ def test_client_create_modelfile(httpserver: HTTPServer):
 
 
 def test_client_create_from_library(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/create', method='POST', json={
-    'model': 'dummy',
-    'modelfile': 'FROM llama2\n',
-    'stream': False,
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/create',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'modelfile': 'FROM llama2\n',
+      'stream': False,
+    },
+  ).respond_with_json({})
 
   client = Client(httpserver.url_for('/'))
 
@@ -356,13 +439,17 @@ def test_client_create_blob_exists(httpserver: HTTPServer):
 
 @pytest.mark.asyncio
 async def test_async_client_chat(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/chat', method='POST', json={
-    'model': 'dummy',
-    'messages': [{'role': 'user', 'content': 'Why is the sky blue?'}],
-    'stream': False,
-    'format': '',
-    'options': {},
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/chat',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'messages': [{'role': 'user', 'content': 'Why is the sky blue?'}],
+      'stream': False,
+      'format': '',
+      'options': {},
+    },
+  ).respond_with_json({})
 
   client = AsyncClient(httpserver.url_for('/'))
   response = await client.chat('dummy', messages=[{'role': 'user', 'content': 'Why is the sky blue?'}])
@@ -371,13 +458,17 @@ async def test_async_client_chat(httpserver: HTTPServer):
 
 @pytest.mark.asyncio
 async def test_async_client_chat_stream(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/chat', method='POST', json={
-    'model': 'dummy',
-    'messages': [{'role': 'user', 'content': 'Why is the sky blue?'}],
-    'stream': True,
-    'format': '',
-    'options': {},
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/chat',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'messages': [{'role': 'user', 'content': 'Why is the sky blue?'}],
+      'stream': True,
+      'format': '',
+      'options': {},
+    },
+  ).respond_with_json({})
 
   client = AsyncClient(httpserver.url_for('/'))
   response = await client.chat('dummy', messages=[{'role': 'user', 'content': 'Why is the sky blue?'}], stream=True)
@@ -386,19 +477,23 @@ async def test_async_client_chat_stream(httpserver: HTTPServer):
 
 @pytest.mark.asyncio
 async def test_async_client_chat_images(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/chat', method='POST', json={
-    'model': 'dummy',
-    'messages': [
-      {
-        'role': 'user',
-        'content': 'Why is the sky blue?',
-        'images': ['iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC'],
-      },
-    ],
-    'stream': False,
-    'format': '',
-    'options': {},
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/chat',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'messages': [
+        {
+          'role': 'user',
+          'content': 'Why is the sky blue?',
+          'images': ['iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC'],
+        },
+      ],
+      'stream': False,
+      'format': '',
+      'options': {},
+    },
+  ).respond_with_json({})
 
   client = AsyncClient(httpserver.url_for('/'))
 
@@ -410,18 +505,22 @@ async def test_async_client_chat_images(httpserver: HTTPServer):
 
 @pytest.mark.asyncio
 async def test_async_client_generate(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/generate', method='POST', json={
-    'model': 'dummy',
-    'prompt': 'Why is the sky blue?',
-    'system': '',
-    'template': '',
-    'context': [],
-    'stream': False,
-    'raw': False,
-    'images': [],
-    'format': '',
-    'options': {},
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/generate',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'prompt': 'Why is the sky blue?',
+      'system': '',
+      'template': '',
+      'context': [],
+      'stream': False,
+      'raw': False,
+      'images': [],
+      'format': '',
+      'options': {},
+    },
+  ).respond_with_json({})
 
   client = AsyncClient(httpserver.url_for('/'))
   response = await client.generate('dummy', 'Why is the sky blue?')
@@ -430,18 +529,22 @@ async def test_async_client_generate(httpserver: HTTPServer):
 
 @pytest.mark.asyncio
 async def test_async_client_generate_stream(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/generate', method='POST', json={
-    'model': 'dummy',
-    'prompt': 'Why is the sky blue?',
-    'system': '',
-    'template': '',
-    'context': [],
-    'stream': True,
-    'raw': False,
-    'images': [],
-    'format': '',
-    'options': {},
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/generate',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'prompt': 'Why is the sky blue?',
+      'system': '',
+      'template': '',
+      'context': [],
+      'stream': True,
+      'raw': False,
+      'images': [],
+      'format': '',
+      'options': {},
+    },
+  ).respond_with_json({})
 
   client = AsyncClient(httpserver.url_for('/'))
   response = await client.generate('dummy', 'Why is the sky blue?', stream=True)
@@ -450,18 +553,22 @@ async def test_async_client_generate_stream(httpserver: HTTPServer):
 
 @pytest.mark.asyncio
 async def test_async_client_generate_images(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/generate', method='POST', json={
-    'model': 'dummy',
-    'prompt': 'Why is the sky blue?',
-    'system': '',
-    'template': '',
-    'context': [],
-    'stream': False,
-    'raw': False,
-    'images': ['iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC'],
-    'format': '',
-    'options': {},
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/generate',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'prompt': 'Why is the sky blue?',
+      'system': '',
+      'template': '',
+      'context': [],
+      'stream': False,
+      'raw': False,
+      'images': ['iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC'],
+      'format': '',
+      'options': {},
+    },
+  ).respond_with_json({})
 
   client = AsyncClient(httpserver.url_for('/'))
 
@@ -473,11 +580,15 @@ async def test_async_client_generate_images(httpserver: HTTPServer):
 
 @pytest.mark.asyncio
 async def test_async_client_pull(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/pull', method='POST', json={
-    'model': 'dummy',
-    'insecure': False,
-    'stream': False,
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/pull',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'insecure': False,
+      'stream': False,
+    },
+  ).respond_with_json({})
 
   client = AsyncClient(httpserver.url_for('/'))
   response = await client.pull('dummy')
@@ -486,11 +597,15 @@ async def test_async_client_pull(httpserver: HTTPServer):
 
 @pytest.mark.asyncio
 async def test_async_client_pull_stream(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/pull', method='POST', json={
-    'model': 'dummy',
-    'insecure': False,
-    'stream': True,
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/pull',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'insecure': False,
+      'stream': True,
+    },
+  ).respond_with_json({})
 
   client = AsyncClient(httpserver.url_for('/'))
   response = await client.pull('dummy', stream=True)
@@ -499,11 +614,15 @@ async def test_async_client_pull_stream(httpserver: HTTPServer):
 
 @pytest.mark.asyncio
 async def test_async_client_push(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/push', method='POST', json={
-    'model': 'dummy',
-    'insecure': False,
-    'stream': False,
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/push',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'insecure': False,
+      'stream': False,
+    },
+  ).respond_with_json({})
 
   client = AsyncClient(httpserver.url_for('/'))
   response = await client.push('dummy')
@@ -512,11 +631,15 @@ async def test_async_client_push(httpserver: HTTPServer):
 
 @pytest.mark.asyncio
 async def test_async_client_push_stream(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/push', method='POST', json={
-    'model': 'dummy',
-    'insecure': False,
-    'stream': True,
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/push',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'insecure': False,
+      'stream': True,
+    },
+  ).respond_with_json({})
 
   client = AsyncClient(httpserver.url_for('/'))
   response = await client.push('dummy', stream=True)
@@ -526,11 +649,15 @@ async def test_async_client_push_stream(httpserver: HTTPServer):
 @pytest.mark.asyncio
 async def test_async_client_create_path(httpserver: HTTPServer):
   httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200))
-  httpserver.expect_ordered_request('/api/create', method='POST', json={
-    'model': 'dummy',
-    'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n',
-    'stream': False,
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/create',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n',
+      'stream': False,
+    },
+  ).respond_with_json({})
 
   client = AsyncClient(httpserver.url_for('/'))
 
@@ -546,11 +673,15 @@ async def test_async_client_create_path(httpserver: HTTPServer):
 @pytest.mark.asyncio
 async def test_async_client_create_path_relative(httpserver: HTTPServer):
   httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200))
-  httpserver.expect_ordered_request('/api/create', method='POST', json={
-    'model': 'dummy',
-    'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n',
-    'stream': False,
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/create',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n',
+      'stream': False,
+    },
+  ).respond_with_json({})
 
   client = AsyncClient(httpserver.url_for('/'))
 
@@ -566,11 +697,15 @@ async def test_async_client_create_path_relative(httpserver: HTTPServer):
 @pytest.mark.asyncio
 async def test_async_client_create_path_user_home(httpserver: HTTPServer, userhomedir):
   httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200))
-  httpserver.expect_ordered_request('/api/create', method='POST', json={
-    'model': 'dummy',
-    'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n',
-    'stream': False,
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/create',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n',
+      'stream': False,
+    },
+  ).respond_with_json({})
 
   client = AsyncClient(httpserver.url_for('/'))
 
@@ -586,11 +721,15 @@ async def test_async_client_create_path_user_home(httpserver: HTTPServer, userho
 @pytest.mark.asyncio
 async def test_async_client_create_modelfile(httpserver: HTTPServer):
   httpserver.expect_ordered_request(PrefixPattern('/api/blobs/'), method='HEAD').respond_with_response(Response(status=200))
-  httpserver.expect_ordered_request('/api/create', method='POST', json={
-    'model': 'dummy',
-    'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n',
-    'stream': False,
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/create',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'modelfile': 'FROM @sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n',
+      'stream': False,
+    },
+  ).respond_with_json({})
 
   client = AsyncClient(httpserver.url_for('/'))
 
@@ -601,11 +740,15 @@ async def test_async_client_create_modelfile(httpserver: HTTPServer):
 
 @pytest.mark.asyncio
 async def test_async_client_create_from_library(httpserver: HTTPServer):
-  httpserver.expect_ordered_request('/api/create', method='POST', json={
-    'model': 'dummy',
-    'modelfile': 'FROM llama2\n',
-    'stream': False,
-  }).respond_with_json({})
+  httpserver.expect_ordered_request(
+    '/api/create',
+    method='POST',
+    json={
+      'model': 'dummy',
+      'modelfile': 'FROM llama2\n',
+      'stream': False,
+    },
+  ).respond_with_json({})
 
   client = AsyncClient(httpserver.url_for('/'))