From 6e96b69b5597438a5e1fa1f9e7f141f2a5bb27f3 Mon Sep 17 00:00:00 2001 From: Eric Apgar Date: Wed, 18 Mar 2026 16:30:41 -0500 Subject: [PATCH 1/7] Changing test script for image input. --- scripts/client_test.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/client_test.py b/scripts/client_test.py index 099c13a..8f248bc 100644 --- a/scripts/client_test.py +++ b/scripts/client_test.py @@ -18,7 +18,7 @@ def pil_to_api_b64(pil_image: PillowImage.Image) -> str: URL = 'https://127.0.0.1:8000/ask' -# images = [pil_to_api_b64(PillowImage.open(''))] +images = [pil_to_api_b64(PillowImage.open(r'/home/eric/Desktop/monkey.png'))] c = Conversation() c.set_overall_prompt(text='Pretend to be a person named John Doe.') @@ -27,11 +27,11 @@ def pil_to_api_b64(pil_image: PillowImage.Image) -> str: prompt = c.to_dict() REQUEST_DETAILS = { - 'tag': 'GPT', - 'prompt': prompt, #'Name a primary color.', - # 'images': images, + 'tag': 'Phi-4', + 'prompt': 'Describe the image.', + 'images': images, 'max_tokens': 64, - 'temperature': 0.9,} + 'temperature': 0.9} def main() -> None: @@ -49,6 +49,6 @@ def main() -> None: if __name__ in {'__main__', '__mp_main__'}: - run_gui() + # run_gui() - # main() + main() From c23cc392d6cadad3057ba3646ef497dd276e0c71 Mon Sep 17 00:00:00 2001 From: Eric Apgar Date: Wed, 18 Mar 2026 17:02:35 -0500 Subject: [PATCH 2/7] Fixed bug for images not working. --- scripts/client_test.py | 22 +++++++++++----------- src/llm_server/backend.py | 3 +++ src/llm_server/schemas.py | 2 +- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/scripts/client_test.py b/scripts/client_test.py index 8f248bc..cf9e458 100644 --- a/scripts/client_test.py +++ b/scripts/client_test.py @@ -6,32 +6,32 @@ from PIL import Image as PillowImage from llm_conversation import Conversation -from llm_server.gui_app import run_gui - def pil_to_api_b64(pil_image: PillowImage.Image) -> str: - buffer = BytesIO() - pil_image.save(buffer, format='PNG') + buffer = BytesIO() + pil_image.save(buffer, format='PNG') - return base64.b64encode(buffer.getvalue()).decode('ascii') + return base64.b64encode(buffer.getvalue()).decode('ascii') -URL = 'https://127.0.0.1:8000/ask' +# TODO: Point to endpoint of hosted llm_server.Server(). +URL = 'http://127.0.0.1:8001/ask' +# TODO: Edit as needed. images = [pil_to_api_b64(PillowImage.open(r'/home/eric/Desktop/monkey.png'))] +# TODO: Edit as needed. c = Conversation() c.set_overall_prompt(text='Pretend to be a person named John Doe.') c.add_context(text='Your favorite color is onyx.') c.add_response(role='user', text='Whats your name and favorite color?') prompt = c.to_dict() +# TODO: Edit as needed. REQUEST_DETAILS = { 'tag': 'Phi-4', 'prompt': 'Describe the image.', - 'images': images, - 'max_tokens': 64, - 'temperature': 0.9} + 'images': images} def main() -> None: @@ -49,6 +49,6 @@ def main() -> None: if __name__ in {'__main__', '__mp_main__'}: - # run_gui() - + # TODO: Make sure a server instance (llm_server.Server() or + # llm_server.server_gui()) is running before running main(). main() diff --git a/src/llm_server/backend.py b/src/llm_server/backend.py index 6cf002f..82e514e 100644 --- a/src/llm_server/backend.py +++ b/src/llm_server/backend.py @@ -110,6 +110,9 @@ def ask(self, details: Request) -> str: else: del input_args['images'] + # Remove unset (None) fields so defaults are used downstream. + input_args = {k: v for k, v in input_args.items() if v is not None} + if isinstance(input_args['prompt'], dict): c = Conversation() c.from_dict(data=input_args['prompt']) diff --git a/src/llm_server/schemas.py b/src/llm_server/schemas.py index 3b12b28..3b5405d 100644 --- a/src/llm_server/schemas.py +++ b/src/llm_server/schemas.py @@ -6,7 +6,7 @@ class Request(BaseModel): prompt: str | dict = Field(..., description='LLM prompt or conversation (llm-conversation.to_dict).') images: list[str] | None = Field(default=None, description='Each image should be base64-encoded image bytes.', examples=[None]) max_tokens: int = Field(default=256, description='Max token count for generation.') - temperature: float = Field(default=0.9, description='Temperature of generated response.') + temperature: float | None = Field(default=None, description='Temperature of generated response.') class Response(BaseModel): From 55e56ca5cb393bf9ae3a7d7f7f2bab91d4af6297 Mon Sep 17 00:00:00 2001 From: Eric Apgar Date: Wed, 18 Mar 2026 17:44:21 -0500 Subject: [PATCH 3/7] Updated readme, exposed image encoder, removed obsolete code. --- README.md | 39 +++++++++++++++++++++++++++------ scripts/client_test.py | 23 +++++++++---------- src/llm_server/__init__.py | 20 +++++++++++------ src/llm_server/gui_app.py | 1 - src/llm_server/helper/helper.py | 16 ++++++++++++++ 5 files changed, 71 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 901051d..db35f0c 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,9 @@ pip install -e ".[gui]" Running an LLM requires an NVIDIA GPU with ideally a large number of TOPS. See the [large-language-model](https://github.com/EricApgar/large-language-model) repo for hardware details and GPU driver setup. # Usage -Create a server, register a model with a tag, load it, and start serving. +Create a server, register a model with a tag, load it, and start serving. Then send a request and receive a response. +## Server ``` import llm_server @@ -75,22 +76,46 @@ server.start() # Non-blocking. server.stop() ``` +## Requester +``` +URL = 'http://127.0.0.1:8001/ask' +details = {...} # See request body examples below. + +response = requests.post(URL, json=details, timeout=15) +data = response.json() +print(data['text']) +``` + # API Endpoints | Method | Endpoint | Description | |-|-|-| -| GET | `/` | Health check — returns `"Running."` | -| GET | `/get-models` | List all registered models and their tags | -| GET | `/ask-test` | Send a test prompt (`"Tell me a joke."`) to the first available model | -| POST | `/ask` | Send a prompt to a specific model | +| GET | `/` | Health check — returns `"Running."`. | +| GET | `/get-models` | List all available hosted models and their tags. | +| GET | `/ask-test` | Send a test prompt (`"Tell me a joke."`) to the first available model. | +| POST | `/ask` | Send a prompt to a specific model. | ### POST /ask — Request Body + +#### Text Example ``` { "tag": "gpt", "prompt": "Tell me a joke.", - "images": [], "max_tokens": 64, "temperature": 0.9 } ``` -`prompt` may also be a conversation dict (see [llm-conversation](https://github.com/EricApgar/llm-conversation)). `images` accepts base64-encoded PNG strings for multimodal models. +* `prompt` may also be a Conversation formatted dict output (see [llm-conversation](https://github.com/EricApgar/llm-conversation)). + * ```llm_conversation.Conversation.to_dict()``` + +#### Image Example +``` +{ + "tag": "Phi4", + "prompt": "Describe the image.", + "images": [llm_server.encode_image()], + "max_tokens": 64, +} +``` +* ```temperature``` arg currently not supported for Phi-4-multimodal-instruct. +* ```images``` accepts base64-encoded PNG strings for multimodal models. Encoder is provided by ```llm_server```. \ No newline at end of file diff --git a/scripts/client_test.py b/scripts/client_test.py index cf9e458..9e10d87 100644 --- a/scripts/client_test.py +++ b/scripts/client_test.py @@ -1,24 +1,16 @@ -import json import requests -from io import BytesIO -import base64 from PIL import Image as PillowImage from llm_conversation import Conversation - -def pil_to_api_b64(pil_image: PillowImage.Image) -> str: - buffer = BytesIO() - pil_image.save(buffer, format='PNG') - - return base64.b64encode(buffer.getvalue()).decode('ascii') +from llm_server.helper.helper import encode_image # TODO: Point to endpoint of hosted llm_server.Server(). URL = 'http://127.0.0.1:8001/ask' # TODO: Edit as needed. -images = [pil_to_api_b64(PillowImage.open(r'/home/eric/Desktop/monkey.png'))] +images = [encode_image(image=PillowImage.open(r'/home/eric/Desktop/monkey.png'))] # TODO: Edit as needed. c = Conversation() @@ -28,7 +20,12 @@ def pil_to_api_b64(pil_image: PillowImage.Image) -> str: prompt = c.to_dict() # TODO: Edit as needed. -REQUEST_DETAILS = { +REQUEST_DETAILS_TEXT = { + 'tag': 'GPT', + 'prompt': prompt, + 'temperature': .9} + +REQUEST_DETAILS_IMAGE = { 'tag': 'Phi-4', 'prompt': 'Describe the image.', 'images': images} @@ -37,9 +34,9 @@ def pil_to_api_b64(pil_image: PillowImage.Image) -> str: def main() -> None: try: - response = requests.post(URL, json=REQUEST_DETAILS, timeout=15) + response = requests.post(URL, json=REQUEST_DETAILS_TEXT, timeout=15) data = response.json() - print(json.dumps(data, indent=4)) + print(data['text']) except Exception as e: raise(e) diff --git a/src/llm_server/__init__.py b/src/llm_server/__init__.py index 85fb36b..ca2afd0 100644 --- a/src/llm_server/__init__.py +++ b/src/llm_server/__init__.py @@ -1,16 +1,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING __all__ = [ 'Server', - 'server_gui', + 'run_gui', + 'encode_image', ] if TYPE_CHECKING: from .server import Server - from .gui_app import run_gui as server_gui + from .gui_app import run_gui as run_gui + from .helper.helper import encode_image def __getattr__(name: str): @@ -18,10 +20,14 @@ def __getattr__(name: str): from .server import Server globals()[name] = Server return Server - elif name == 'server_gui': - from .gui_app import run_gui as server_gui - globals()[name] = server_gui - return server_gui + elif name == 'run_gui': + from .gui_app import run_gui + globals()[name] = run_gui + return run_gui + elif name == 'encode_image': + from .helper.helper import encode_image + globals()[name] = encode_image + return encode_image else: raise AttributeError(f'module {__name__!r} has no attribute {name!r}') diff --git a/src/llm_server/gui_app.py b/src/llm_server/gui_app.py index 53ef34c..9e7308c 100644 --- a/src/llm_server/gui_app.py +++ b/src/llm_server/gui_app.py @@ -1,7 +1,6 @@ import queue import weakref import re -from dataclasses import dataclass import socket from nicegui import ui, run diff --git a/src/llm_server/helper/helper.py b/src/llm_server/helper/helper.py index 7b2b53c..026645a 100644 --- a/src/llm_server/helper/helper.py +++ b/src/llm_server/helper/helper.py @@ -1,5 +1,21 @@ +from io import BytesIO +import base64 from typing import Optional, overload +from PIL import Image as PillowImage + + +def encode_image(image: PillowImage.Image) -> str: + """ + Encode a PIL image as a base64 string for API transport. + """ + + buffer = BytesIO() + image.save(buffer, format='PNG') + encoded_output = base64.b64encode(buffer.getvalue()).decode('ascii') + + return encoded_output + class Endpoint: From fab68cf57c1fd7042c1173c8b450d920a399a7d7 Mon Sep 17 00:00:00 2001 From: Eric Apgar Date: Wed, 18 Mar 2026 18:08:41 -0500 Subject: [PATCH 4/7] Added tests. --- pyproject.toml | 6 +++- tests/test_server.py | 65 ++++++++++++++++++++++++++++++++++++++++++++ uv.lock | 16 +++++++---- 3 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 tests/test_server.py diff --git a/pyproject.toml b/pyproject.toml index ff0d341..dbc72d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "llm_server" # The pip install . -version = "0.1.0" +version = "0.2.0" description = "Template for repos that are intended to be packaged libraries." readme = "README.md" authors = [{ name = "Eric Apgar" }] @@ -17,6 +17,10 @@ dependencies = [ gui = [ "nicegui>=3.8.0", ] +dev = [ + "pytest>=8.0.0", + "requests>=2.32.0", +] [tool.uv.sources] llm = { git = "https://github.com/EricApgar/large-language-model" } diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..4db6d9c --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,65 @@ +""" +Tests for llm_server.Server. + +These tests can load a real model when hosting the server, so if running a full +test they require local model weights. Sync the environment first, then pass +LLM_MODEL_CACHE inline when invoking pytest so it only exists for that one +command and does not persist in your shell: + + uv sync --extra dev + LLM_MODEL_CACHE= pytest + +Tests are skipped automatically if LLM_MODEL_CACHE is not set. + +Make sure the virtual environment is active. +""" + +import os +import time + +import pytest +import requests + +import llm_server + + +MODEL_CACHE = os.environ.get('LLM_MODEL_CACHE') + + +@pytest.fixture(scope='module') +def server(): + s = llm_server.Server() + s.set_host(ip_address='127.0.0.1', port=8001) + s.start() + time.sleep(1) # Allow uvicorn to finish starting. + yield s + s.stop() + + +@pytest.fixture(scope='module') +def server_with_model(): + if not MODEL_CACHE: + pytest.skip('LLM_MODEL_CACHE environment variable not set.') + s = llm_server.Server() + s.set_host(ip_address='127.0.0.1', port=8002) + s.add_model(tag='gpt', name='openai/gpt-oss-20b') + s.load_model(tag='gpt', location=MODEL_CACHE) + s.start() + time.sleep(1) # Allow uvicorn to finish starting. + yield s + s.stop() + + +def test_server_running(server): + response = requests.get('http://127.0.0.1:8001/', timeout=5) + assert response.json() == 'Running.' + + +def test_ask(server_with_model): + response = requests.post( + 'http://127.0.0.1:8002/ask', + json={'tag': 'gpt', 'prompt': 'Name a primary color.'}, + timeout=60) + data = response.json() + assert isinstance(data['text'], str) + assert len(data['text']) > 0 diff --git a/uv.lock b/uv.lock index 2b62547..42a6db5 100644 --- a/uv.lock +++ b/uv.lock @@ -473,7 +473,7 @@ source = { git = "https://github.com/EricApgar/llm-conversation#ce9d59af41f4a018 [[package]] name = "llm-server" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, @@ -482,6 +482,10 @@ dependencies = [ ] [package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "requests" }, +] gui = [ { name = "nicegui" }, ] @@ -491,9 +495,11 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.135.1" }, { name = "llm", extras = ["all"], git = "https://github.com/EricApgar/large-language-model" }, { name = "nicegui", marker = "extra == 'gui'", specifier = ">=3.8.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "requests", marker = "extra == 'dev'", specifier = ">=2.32.0" }, { name = "uvicorn", specifier = ">=0.41.0" }, ] -provides-extras = ["gui"] +provides-extras = ["gui", "dev"] [[package]] name = "markdown2" @@ -1295,9 +1301,9 @@ dependencies = [ { name = "torch" }, ] wheels = [ - { url = "https://download.pytorch.org/whl/cu130/torchvision-0.25.0%2Bcu130-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3646e6c8fa5066da392d0ff13002cc683301386fe1933f8f1432fc5292e5d288" }, - { url = "https://download.pytorch.org/whl/cu130/torchvision-0.25.0%2Bcu130-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c2f5e38f0cd57a2796e4503c0f13365deba01dbc167ef820f0beec7ca96f5f2e" }, - { url = "https://download.pytorch.org/whl/cu130/torchvision-0.25.0%2Bcu130-cp312-cp312-win_amd64.whl", hash = "sha256:7dd245ee7df0ceb00125e57615de31ca7232bf046143c2c3fe7a3b321bb50958" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.25.0%2Bcu130-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3646e6c8fa5066da392d0ff13002cc683301386fe1933f8f1432fc5292e5d288" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.25.0%2Bcu130-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c2f5e38f0cd57a2796e4503c0f13365deba01dbc167ef820f0beec7ca96f5f2e" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.25.0%2Bcu130-cp312-cp312-win_amd64.whl", hash = "sha256:7dd245ee7df0ceb00125e57615de31ca7232bf046143c2c3fe7a3b321bb50958" }, ] [[package]] From 958f7ded1edbbb6a7d24be15563025e2c8dd61a5 Mon Sep 17 00:00:00 2001 From: Eric Apgar Date: Wed, 18 Mar 2026 18:21:57 -0500 Subject: [PATCH 5/7] Updated readme. --- README.md | 12 ++++++++++-- assets/LLM Server GUI.png | Bin 0 -> 24834 bytes 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 assets/LLM Server GUI.png diff --git a/README.md b/README.md index db35f0c..54d5338 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # llm-server -Host an LLM as a non-blocking HTTP server with a simple interface for managing and querying models. Built on [FastAPI](https://fastapi.tiangolo.com/) and [uvicorn](https://www.uvicorn.org/), it wraps the [large-language-model](https://github.com/EricApgar/large-language-model) library to expose LLM inference over a REST API. +Host an LLM on a non-blocking HTTP server with a simple interface for managing and querying models. This allows you to host an LLM on LLM-capable harware and then access it over the network from devices that can't run an LLM locally. + +Built on [FastAPI](https://fastapi.tiangolo.com/) and [uvicorn](https://www.uvicorn.org/), it wraps the [large-language-model](https://github.com/EricApgar/large-language-model) library to expose LLM inference over a REST API. + +A user friendly GUI interface is included (but **not** required) for general use. + +
+ LLM Server GUI +
# Setup See [Releases](https://github.com/EricApgar/llm-server/releases) to install from wheel file. @@ -7,7 +15,7 @@ See [Releases](https://github.com/EricApgar/llm-server/releases) to install from See ```pyproject.toml``` for required Python version and dependencies. ## Optional Dependencies -This library uses optional dependencies for additional features. See the ```pyproject.toml``` for the list of optional library tags. To install with the GUI support, use the ```gui``` tag. +This library uses optional dependencies for additional features. See the ```pyproject.toml``` for the list of optional library tags. To install with GUI support, use the ```gui``` tag. ## Library Install this repo as a library into another project. diff --git a/assets/LLM Server GUI.png b/assets/LLM Server GUI.png new file mode 100644 index 0000000000000000000000000000000000000000..afa2e1a553c6876b582dceec5aeef055963fdd93 GIT binary patch literal 24834 zcmd431yCH{yEZsc2n0!h03ktwdx8cJLvVL@_uvi*8gvK_gA*WlkiiC+1cEb6aEG9S z4et7Ne)rb>Zf)J&x_9@#wRnfGcS(A`Mj zpA7phuqO_(7!Q2ha|6q2U}Iy?EGqv2Hp$&3b==jRt=zp#T`fV@PR@>&>~7|+mX=O# zHqP$5_Yk5W5H(2ly|{*V=JuSI0g?4RtUa_8wJCx4J3c1)A9pxr{aAOJVHL1(yRiw~ z3XcXo`wF0G+0VguKIw015o571eeTh+u$r5@l%{^D!g{w?JZM4eicWcF$A=^GblGR8 z36~!GF7wyRGevqpB9&*vlC7?VLu19ehbsncQpei z{kbNlfEU^75e)=W|K_QXwNVtVZT>mn@=n6<5sn6 zy!W{QAp(E%uqXCn(*Orh%tT-t@aLkDa@Sd*}!XL+1l~-sSKx zm$%YIjf;Z$fb+;g`GSp+4@U4m02?30*R9@E=-cI<05V<`lSG?bzzTt{xysGrG0{c9 zw_gvY9s^^=(PIT{xQPF~!e`tX;E}vDh(`jumVzA~flmrHc|OZx@Se1 z6IvB=g0-&xsPOp{tFEUdh3fCN7I9BIlO_p48iIg$AmnxZ@T4_ND)Kq^DPU4(ZRV-~#Eo16#_U1s>R zkwNl2{mOwt0Tb#<&$(7Du13}=CJL5v=8Q%r!h8d*2EXz`&T*W%KpNB*+#gNf@Pfga z8v`8_Cq->hW*ol#UGj3WCfDz1H(~aVIL#=F4ny&_g}BO}OJLLd`eog7q>7id&A#-? z+17W^e>yDLe0su@C$*2w{=V0_874M0gGHmbyQw-!_1ZRwol0Zk=Rge-@&p$g!!%vq zKiw|%;mrasU%t)ZPEgUK!s$_Em$ME~Zu#HapvV5%dO2a{s^l=6rITj2sSo8}(2$27 zVRjT++jbSE&amEcy$|r4ab`>yeGuj_myRY%Or)&XF_rrq?_=f)tv3&f^nTxjg(b*T zK)`p?X z^9yxQAO0-_^jN+}`x5U3*{`*nxyIJq{*sm!mwu7K^5Z|1V@BKvg^tFJBPy|j#5Lo~ zwgQMV^ow!DG*x=O(1L=;HGH4dsh?esy@{B}W5d;bAhP$T!`enzG=7U044c+Z*7Y6cROG&~{r)R;xFWqzfq(4laqUad=wXdryre$buv#FVv>|(Ch z+767Zx-bnB(v#HOIz}kpQ^cl8vMl&QHEMc}wpNz1gToVk8(sS=@P@1@RAjdX)A?WQ z(`qM&u4Ie&^QETo?9TPZu^bou$LO50H#*5_Q>3VEibkV%j@OK+2h2LRJ6N; z$mvo&xx5$#_O5X4QJ%uxW&-<-a%e_^Af>|Ma{in_SFyGC?}-Vgmm_R89 zeIxBoI{bjO6_1UfMpjro0$h%4yCZF*ejR3kfA`KMRe( zLN-x)?4e;(5CL7NQC69HC?Rd?Ki%H%PysEOOqt3kV=ea9!=!7{K7I-T(>d#>?K{lO zPkdH0yxQ+S_NwI#u;;)GT!@^Gg;uWZIGJjQwL~y>6gxq$kM}j=2*IJ{P(&clho55U z&Xn}l{_PzhI-O0LWzMCRNM8?^Tx^>62cposA8X$6-Alp}wH@jCE!g}#W~#6i`uSjT z<)Ng}V{)B?8|tEp@FNd%?7SEWEP}@-Q4=b}`p%nFhx$|d5dPT5uI}aJ8i-0|yoP#t zXQuVdthS8F_B5G}wyTX=knYX#uF&WazU?^MuYTA~n%K?!g_p(-w4v|s1CRrzWx1^> zPvYqeF(~OySKXby2RYHdN&@Gpl#RJI3A3{H&&|#K#sDVa z3>6t2UHkoeyj&q$KiA`sH;(@zYw~}?0R7jegt7Pc?eFiej^P7#jHLsKtY9lkWrp_a z@B@-2h5mmga9YqQ{T0zS`op1WRe-ag z^60ze+((ygDh|#q6OFx?gvmy^zUJN_nthCbi<;+WlH)FVjbhQ$&4VLo(%d~HOCvjQ zZ>yE%Qwp*e;Q;@D*#5#dyWyS=#~b%W#<2h|K%2y+O*g=@xk@*+$-WDAP?M5AiFJH? zE|tBOeC4+#_Q(wupAP4F-gKdr{SKR^{m{d3;BI)(wGV|e1rd?Z?T*_kHE-k+cr7`Z zito5CrMJ8_Bj{=imU@>alnD~&`WjQq3)zFTIopl9_f?I+FJ{tv9Q>Qv-X0pLP`+HH zc05)G&u5FT4ab$Kzd67Ifsz#@kq^{g7zUNjpOR)5F6eFhIc+8#EBk%=}%uh#mv0~=a=m^^xuLR2b2jF<5R0uaAGu2l8 z5qQjOS`TTNw1>+GO~gj`5|`PH0||G~70N5Eo^_dK@yq8(wXn!w*n7Jr&jVvwG=F`;L*wN6h#FQP(H)Vh#m{KZl3BNY@tsg09JBnuND=jj?8XOrwkwuhEp)KI+>Z7tE$r(9fIkLL)P`0MNIIXF1h z*4BJ0Nf+p^xks(>R5;?1RjFB7FUiR_Fc=IPEv=fA2ferYjxcm(Wu>R5N6=$){V+au zH{phSv-TkqrzLXj@^qJxj_&ixXIkvw=;&w&MA=qbK}Kfp1O&mTe}q5{!5MaYN0}UMtuCAtu5AR6Nb>=JeaL5Xng!0yfs^d7|rFZ@`A7}zqLul zQO5#@@WF7q0(f88Uc2}*e%`||`f7}J#pv8|JP__;eC%o+rdc{gm`1I^a-udPlk6aC@9A6qq7n!rkmlHVeC(!5g3!3h^8lzF~ ziEPfG)}%M~nVp-Xa29A8oI!x;AEgf!bLx#ctAJQerNPj zCv01Pf=eR&NMf;MT@5UGp#Wpn^%iP7JG1*UjysZhrTe(Zn84mjlskMe2VXeSK-XGdjhrOq4@kD2y8HPi6I)mSN$3(@?UruF=ud z?2~+TVut{Oha>U2rz#WI?)M}>s?IFEMq_B6g`)TzU;CIza7WdB_ii#t?X;FCQ+a)O zfJS9AFwhrUuJx?S@tVeb<>F=)!s>oLsKlo>5c}yifyKw28zj2A(pObQK?Lr9vFZOh z_tVbDlS7ILx4W04CJLovZc~1|dX$8F+p564l;&V0c{}_<@P%OG#xAKxh+fcix1L$9 zSDwM6J~YI6q4{*BDR_bziBerdw+zVmz6j{<8sq#{UmW}8r=Ct*R8ZJ}D{2mPxVveu z^kj+fxtZ5m;44x&GVKMuBF(+~+^JCaTP+O*wK%VmNseU`>uu5JZ2f?z(yw~DreZv2}U^@ZKRqS?PI!_CW3#ScWaHaAlgGoA zQH)H(`J9GvO4=#@XM8q)?5zzR-16LEQd*cAaG721h9o8=Xs{%wr>Dc= z@YdGWwL<%eN=qxNO^$6qkFBqJW2Rwd6pwIm6XjTYbJ=rTF%Ktf5ArJqd1&5KHhJC@ z3Y~|v8jMseplqL-rCc7_K6I9_DPE!%v=QQjWX`2#*R8K{_yjJmglrU@>NM53OxII`~Fi9toe{ZFJ4t`=%P_XoOOm+RTg z|ClT9i}hAprr z=E}gm7KJAobsga>Z(iTuEl; zkOKoadc8G({hD@-{5pKKu##g4|M`Hxi$w;%#5iCVgU&xw_VtW4x*bWVEqc~<&Ol6) zBs*tD-%JN<#;drTFg~;@)k&8qxao)y&S13 zxOIv#QBQ}A`>%zt@-NV*@!mhL)z_xff$hOc?KTvI>dA+FM~tHp^X;|uG*pjqlM=k= zQZd)|hX~GF&H4`tZXvMhft5)9s;(A^*Q6@eqAkyD=LG8>G6;c5q?w6vV0!GS{k1hU z?uWaEFT?^{6rp^(ZT`N#7f2|-{ph#Ftu1+M)v_^CPNNl{Hr-k)VoBQzWG7!*db$s0 znpas_nScPI2CHDh(1m7-_|*fy*fpeW;iTa|ncaulKESeFnbz0g%u|C!_V)}6y`0iIo<@>_*7$^1N z%}+Gh2SVCZEh;2QX};mG3iDeh^qajk3Cky>q}HaUG|6(hDVAhXRROW1qZ;7PP$+bb zxxYq9Nr@%7bW(-y%4Q_KuDf%wPIfr?p{>$%luBuvNDHX9{0} zS)_ZEyS+oq;JSb>t}s~O{k;Ry8h>fVf} zB>TKZaLFhJ+)&z2&CIk^>LjNmRSt_1xl$T7Sv(u>=^uuxs=MwRXs}pPk&XAvBrF-OuM$llau<0C|{7*~-Qhq2JT%U&4^` z)#JbAyq_cnZu(9;g?@F=8V zBBmF0%<}ehr(1#jRKR|+f+Ja&lZ(H{=PR<{U}p9T8ypzeR!;|L5kfONyHfV*kv#Tt zt%{6#6))2cqJyU#4!Xic=sisWs(*$ zENXH;^vZBi&<(s~#d-L)dSK3Ix_zF{eMfT)!Fib-TCN|YxG};u$(b7nUyQ~VW4;;< zO`Xt&Y|S^;L!9Td?jfd$%DhnrQHiOHx0k!LLAL>PoXR)5@`!ulr!+-Regq{L)#|!2 zyZQV7Os2MjT};(lv-Rug>221MuZF?lf$rVk`KSUKFaxdVs)y(^>lwy?mRFVYNw7-- zr;H~DM-A2L4PwiYsv}i#H-~*qSwx!|7yR~S+DAlx%qA2wU4QxiDh=tb51eCiSv_l# zjzYOCse7U_zpKt4Um|Ev*f6EDd@j=|zs&BSieDQ4uJRe#BQGN`4CI}Xx?(c7?WK^n zVF(hCi4?bwK>}2p)d5f8tZu~2*frGPDqCfFIQQk!{vGV?A z4-ye<{G&3d2hEw1*blHi0vAdsCj zPx@C(I#oaW@v|59OL7KF?uedG?#Pc{TPf_k*xqCtxi@a(e6LE9)06=Yeg`&K_-qeaw=U=$w6vJ`h7YGay8>nYmf}afHP? z9xB84UTv+lgp+uH^^a20p1_s-oFvE!o@^!QNG-K{LLgx!nS^SVFba`o#Zu+k_t zT#0Ifs`mEJ^(j*onUBwbdAE|=i-jV^T0X}Nmlr|XlN(;K)_ntgHS5;j@YYhRNl}I^ z-aBU7{S)2X=BAcfC2fvw+iO6d9O69*75 z*D~a=>+}3ZP&1L4(?-5$VIN1T8_d;km-yTco^;L!9WO_?FwFD5v&XDnphSbl0bos`fN4w-4T1oGei}@32uMF)n-Wsqb{^l0J**CylXDa?W2a)`W zln0&7yFdH=Q}N(w{(2Nh^Gh42lo?r9|KC=&B7eL!!KrIwX{k9q6HZ2Esak7$+0F=F zE16?Fe^&dPxT)h?yocRkphvf5MU?RYZBMP++w_X^y-g(|ZuJCcmcE5b1`63*Xj#)P zL1b^`l$n^Q@6%PW5LbV&SAOzsZ=QDZ2&-WknZ@mF7{Gfj&&|t5*R#2CFAF!X%%@p< z=Q6nZSg@g%>~dt*@ub855XE6krEb5+Z}j+yYmM7y4TgZ@*w;_8Ezpk&*%>=`d9_9F z-V3;QF94>D98hTQ5Nf;J>{2q~VCa2tO8LY!dp53~AYQcjd12O7VWc(9FZDolq~~2R z%cZ5&(92+Pk_g-|R>B-#_xUTL9dl1u!>|a~0rMq)R^3i@US9dib7A8>_`^^_uqv_U z%3W?G*NduJ-l$heKS**^w0u;Hc0TbgD=+k~VAF)6d}d#@^ys;! zRlSO~fGcA+|2*3a?5cL4atY?{?CP)$ACEQrgk2ZtysPz-j5X&sb%YmXT=7?YaT-yr zq|{x7lxXq1@^)k|-CXLPW2*9Uhqo@k1I26slIM_kqVZ z6fa%%@z!l_rzM_jZ7kCuyo8jvu}l2Pu633)N19}$-cZ6?zG11J@{mruRu2(GTC)@E zpTx`)m@iUJ=i7dV9#e28=fzaa1w-o}yKwplXg*L`xTuee)$D1p-@S^KE&S!ulB-4P zl!YFBfjYcvYUk}IOSRBe+(Zw`@xA>ipPG@~Rmf3At*ScIoXTs@;T{(i#WAPFR1)xR zBn8jE6x!zUErZu%&vvrL_G3UHbRK&z5XcB9^<1H$sApof+~#U&s(P;fG&!lbwQv2! zl(X*O>82nzw=9x_T?hS^t9A7_!gg=>%_n3C)1h6odA9J|n~s_QFm%V%N-`zSLI2by zrNMfG;K`7(+3mSwZ_4OzC&#jp&~MB5{s@cH5SBw)P*Qb;_H_zoCVcGp@Izho>*VLv zkL~GrVc%Vj_R1*6>jU~PjCOy|upmx;^C)U+cqmlVV+?jH4I6iIA|fAo#rY!~5G|$= zukEAKLXWpiT(7#FPb5Vm5p6z0p*p_=fi%`XSOBU9d(U09Xd2*=kWo@r3Uk_2LWd2M zI}d{+BJ4LBex;3JWB-l4LVjd$cxqWObUh9KLIzFKooX6=?C`tB5YBK%Y4>&fT@_6e zCCMiBn_8EV5QEMTmzVV<&#Gaf3vMRCVjZ13v;LhW6G63EJFsJ@^vIAv)XPT#b+#iG{b4$=8#abp0q<$_3=`#!%i@x#Sm#_DI2SsTG zOf94{gr@Q3)SC^b)~*rQT&gW;G<4@%QjJ^m_$6s$BR$l+ULptANi+Bjh0)5Dj8T@4J^b4e@sOahLf|$$FeNEGe zfRr!P*gz5x*jZ>eG(OHJATTvFRQ(D_yCr32I@sHn{pYk>`C(mvzyCb#`7cywCp{Ne z0^!o1Kh)C*;arXT;xbA~agmWF>cw%QTb*9Aw~vo??&WEUm49455XE1z9tV zij#w*BOu!V5_uaieQ0}-D|Cb>d z{*P(Xq>WSP^73+j*-M}w&2T7GUssD$oiDZ83D{tx4mhquD31Cm${0rO&GxffOdU#cav}9wfAP}h%)Z! zr=SDr+vyu*+l{oQN{MkRO^^t4xu$p1Wg<1AIWdkIHyhE|v|qNE)v@{T;kf;{<0${7 z*#WD?>7Dn%yzciveL3;X*UR-G4J^S}G1)KaRN^o^fuw^%5s39S6`asjIEcj&K zmXW|X5!Yb*3+-w5**Ul1R!y5om&d6ZgXGI|zw@*pr@^Vld#$hhj(*oN*dVN9nZ~QXnzmg>?)sT+v2~Dl7lDs-+rLz`?eazm`TmVp>%9X zK4I#o#>+l{c*CzDryt=gv&x(N6wFjVPFn1CU!3qP29_1eJaR`U8rJ;kTjhZBDiz^P zzl2h8yz;`e?PuexJ7ttiIN4O$qXP?w{+gLEcsIQx<&*7&7Q-#-+rb5Ck~YKH4&(NF zSNcd!Z_%!H!GDRo*0fGIg)m z7@OYM((e949YzPknm0w0&@{jEqhw*(>pdf*j5iPZO3yz&$n)4aF(9@ z4n~RAu>RT%{%!u{h7PZsL(e-%eZDHkDp@u0tgMSV6*FI_eeSYO>~r$S>B2-VlF$#2 zAWNDtiK)f5i(E+_ms8M&+jF1KD_PTwkR~crwdftuX*ao~*c=2AJ0##Nl+}CwcuUtY zrjjkYdSePvSGBYs>7txeSI|(L?unhge{^DU7TQ>B2j&^_msp)HRb19`-2ARQPd1p; z1gXL;@V$C{!yDA0lEU5{I4$GqcU%)t(hMIcrV~|H8+UR_nVyc_ioJ3=Pk7=H+iy>B z=NABb(Im8-3q0?=hkMy5Ja>p>%(8=zZoa+Vm9An?>pg{>cryQ)@^W07=}-=E*7Z9L zE;QUy7AvC*7B=P}q%|)%b;yp-N#!Kev&&CpPa1eH^~guyJWNEcE8tiD{wIM;YgBZP zuOg>`y408@ljK(Y>SGhqdzjkMXl88Y7;73aPUYP40SjX0v`-4;6IqQm>p6xK zO2*^XqkiKWqD<)ua`&4IDv8q0&s{&|TQW$6?xEsdMrr8R9ahW9DlD~zh{`igv11Iu%ggJCpSycOa+{j{ zu~Vmi1_O8>jj?eEBfR({`76PS9w>N0 z^WDTG-S5R=T0z!arf$2Ki04s#hBjs9_=d^ibA?}Dpow5`6oLdic(smmh#ITrbcLLeLjrK9q`LM>VShiZL=5O31j`zu(j$y);%v4eho#I475;S~Hj z!ffkkdqEhfZV-2XLjAo2kC#5+0)$wuGP-BE`7H>DpDvcT)du)}HBz557MNzo= z4V`xLsx-?h;}WvJT0P1#9^djT$s|SLJ#RZ=jHP0%t0PIE#Ru|kyq@zIv5A~SDF4oE z`x>&%=hD{x~T-8YYej*PZ&K+>~^hy7o+uSvvzr)GiQ3Qc%Z<< zTIV!{%026Bs`96NAercjcmspdfsph`rk_y(DsvI0D|)m3MJQ7ft=#2dTj1j-TlReD z5>+Yaa(0oVA_T@x_4Pnsczp4RW${2YHFT!M=GLV@G_9z5m# z{Pw(3K3WU%{HRVj0%Si{+e6FfYSX`Y(na<=yc7peO7W_HzP1*ZqHZtHh+CcS`wyxs zNccRF{D&7>#WFZFq2FFjD8~82K1>dCv|=SQS%)B;rpicY-uJZaZQXJZKN}g7x`?sa zvKn{)`9nm)!HRqh6|KJKgB>QlcIE)KZ9OtECt(3&?j4>acS zdXJeva&X%Vm>Zp?{1iHzUyk)w20N{!IKy^BU8E!0*aaUOH_h914vXPC873m9FYC!q zSHD6sbk(nx&82t0A*UOhO!5t(C1a(@Go@pU_{LCSAU!jB>d6^t^;K&ykX`iFXSOGW zqk6*z13RVE+w-_rI(c$(*TyIYX6&&-pr# zeQRRD&h|sa)(V@b1kW2=@6 zfc~oWy1nefvuvvACtr@^^8SL1RyRbT%;wCO?z?e^2f^s}!w#W1G#NRC`h^n!qWj1M z9H#dWf_ceYz+Y#E#YoI3_k=Wix|rjn5*iuYoOf6m@o|-e$;%+lyPpauHXSUcRI>}S zS*~#?icWpF)$G>4G1>YW7wE~LcKF=G4<&LD!>UyfzmKY+iJfIDRKrF4wpvrksc4@m z0sM&;v1^E}`Z4_UgB+k*9Ykr~%ZSxohvqxwaByq}t2&AXV&Vsv`RFHZEEbn;VJmTi zJ45C_@#twb^%wBTst=-6p^?TA=bNjuUOL75xarai?$2A6lwt|mF06b>mq*+#@ePl^ z_2{mbUnlx1&lhZe-$NZo`^m(icKQ9smiaS_{!XboS_>pNu6af|AJ7X8j$(%a@RO#9 z`W%h%oVk7h&q4<1Si{7%_lhO*(Paa>orS_#Wy<~?q99!$`^L!!<)2fV$oTGb7~Ivh zqMk}NbLSG92HY1%HQudzI@RLh;w9e>TdIs0TsZQ&V(h3pcIiFM=Vn6xq?I~xX2T_L z|K_t-lKQcB21o-L`R?-LM)~KNE_|4%yPi=-e|;*jf!?fMXs93`KJI8LE;N)17#~Vxo@W8x_@USD_*jz?ez&y>51%^Ev;|x; zdla-SI@PI_u&wvw1i$;d)ABls@DuO{e#55z+o;s!k$8m`k?eZX^{oMudti6tySX7y zm;K71{=_L8xo%D?%oCiN8~+B2`=57ppX&#&M({g2ngBl^kp*5$&-)(ISYhUc zYTMq>IQ+TgtGXQJ-U{QI%7>sYfYkY#YzfR!!%6GxdBco;Gb@c{f`v%ohu{ZHh&ZZC>4{Mz2JBz1FUBef62RalZqG)MtNW|$gFa&x20d@v zDcygV=v@B~4d@AO&#RTR0}swM=PlMm&olN12;X~q`0>b#qQ7~v!YDHlhD>$xW>b*C zfLClRQl|n59k+gLCTxDH zn63vl6m{77?2aQ8XW70ooZJyv%aJ4ftIMm?F|8FA&vw|cybk$2udj$N##I){ib2MU zdutt>_y3{EG-KMVF?tVz1aYSL17FVfbQ;*18N<6Lx)Z#?pZAuw+nq$mxo}Y@#yK*a zGA^PU>)TE@V-{Rcy_eRMvP*b%+@NHV8eQVi2?ROMNiK474hbve)1eGZzaKe0oeRJpVJyG4`H|4rh3mO(G`%@hlb7fz zB8{TR<$0aJf>XDZtI7D#n-}}r;P{r2*YKyyHv%~4pE}5I3W^DwRiah#w*R!*MdwjG zx_uO!$wNliRpd8#kqW#X6KcQ8O8#0N-&S#UQPyE*MFXr4^?U93N$Jm+OJ)cITJp-Ou00vk?U zm}=krMpg@^ydqOfG?!Yq+XXek%?#fhvjMQ)$TPkSw}y0T;U9y<@ohGYg~=Om;JtWuJ# zS~*!+?VO*xNIL21;R_Ce3O_nLyGLU=a%L)4*SS-hc+`!=xe^Q^3Y$5sJGzH z@reLzbv;t9kPnCa_r!4yj3{-x&x!wgG<%gAZYbfavKZpzkPYFee`p^QXVy?~k5Fz`EX_fF<#*YAli0xr87r(ZoykbmJ z34a5owepoPiUKHk>tGf)ZQUAP&NXbJua)0GA09>EQT|4ZAGb;;Wnp>NjTe=wPGSXQ z?N($o+e}1uAQTO}(;TnGwNw8LzBJ6n=$y=SVl@y{EZnLnV&@sc32Ycz8`t2is{8v5 zCmdjYpcj6CyBr-Qakz#6T%7fzMnaUq5HLt z;ek2oBgTMg z7O{klSz7}9o{Mt8pyYB5+w;Mjnl@^I(&I;007h4g+I(FE@Js%?M&f@G1*P_7A~!F~ ztje3K`2Ic{P@m>1(&}bv&6b-Km05pI?z~>~*Mt=GWgy%83*4+Q^624HWQN;Op~?E% z-y=}D4CFysbaAC$`x9^@<5m$z6AQn4$lpuJ7Y3M*C5@f(M_;nuDLbl#E!OE;}wv*~d4=)v11buUyV#TU&!_L3@8Df{vB z0J>W0v9Qsxm+bIzKO-X=lvuRR`N&W)39qJNfPLV--8WC7f{dR6c=_bM6lBzveDT$j z?k8ZTC@ZpXQ=6z)p&wj=6m)%Cj|4hJZp_qcF5#;u2q&hU}xubPJ_;pJl zn6?0CS{UFj%dPfVah#m?_B*ivmdEtU+90KDHP!lz8_+RKP8`rHKL_TJcrNGBcGqjt z=qeJZ0>jrFqfyqLpR=RW1?OpaL!CyN-Ix~>qn)$A#>KAS(dik1Z*pAyolZg0_GNZP z6%0E*?Us&In^QLteguJ=+haiz8Sf5uD=!-({digJiv_P!je9wU`8lh1cEsZpQ*4y- ztTAMNe%nb38~c-k8?o%A2`(1pzU&`OjJMNYiR6Vr!y+}2gKo-&q$TNop59ugf1am2 zQ=;hzL?mlB^8>F2iKTyw@Ic#!a*&` z*)FHu8JD6^Qqxhd>w*H~v$5xXuRXU+gl^^!z8Ev$i6-p5dSfOt-6CsGxfSD{YS5s# zJMIz@M|Tjqk|lHoQASmA2y~|U%ni8j9K*g*()qE+4#SCrsP85-L2H!AdlGo7uPsWJ7*ufW72ACe7r-J{L2IB+yO8faD z?Px44ND)q7f5c$p>Gqn`ZU4!FUbJdIL=?9=MdyB81|g7a+jd&4+>5-dH?TkGOiCFm zw7Q{{f<=jW-aU1>0|d$dkJekeug5Y`TTBhns?GL|sv^5(y93vFj2Wq`lPEP3v`!^x zGu$PW1q3X6oCzao&pA9Z5c+A)EAcl}OAHqMIuQz}T)RfCVj#@>n+$(I^Q$Kb-s=9V zSemMBMU(uE93oDVLM~mFdwd|RZdEP;+!F;^i&$^F%J5*HIk6LTaeFU^`Ie|8(ghz0 z4ZJ+LEqoR=yd7nZcUfM{>D_K~Lbs|bx3=?yLyO9OUTD6XQJtEj&>Ib?y;m9uf!w#= z$dy9=mF7>s<_ss_^~q7Nv2hWy_Bi{C7504Ihkv&@KK6P^Yppaka9dPSHsgZIwj$1~ z2uO+7L;lQWHb|*2w~`-L$7S#;(_r~T*gmH(dKGjkbmJm=V70Q^iIKaqjBj-w{)i|? z2!A-XS{Xx9`MqhbYV|aX_H2DFxntObY4m8=p*%u3-7eSzgLvcPTU1!#v1qNNKclA~ zRuzx{AnnuB&$$@44hDWa-2yc9yk?ZcvtdtUrq`>jxj{NyP6!Ix#9HBAoW(p-8Qo_4 z)MGn5iV(~;ePT~~eK^~yykf9+oI{Fvy){=gSa#>(VagnI&ZuG27oWz*i{K?>74T`t zqZMF3N)C(}GL2yy`3dN^p9sy(3o=)%({bXoEv(k|TlCVQo?g4nA&}%3#cn<)TjPan zGhq{Ae7B56K%BOwIK$b7cK#;mVF>_wSla0^#;vJ#BQ&`mv~5l~TK$zc(t}oPMRTZv zKHS^}!p$dfVRt|u3DpE@1keM)#^e0MhOU1ONbr>KDlv;A{#q@Gr1IFN65Z_NN3N>0jOZ@) ze8Y^Ux$5eprIiAQ`}sI(GyQTgn5wU8hEbO> zvweH3w6AZVI{e!wbufrLv>bRGH2}x!f3t!mNJ?`t(X9&u-Bno^bFim>W2}XAV`H%F zX6j=r@Mmho$=oPxKh5KB1PE!Y)s?yR8_z6A-zBQ{UmFsry;0)-rC1MyTYccssZ77X zp(CX1?#%gWBA>Fcdz+b4H;~>_F?}BpP-{Ck~vRH6Tqg)EMe zkgMJj9{>}4Jz^cdQMC}`{WlDiLp{;@G*v#AcyLYgmt1>2f~<+chvDq<+k1U1a&ZD8 zaTa>V(2e4<5=hHerl=y@sC7z-|4E337!GHn2j55(-i$|ux>V4hnb4@s7?lUw!v)t6oi zt$&)7l+-^V{Hg(PJCCf8qOTv*evwg7RD4A9H1a)1YT20G-|!kpUS#X0B+C^R{nLO$ z4GRWCK=$Fss zdntEOMXYt?`~34%w2ynn9nibXx-4@=N_)S7iwz#4YamgvuYhf;`>Z$}IJV-hEKKT` z8!^o)42@J0wx^;_2KHV>tya%hI*&{9CAMD=dFI045%ECgshTN z=-)J>UN{bOt5xl@R{$pJkK*1X5inFs2e&6X!lF?JH}}rGth{RBy-t7Y~F=bn<_a0xkdek}Lnlw&eWB3-pSAU;1l3MN1e^OuU;^)XXVFg4f}ayG zwh#lC|A^&Qq=zOwU?dq!?|$6jPPPR5dpXKp-k8FPO3sTK3vG!g({R(TCZ287yGk}* z*0r5x%*@Os6%`(buC3`mOkMk5?b=unX(CO!LIk9DgLH*ZLZLeedp>d#>wT z=bUY6OKg;ZvWi8KkZcl|h1BvzP{zJC5 zJiEJvagh)fCMRNtg@w7})LXbaq=u<%3=KagB#bWY_W&u^%x7mH*1@SbzehKJrHOIT z?6nn1NV-(I2hDc_Y?%f6yN}mLH14u>3WA91d3ULWv-={`Yi83>H8nNlr1%{xfKHUw ztH8iO=m5tw%NKboaF%z;%_rn2*i84&v*Z|@|8ssNkDxfHV{e^Yv9NLjz;U@b<*H-- zi)cXrzF4v8G@yztu+wv>=#EmY@Wa@qGq)9&o>jlO1$P$_#r>bZx$6>3lHG%NOS)#( zd~pvU>M*VuHadzQuj(@$lNKAA`}T%+XIdppWBt~B-TdOa{I1OgpVSb<{(h``9?_7P zB_PWx%--lQ&poI-TPkbm0rUp=Z{r?cZsEGzYuo2ztZ?g$+qG?tKNsBpDC46bl7APj zKXg29K)_gfq($0E?F*IC?|FUZ(4_sumn~$$k7E9DhIor1!!~L3i}=FT4@(aU=AOt6Bstp-C_ZU6@7dXrXmr9RH!lmYsBc4Wg&g{E6mdL`_$paerQOUmFlc- zICcHfh2P?Kz9ympk)Y4O!t-$M#Q69BtV5rE-i^5{_|?nB=8GplhG^4li_ zwqFM`+}ooxSHKB2ye>3@35jACwqd+K^fA+yRv~Ez+l1ZPv$Ah136mC~($pF_SI%2s z`I$6JODn+8M{`*HgoM51I|L(NKjnUjJ-P+0oZoNcLN<(xfja&?x4)Vayc_1Hq(X+i z>}_rBMc(WASg0}i$^S}BBgJyxJ@|X!_jW&@dgQS3Xi?vzsj@v^2$kd-jW?9Rx#{!RR$QlP=(U}2p_&WrTwt5~SsHbyFmVsjj zL8^Vk)Ao31+zkH9+wRn>G7^$z&jJFeJLvf|_$`B0=8RnNjFK5MjXNW=+P^rB`L6T~ z*vKKO4KzW^-;V=lH=-xIP!=O$)$UFEFDXD{>h6<3MVqm1bvAs6t_LfB|nz4Z4_4kb$9wq}yU8 z0_%tT*A2vNtSL#4m2-yyKwJrYGqUheoeJrUR&0r+%faM;RojJYENn{H)g(W?=|E68mV^{zuiE5UW-kCLhK=|S+w z<=8S(Ph$=W!1(Sk5^;_TI}ABZQX>|bcULmI8bcRm(C_BvwskBk&tmn(cwDh9PJ|Es zF7Wp|cGU)v@!A8lCe|Ci7&U-fP@&)E!q%+McJ3v{sZwkgkCLBUypetwkjlDCs9uJl z53I#1zeFy80e=HsrPRF9~<7aY0kG%nWozgR}v?10r4f1+&m+>eHYMLPlOF^BTU$)WhxcSPxT)+^zWqR0>Nl&E9AyZLKoCXfh+iMV4AeJTsh?8E&F zGo726EZ9LA(Rfx5()$(gQFkZw< zUJ65=s*qVq$?H$uPlmPgQ>VJ6MXaTs90T8<767`j1lkQ2a(N{fpoNsNeJ|;<7R;US!?OfwJGs6BTvYvX2o#eg>g`2YBbq(bpu^xuFiQ0`#>bM%1&E6>%UbKYQ?PHMDjI;s0Xr zU5u$f6h{GE)(iebn7-{^AA#;xPHe>RSf+SAZ`x%JKqXtIupPkYCu<-}X^~`^3uDeX zJD_qpxAkR|gz`XHLG5E^&Re}V89jhpIVLwfti^798TXEWC|r|e45!Dt!SL1q%|C0X z5yL`Hj06fl^F&)#T15R>M8#dVNrkW8aW@&|ZH((nj8Lftl=n8ww7sn>9zIIGO=;S% zfxc3n&6s_-)KqszSV*}7(a{4F@y%QcH(OfskiM@p?lvGUCQ0-dN3^N#8cF5_n*i|T zO58r$lJK}+6V;oS^;Wr$IHbYkwAPf5;qqFU=ZiVen8a}tq)NHl>?Kb4hz>%FQQob1 zVOgGw!PJ}w#j2oGX>l4q(EG$k$}Y6o)(T+J5$W)uu79kZ3j5E z24y_yS9k2>uH=Un9iQCEnrS*Wh1Fd+;SV7+oS-`+QDNw#eFFgoy;d7LtnCX=l89D& zqMuGnYIH`x(bvK;+fx5+XY8e%e23iWDi^jbP+IxpBe9axwfI<@*#bJQv`m(w2O(bQ zagahl*M~;d7I_3F1E}cK)81Kq98=>7^xUX)IGfT}^Iz{5ba0

tevBZS~(2&Vi6T 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 G(7ym4**b~< literal 0 HcmV?d00001 From 96b861b51be4c19b433a09f71401aa14a3703b40 Mon Sep 17 00:00:00 2001 From: Eric Apgar Date: Wed, 18 Mar 2026 18:45:35 -0500 Subject: [PATCH 6/7] Updated dependencies (LLM). --- uv.lock | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 42a6db5..45941f9 100644 --- a/uv.lock +++ b/uv.lock @@ -445,11 +445,10 @@ wheels = [ [[package]] name = "llm" -version = "0.4.2" -source = { git = "https://github.com/EricApgar/large-language-model#275714b6a3c52eb7ce436e7707a30b29de5ff162" } +version = "0.4.3" +source = { git = "https://github.com/EricApgar/large-language-model#4f85188e9b30b91caf7bdc64e53bbf4e905e8e43" } dependencies = [ { name = "llm-conversation" }, - { name = "pytest" }, { name = "sentence-transformers" }, { name = "torch" }, { name = "torchvision" }, From 9220ac662b6f405ea61e9d6df6c60e912cb38088 Mon Sep 17 00:00:00 2001 From: Eric Apgar Date: Wed, 18 Mar 2026 18:53:55 -0500 Subject: [PATCH 7/7] Added workflows. --- .github/workflows/tests.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..0769b6f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,23 @@ +name: Tests + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + runs-on: self-hosted # Requires a self-hosted runner with a GPU and model weights. + # Register one at: Settings -> Actions -> Runners -> New self-hosted runner. + + steps: + - uses: actions/checkout@v4 + + - name: Install package + run: uv sync --extra dev + + - name: Run tests + run: uv run pytest -v + env: + LLM_MODEL_CACHE: ${{ secrets.LLM_MODEL_CACHE }}