diff --git a/.github/workflows/quality-assurance.yml b/.github/workflows/quality-assurance.yml index f175984..ca08e4d 100644 --- a/.github/workflows/quality-assurance.yml +++ b/.github/workflows/quality-assurance.yml @@ -8,7 +8,7 @@ jobs: # TODO(dmu) LOW: Consider using Debian Buster (the same as docker image is based on) if it is easy to do runs-on: ubuntu-latest - container: python:3.10.4 + container: python:3.10.13 services: # TODO(dmu) LOW: This section duplicates services already defined in `docker-compose.yml`. diff --git a/Dockerfile b/Dockerfile index 378626c..b93fd12 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10.4-buster +FROM python:3.10.13-buster WORKDIR /opt/project diff --git a/README.md b/README.md index d73c30b..0f59039 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,10 @@ # Initial Project Setup -1. Install Poetry +1. Install Python version 3.10.13 and make sure it is being used in the following steps and later during development + (it is recommended to use [pyenv](https://github.com/pyenv/pyenv) for Python versions management) + +2. Install Poetry ```bash export PIP_REQUIRED_VERSION=24.2 @@ -18,13 +21,13 @@ poetry config virtualenvs.path ${HOME}/.virtualenvs && \ poetry run pip install pip==${PIP_REQUIRED_VERSION} ``` -2. Clone the Repository +3. Clone the Repository ```bash git clone https://github.com/thenewboston-developers/thenewboston-Backend.git ``` -3. Copy the settings templates into a new local directory: +4. Copy the settings templates into a new local directory: ```bash mkdir -p local @@ -32,7 +35,7 @@ cp thenewboston/project/settings/templates/settings.dev.py ./local/settings.dev. cp thenewboston/project/settings/templates/settings.unittests.py ./local/settings.unittests.py ``` -4. Install / upgrade docker as described at https://docs.docker.com/engine/install/ +5. Install / upgrade docker as described at https://docs.docker.com/engine/install/ ```bash # Known working versions described in the comments below @@ -42,14 +45,14 @@ docker --version # Docker version 26.0.1, build d260a54 docker compose version # Docker Compose version v2.26.1 ``` -5. Commands for setting up local environment. Run the following commands: +6. Commands for setting up local environment. Run the following commands: ```bash make run-dependencies # Sets up the necessary Docker containers for Redis and PostgreSQL make update # Installs project dependencies, pre-commit and applies database migrations ``` -6. Fire Up the Server 🚀 +7. Fire Up the Server 🚀 ```bash make run-server # Starts the Django development server diff --git a/poetry.lock b/poetry.lock index d60ca1d..fe604f5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -124,6 +124,31 @@ files = [ [package.dependencies] vine = ">=5.0.0,<6.0.0" +[[package]] +name = "anthropic" +version = "0.32.0" +description = "The official Python library for the anthropic API" +optional = false +python-versions = ">=3.7" +files = [ + {file = "anthropic-0.32.0-py3-none-any.whl", hash = "sha256:302c7c652b05a26c418f70697b585d7b47daac36210d097a0daa45ecda89f258"}, + {file = "anthropic-0.32.0.tar.gz", hash = "sha256:1027bddeb7c3cbcb5e16d5e3b4d4a8d17b6258ca2fb4298bf91cc69adb148452"}, +] + +[package.dependencies] +anyio = ">=3.5.0,<5" +distro = ">=1.7.0,<2" +httpx = ">=0.23.0,<1" +jiter = ">=0.4.0,<1" +pydantic = ">=1.9.0,<3" +sniffio = "*" +tokenizers = ">=0.13.0" +typing-extensions = ">=4.7,<5" + +[package.extras] +bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"] +vertex = ["google-auth (>=2,<3)"] + [[package]] name = "anyio" version = "4.2.0" @@ -1152,6 +1177,45 @@ files = [ {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, ] +[[package]] +name = "fsspec" +version = "2024.6.1" +description = "File-system specification" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fsspec-2024.6.1-py3-none-any.whl", hash = "sha256:3cb443f8bcd2efb31295a5b9fdb02aee81d8452c80d28f97a6d0959e6cee101e"}, + {file = "fsspec-2024.6.1.tar.gz", hash = "sha256:fad7d7e209dd4c1208e3bbfda706620e0da5142bebbd9c384afb95b07e798e49"}, +] + +[package.extras] +abfs = ["adlfs"] +adl = ["adlfs"] +arrow = ["pyarrow (>=1)"] +dask = ["dask", "distributed"] +dev = ["pre-commit", "ruff"] +doc = ["numpydoc", "sphinx", "sphinx-design", "sphinx-rtd-theme", "yarl"] +dropbox = ["dropbox", "dropboxdrivefs", "requests"] +full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "dask", "distributed", "dropbox", "dropboxdrivefs", "fusepy", "gcsfs", "libarchive-c", "ocifs", "panel", "paramiko", "pyarrow (>=1)", "pygit2", "requests", "s3fs", "smbprotocol", "tqdm"] +fuse = ["fusepy"] +gcs = ["gcsfs"] +git = ["pygit2"] +github = ["requests"] +gs = ["gcsfs"] +gui = ["panel"] +hdfs = ["pyarrow (>=1)"] +http = ["aiohttp (!=4.0.0a0,!=4.0.0a1)"] +libarchive = ["libarchive-c"] +oci = ["ocifs"] +s3 = ["s3fs"] +sftp = ["paramiko"] +smb = ["smbprotocol"] +ssh = ["paramiko"] +test = ["aiohttp (!=4.0.0a0,!=4.0.0a1)", "numpy", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "requests"] +test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask-expr", "dask[dataframe,test]", "moto[server] (>4,<5)", "pytest-timeout", "xarray"] +test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr", "zstandard"] +tqdm = ["tqdm"] + [[package]] name = "greenlet" version = "3.0.3" @@ -1279,6 +1343,40 @@ cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] +[[package]] +name = "huggingface-hub" +version = "0.24.5" +description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "huggingface_hub-0.24.5-py3-none-any.whl", hash = "sha256:d93fb63b1f1a919a22ce91a14518974e81fc4610bf344dfe7572343ce8d3aced"}, + {file = "huggingface_hub-0.24.5.tar.gz", hash = "sha256:7b45d6744dd53ce9cbf9880957de00e9d10a9ae837f1c9b7255fc8fa4e8264f3"}, +] + +[package.dependencies] +filelock = "*" +fsspec = ">=2023.5.0" +packaging = ">=20.9" +pyyaml = ">=5.1" +requests = "*" +tqdm = ">=4.42.1" +typing-extensions = ">=3.7.4.3" + +[package.extras] +all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio", "jedi", "minijinja (>=1.0)", "mypy (==1.5.1)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.5.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +cli = ["InquirerPy (==0.3.4)"] +dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio", "jedi", "minijinja (>=1.0)", "mypy (==1.5.1)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.5.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"] +hf-transfer = ["hf-transfer (>=0.1.4)"] +inference = ["aiohttp", "minijinja (>=1.0)"] +quality = ["mypy (==1.5.1)", "ruff (>=0.5.0)"] +tensorflow = ["graphviz", "pydot", "tensorflow"] +tensorflow-testing = ["keras (<3.0)", "tensorflow"] +testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio", "jedi", "minijinja (>=1.0)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"] +torch = ["safetensors[torch]", "torch"] +typing = ["types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)"] + [[package]] name = "hyperlink" version = "21.0.0" @@ -2813,6 +2911,123 @@ files = [ [package.extras] doc = ["reno", "sphinx", "tornado (>=4.5)"] +[[package]] +name = "tokenizers" +version = "0.19.1" +description = "" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tokenizers-0.19.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:952078130b3d101e05ecfc7fc3640282d74ed26bcf691400f872563fca15ac97"}, + {file = "tokenizers-0.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82c8b8063de6c0468f08e82c4e198763e7b97aabfe573fd4cf7b33930ca4df77"}, + {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f03727225feaf340ceeb7e00604825addef622d551cbd46b7b775ac834c1e1c4"}, + {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:453e4422efdfc9c6b6bf2eae00d5e323f263fff62b29a8c9cd526c5003f3f642"}, + {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02e81bf089ebf0e7f4df34fa0207519f07e66d8491d963618252f2e0729e0b46"}, + {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b07c538ba956843833fee1190cf769c60dc62e1cf934ed50d77d5502194d63b1"}, + {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e28cab1582e0eec38b1f38c1c1fb2e56bce5dc180acb1724574fc5f47da2a4fe"}, + {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b01afb7193d47439f091cd8f070a1ced347ad0f9144952a30a41836902fe09e"}, + {file = "tokenizers-0.19.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7fb297edec6c6841ab2e4e8f357209519188e4a59b557ea4fafcf4691d1b4c98"}, + {file = "tokenizers-0.19.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2e8a3dd055e515df7054378dc9d6fa8c8c34e1f32777fb9a01fea81496b3f9d3"}, + {file = "tokenizers-0.19.1-cp310-none-win32.whl", hash = "sha256:7ff898780a155ea053f5d934925f3902be2ed1f4d916461e1a93019cc7250837"}, + {file = "tokenizers-0.19.1-cp310-none-win_amd64.whl", hash = "sha256:bea6f9947e9419c2fda21ae6c32871e3d398cba549b93f4a65a2d369662d9403"}, + {file = "tokenizers-0.19.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5c88d1481f1882c2e53e6bb06491e474e420d9ac7bdff172610c4f9ad3898059"}, + {file = "tokenizers-0.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddf672ed719b4ed82b51499100f5417d7d9f6fb05a65e232249268f35de5ed14"}, + {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:dadc509cc8a9fe460bd274c0e16ac4184d0958117cf026e0ea8b32b438171594"}, + {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfedf31824ca4915b511b03441784ff640378191918264268e6923da48104acc"}, + {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac11016d0a04aa6487b1513a3a36e7bee7eec0e5d30057c9c0408067345c48d2"}, + {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76951121890fea8330d3a0df9a954b3f2a37e3ec20e5b0530e9a0044ca2e11fe"}, + {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b342d2ce8fc8d00f376af068e3274e2e8649562e3bc6ae4a67784ded6b99428d"}, + {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d16ff18907f4909dca9b076b9c2d899114dd6abceeb074eca0c93e2353f943aa"}, + {file = "tokenizers-0.19.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:706a37cc5332f85f26efbe2bdc9ef8a9b372b77e4645331a405073e4b3a8c1c6"}, + {file = "tokenizers-0.19.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:16baac68651701364b0289979ecec728546133e8e8fe38f66fe48ad07996b88b"}, + {file = "tokenizers-0.19.1-cp311-none-win32.whl", hash = "sha256:9ed240c56b4403e22b9584ee37d87b8bfa14865134e3e1c3fb4b2c42fafd3256"}, + {file = "tokenizers-0.19.1-cp311-none-win_amd64.whl", hash = "sha256:ad57d59341710b94a7d9dbea13f5c1e7d76fd8d9bcd944a7a6ab0b0da6e0cc66"}, + {file = "tokenizers-0.19.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:621d670e1b1c281a1c9698ed89451395d318802ff88d1fc1accff0867a06f153"}, + {file = "tokenizers-0.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d924204a3dbe50b75630bd16f821ebda6a5f729928df30f582fb5aade90c818a"}, + {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4f3fefdc0446b1a1e6d81cd4c07088ac015665d2e812f6dbba4a06267d1a2c95"}, + {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9620b78e0b2d52ef07b0d428323fb34e8ea1219c5eac98c2596311f20f1f9266"}, + {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04ce49e82d100594715ac1b2ce87d1a36e61891a91de774755f743babcd0dd52"}, + {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c5c2ff13d157afe413bf7e25789879dd463e5a4abfb529a2d8f8473d8042e28f"}, + {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3174c76efd9d08f836bfccaca7cfec3f4d1c0a4cf3acbc7236ad577cc423c840"}, + {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c9d5b6c0e7a1e979bec10ff960fae925e947aab95619a6fdb4c1d8ff3708ce3"}, + {file = "tokenizers-0.19.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a179856d1caee06577220ebcfa332af046d576fb73454b8f4d4b0ba8324423ea"}, + {file = "tokenizers-0.19.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:952b80dac1a6492170f8c2429bd11fcaa14377e097d12a1dbe0ef2fb2241e16c"}, + {file = "tokenizers-0.19.1-cp312-none-win32.whl", hash = "sha256:01d62812454c188306755c94755465505836fd616f75067abcae529c35edeb57"}, + {file = "tokenizers-0.19.1-cp312-none-win_amd64.whl", hash = "sha256:b70bfbe3a82d3e3fb2a5e9b22a39f8d1740c96c68b6ace0086b39074f08ab89a"}, + {file = "tokenizers-0.19.1-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:bb9dfe7dae85bc6119d705a76dc068c062b8b575abe3595e3c6276480e67e3f1"}, + {file = "tokenizers-0.19.1-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:1f0360cbea28ea99944ac089c00de7b2e3e1c58f479fb8613b6d8d511ce98267"}, + {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:71e3ec71f0e78780851fef28c2a9babe20270404c921b756d7c532d280349214"}, + {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b82931fa619dbad979c0ee8e54dd5278acc418209cc897e42fac041f5366d626"}, + {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8ff5b90eabdcdaa19af697885f70fe0b714ce16709cf43d4952f1f85299e73a"}, + {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e742d76ad84acbdb1a8e4694f915fe59ff6edc381c97d6dfdd054954e3478ad4"}, + {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8c5d59d7b59885eab559d5bc082b2985555a54cda04dda4c65528d90ad252ad"}, + {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b2da5c32ed869bebd990c9420df49813709e953674c0722ff471a116d97b22d"}, + {file = "tokenizers-0.19.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:638e43936cc8b2cbb9f9d8dde0fe5e7e30766a3318d2342999ae27f68fdc9bd6"}, + {file = "tokenizers-0.19.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:78e769eb3b2c79687d9cb0f89ef77223e8e279b75c0a968e637ca7043a84463f"}, + {file = "tokenizers-0.19.1-cp37-none-win32.whl", hash = "sha256:72791f9bb1ca78e3ae525d4782e85272c63faaef9940d92142aa3eb79f3407a3"}, + {file = "tokenizers-0.19.1-cp37-none-win_amd64.whl", hash = "sha256:f3bbb7a0c5fcb692950b041ae11067ac54826204318922da754f908d95619fbc"}, + {file = "tokenizers-0.19.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:07f9295349bbbcedae8cefdbcfa7f686aa420be8aca5d4f7d1ae6016c128c0c5"}, + {file = "tokenizers-0.19.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:10a707cc6c4b6b183ec5dbfc5c34f3064e18cf62b4a938cb41699e33a99e03c1"}, + {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6309271f57b397aa0aff0cbbe632ca9d70430839ca3178bf0f06f825924eca22"}, + {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ad23d37d68cf00d54af184586d79b84075ada495e7c5c0f601f051b162112dc"}, + {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:427c4f0f3df9109314d4f75b8d1f65d9477033e67ffaec4bca53293d3aca286d"}, + {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e83a31c9cf181a0a3ef0abad2b5f6b43399faf5da7e696196ddd110d332519ee"}, + {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c27b99889bd58b7e301468c0838c5ed75e60c66df0d4db80c08f43462f82e0d3"}, + {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bac0b0eb952412b0b196ca7a40e7dce4ed6f6926489313414010f2e6b9ec2adf"}, + {file = "tokenizers-0.19.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8a6298bde623725ca31c9035a04bf2ef63208d266acd2bed8c2cb7d2b7d53ce6"}, + {file = "tokenizers-0.19.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:08a44864e42fa6d7d76d7be4bec62c9982f6f6248b4aa42f7302aa01e0abfd26"}, + {file = "tokenizers-0.19.1-cp38-none-win32.whl", hash = "sha256:1de5bc8652252d9357a666e609cb1453d4f8e160eb1fb2830ee369dd658e8975"}, + {file = "tokenizers-0.19.1-cp38-none-win_amd64.whl", hash = "sha256:0bcce02bf1ad9882345b34d5bd25ed4949a480cf0e656bbd468f4d8986f7a3f1"}, + {file = "tokenizers-0.19.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:0b9394bd204842a2a1fd37fe29935353742be4a3460b6ccbaefa93f58a8df43d"}, + {file = "tokenizers-0.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4692ab92f91b87769d950ca14dbb61f8a9ef36a62f94bad6c82cc84a51f76f6a"}, + {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6258c2ef6f06259f70a682491c78561d492e885adeaf9f64f5389f78aa49a051"}, + {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85cf76561fbd01e0d9ea2d1cbe711a65400092bc52b5242b16cfd22e51f0c58"}, + {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:670b802d4d82bbbb832ddb0d41df7015b3e549714c0e77f9bed3e74d42400fbe"}, + {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:85aa3ab4b03d5e99fdd31660872249df5e855334b6c333e0bc13032ff4469c4a"}, + {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbf001afbbed111a79ca47d75941e9e5361297a87d186cbfc11ed45e30b5daba"}, + {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4c89aa46c269e4e70c4d4f9d6bc644fcc39bb409cb2a81227923404dd6f5227"}, + {file = "tokenizers-0.19.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:39c1ec76ea1027438fafe16ecb0fb84795e62e9d643444c1090179e63808c69d"}, + {file = "tokenizers-0.19.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c2a0d47a89b48d7daa241e004e71fb5a50533718897a4cd6235cb846d511a478"}, + {file = "tokenizers-0.19.1-cp39-none-win32.whl", hash = "sha256:61b7fe8886f2e104d4caf9218b157b106207e0f2a4905c9c7ac98890688aabeb"}, + {file = "tokenizers-0.19.1-cp39-none-win_amd64.whl", hash = "sha256:f97660f6c43efd3e0bfd3f2e3e5615bf215680bad6ee3d469df6454b8c6e8256"}, + {file = "tokenizers-0.19.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3b11853f17b54c2fe47742c56d8a33bf49ce31caf531e87ac0d7d13d327c9334"}, + {file = "tokenizers-0.19.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d26194ef6c13302f446d39972aaa36a1dda6450bc8949f5eb4c27f51191375bd"}, + {file = "tokenizers-0.19.1-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e8d1ed93beda54bbd6131a2cb363a576eac746d5c26ba5b7556bc6f964425594"}, + {file = "tokenizers-0.19.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca407133536f19bdec44b3da117ef0d12e43f6d4b56ac4c765f37eca501c7bda"}, + {file = "tokenizers-0.19.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce05fde79d2bc2e46ac08aacbc142bead21614d937aac950be88dc79f9db9022"}, + {file = "tokenizers-0.19.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:35583cd46d16f07c054efd18b5d46af4a2f070a2dd0a47914e66f3ff5efb2b1e"}, + {file = "tokenizers-0.19.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:43350270bfc16b06ad3f6f07eab21f089adb835544417afda0f83256a8bf8b75"}, + {file = "tokenizers-0.19.1-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b4399b59d1af5645bcee2072a463318114c39b8547437a7c2d6a186a1b5a0e2d"}, + {file = "tokenizers-0.19.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6852c5b2a853b8b0ddc5993cd4f33bfffdca4fcc5d52f89dd4b8eada99379285"}, + {file = "tokenizers-0.19.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcd266ae85c3d39df2f7e7d0e07f6c41a55e9a3123bb11f854412952deacd828"}, + {file = "tokenizers-0.19.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecb2651956eea2aa0a2d099434134b1b68f1c31f9a5084d6d53f08ed43d45ff2"}, + {file = "tokenizers-0.19.1-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:b279ab506ec4445166ac476fb4d3cc383accde1ea152998509a94d82547c8e2a"}, + {file = "tokenizers-0.19.1-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:89183e55fb86e61d848ff83753f64cded119f5d6e1f553d14ffee3700d0a4a49"}, + {file = "tokenizers-0.19.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2edbc75744235eea94d595a8b70fe279dd42f3296f76d5a86dde1d46e35f574"}, + {file = "tokenizers-0.19.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:0e64bfde9a723274e9a71630c3e9494ed7b4c0f76a1faacf7fe294cd26f7ae7c"}, + {file = "tokenizers-0.19.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0b5ca92bfa717759c052e345770792d02d1f43b06f9e790ca0a1db62838816f3"}, + {file = "tokenizers-0.19.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f8a20266e695ec9d7a946a019c1d5ca4eddb6613d4f466888eee04f16eedb85"}, + {file = "tokenizers-0.19.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63c38f45d8f2a2ec0f3a20073cccb335b9f99f73b3c69483cd52ebc75369d8a1"}, + {file = "tokenizers-0.19.1-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:dd26e3afe8a7b61422df3176e06664503d3f5973b94f45d5c45987e1cb711876"}, + {file = "tokenizers-0.19.1-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:eddd5783a4a6309ce23432353cdb36220e25cbb779bfa9122320666508b44b88"}, + {file = "tokenizers-0.19.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:56ae39d4036b753994476a1b935584071093b55c7a72e3b8288e68c313ca26e7"}, + {file = "tokenizers-0.19.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f9939ca7e58c2758c01b40324a59c034ce0cebad18e0d4563a9b1beab3018243"}, + {file = "tokenizers-0.19.1-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6c330c0eb815d212893c67a032e9dc1b38a803eccb32f3e8172c19cc69fbb439"}, + {file = "tokenizers-0.19.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec11802450a2487cdf0e634b750a04cbdc1c4d066b97d94ce7dd2cb51ebb325b"}, + {file = "tokenizers-0.19.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2b718f316b596f36e1dae097a7d5b91fc5b85e90bf08b01ff139bd8953b25af"}, + {file = "tokenizers-0.19.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:ed69af290c2b65169f0ba9034d1dc39a5db9459b32f1dd8b5f3f32a3fcf06eab"}, + {file = "tokenizers-0.19.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f8a9c828277133af13f3859d1b6bf1c3cb6e9e1637df0e45312e6b7c2e622b1f"}, + {file = "tokenizers-0.19.1.tar.gz", hash = "sha256:ee59e6680ed0fdbe6b724cf38bd70400a0c1dd623b07ac729087270caeac88e3"}, +] + +[package.dependencies] +huggingface-hub = ">=0.16.4,<1.0" + +[package.extras] +dev = ["tokenizers[testing]"] +docs = ["setuptools-rust", "sphinx", "sphinx-rtd-theme"] +testing = ["black (==22.3)", "datasets", "numpy", "pytest", "requests", "ruff"] + [[package]] name = "tomli" version = "2.0.1" @@ -3286,4 +3501,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "3cca514babddf4eb1047a123964cc9e9f61268dc387b4de44a86862cd43bad12" +content-hash = "fc297f939ab7de8bd3f6520f8706c521e7057dc4171dea680b98331f1c605a6c" diff --git a/pyproject.toml b/pyproject.toml index 58b1880..d3b38c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ sentry-sdk = {extras = ["django"], version = "^1.45.0"} django-restql = "^0.15.4" discord-py = "^2.4.0" promptlayer = "^1.0.9" +anthropic = "^0.32.0" [tool.poetry.group.dev.dependencies] colorlog = "^6.7.0" diff --git a/thenewboston/art/views/openai_image.py b/thenewboston/art/views/openai_image.py index 402f006..398c9da 100644 --- a/thenewboston/art/views/openai_image.py +++ b/thenewboston/art/views/openai_image.py @@ -2,7 +2,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from thenewboston.general.clients.openai import OpenAIClient +from thenewboston.general.clients.llm import LLMClient from thenewboston.general.constants import OPENAI_IMAGE_CREATION_FEE from thenewboston.general.enums import MessageType from thenewboston.wallets.consumers.wallet import WalletConsumer @@ -23,7 +23,7 @@ def create(self, request): description = serializer.validated_data['description'] quantity = serializer.validated_data['quantity'] - response = OpenAIClient.get_instance().generate_image(prompt=description, quantity=quantity) + response = LLMClient.get_instance().generate_image(prompt=description, quantity=quantity) self.charge_image_creation_fee(request.user, quantity) diff --git a/thenewboston/contributions/models/contribution.py b/thenewboston/contributions/models/contribution.py index e609d7f..d92ce6e 100644 --- a/thenewboston/contributions/models/contribution.py +++ b/thenewboston/contributions/models/contribution.py @@ -4,7 +4,7 @@ from django.db import models from django.utils import timezone -from thenewboston.general.clients.openai import OpenAIClient +from thenewboston.general.clients.llm import LLMClient, make_prompt_kwargs from thenewboston.general.models import CreatedModified from thenewboston.general.utils.transfers import change_wallet_balance @@ -69,11 +69,11 @@ def assess(self, save=True): assessment_points = pull.assessment_points assessment_explanation = pull.assessment_explanation case ContributionType.MANUAL.value: - result = OpenAIClient.get_instance().get_chat_completion( - settings.GITHUB_MANUAL_CONTRIBUTION_ASSESSMENT_PROMPT_NAME, + result = LLMClient.get_instance().get_chat_completion( input_variables={'description': self.description}, tracked_user=self.user, tags=['manual_contribution_assessment'], + **make_prompt_kwargs(settings.GITHUB_MANUAL_CONTRIBUTION_ASSESSMENT_PROMPT_NAME), ) assessment_points = result['assessment'] assessment_explanation = result['explanation'] diff --git a/thenewboston/discord/bot.py b/thenewboston/discord/bot.py index a1b3f67..41e64ce 100644 --- a/thenewboston/discord/bot.py +++ b/thenewboston/discord/bot.py @@ -11,7 +11,7 @@ from django.conf import settings # noqa: E402 from django.contrib.auth import get_user_model # noqa: E402 -from thenewboston.general.clients.openai import OpenAIClient # noqa: E402 +from thenewboston.general.clients.llm import LLMClient, make_prompt_kwargs # noqa: E402 logger = logging.getLogger(__name__) @@ -20,6 +20,49 @@ bot = commands.Bot('/', intents=intents) +# TODO(dmu) HIGH: Cover bot logic with unittests: it is already complex enough + + +def is_ia(author): + return author.id == settings.IA_DISCORD_USER_ID + + +def map_author_plaintext(author): + return 'ia' if is_ia(author) else author.name + + +def map_author_structured(author): + return 'assistant' if is_ia(author) else 'user' + + +def messages_to_plaintext(messages): + return '\n'.join(f'{map_author_plaintext(message.author)}: {message.content}' for message in messages) + + +def messages_to_structured(messages): + structured_messages = [] + + prev_role = None + for message in messages: + content = message.content + + if (role := map_author_structured(message.author)) == prev_role: + # We need to merge messages to prevent the following error from Anthropic + # messages: roles must alternate between "user" and "assistant", but found multiple "user" roles in a row + assert structured_messages + structured_messages[-1]['content'][0]['text'] += f'\n{content}' + else: + structured_messages.append({'role': role, 'content': [{'type': 'text', 'text': content}]}) + + prev_role = role + + return structured_messages + + +async def get_historical_messages(channel): + # TODO(dmu) MEDIUM: Filter out only author's and IA's messages from the channel? + return [message async for message in channel.history(limit=settings.DISCORD_MESSAGE_HISTORY_LIMIT)] + @bot.event async def on_ready(): @@ -32,28 +75,25 @@ async def on_message_implementation(message): await message.reply('Please, register at https://thenewboston.com') return - # TODO(dmu) MEDIUM: Request message history just once and convert it to necessary format before LLM call - plain_text_message_history = await get_plain_text_message_history(message.channel) + messages = (await get_historical_messages(message.channel))[::-1] # TODO(dmu) HIGH: Consider making just one LLM call that will return required response if necessary - answer = OpenAIClient.get_instance().get_chat_completion( - settings.DISCORD_IS_RESPONSE_WARRANTED_PROMPT_NAME, - input_variables={'plain_text_message_history': plain_text_message_history}, - tracked_user=user + answer = LLMClient.get_instance().get_chat_completion( + input_variables={'plain_text_message_history': messages_to_plaintext(messages)}, + tracked_user=user, + **make_prompt_kwargs(settings.DISCORD_IS_RESPONSE_WARRANTED_PROMPT_NAME), ) # TODO(dmu) LOW: Rename requiresResponse -> requires_response if answer.get('requiresResponse'): - historical_messages = await get_historical_messages(message.channel) - - ias_response = OpenAIClient.get_instance().get_chat_completion( - settings.DISCORD_CREATE_RESPONSE_PROMPT_NAME, + ias_response = LLMClient.get_instance().get_chat_completion( input_variables={ - 'messages': historical_messages, + 'messages': messages_to_structured(messages), 'text': message.content }, tracked_user=user, - tags=['discord_bot_response'] + tags=['discord_bot_response'], + **make_prompt_kwargs(settings.DISCORD_CREATE_RESPONSE_PROMPT_NAME) ) await message.reply(ias_response) @@ -72,33 +112,5 @@ async def on_message(message): await message.reply('Oops.. Looks like something went wrong. Our team has been notified.') -async def get_historical_messages(channel): - # TODO(dmu) LOW: Make `get_historical_messages()` DRY with `get_plain_text_message_history()` - results = [] - - async for message in channel.history(limit=settings.DISCORD_MESSAGE_HISTORY_LIMIT): - # TODO(dmu) LOW: If `_ia` supposed to be a suffix then use .endswith(). Also put `_ia` in a named - # constant or (better) custom setting - if '_ia' in str(message.author): - results.append({'role': 'assistant', 'content': [{'type': 'text', 'text': message.content}]}) - else: - results.append({'role': 'user', 'content': [{'type': 'text', 'text': message.content}]}) - - return results[::-1] - - -async def get_plain_text_message_history(channel): - # TODO(dmu) LOW: Make `get_plain_text_message_history()` DRY with `get_historical_messages()` - messages = [] - - async for message in channel.history(limit=settings.DISCORD_MESSAGE_HISTORY_LIMIT): - # TODO(dmu) LOW: If `_ia` supposed to be a suffix then use .endswith(). Also put `_ia` in a named - # constant or (better) custom setting - author_name = 'ia' if '_ia' in str(message.author) else message.author.name - messages.append(f'{author_name}: {message.content}') - - return '\n'.join(messages[::-1]) - - if __name__ == '__main__': bot.run(settings.DISCORD_BOT_TOKEN, log_handler=None) diff --git a/thenewboston/discord/tests/test_bot.py b/thenewboston/discord/tests/test_bot.py index da3c507..33a0ca7 100644 --- a/thenewboston/discord/tests/test_bot.py +++ b/thenewboston/discord/tests/test_bot.py @@ -1,11 +1,111 @@ +from collections import namedtuple from unittest.mock import patch import pytest +from django.test import override_settings -from thenewboston.discord.bot import on_ready +from thenewboston.discord.bot import messages_to_structured, on_ready + +Author = namedtuple('Author', ['id']) +Message = namedtuple('Message', ['author', 'content']) @pytest.mark.asyncio async def test_on_ready(): with patch('thenewboston.discord.bot.bot'): await on_ready() + + +@override_settings(IA_DISCORD_USER_ID=1234) +def test_messages_to_structured(): + assert messages_to_structured([Message(author=Author(id=1234), content='hello')]) == [{ + 'role': 'assistant', + 'content': [{ + 'type': 'text', + 'text': 'hello' + }] + }] + assert messages_to_structured([ + Message(author=Author(id=1234), content='hello'), + Message(author=Author(id=1234), content='world') + ]) == [{ + 'role': 'assistant', + 'content': [{ + 'type': 'text', + 'text': 'hello\nworld' + }] + }] + assert messages_to_structured([ + Message(author=Author(id=1234), content='hello'), + Message(author=Author(id=10), content='world') + ]) == [ + { + 'role': 'assistant', + 'content': [{ + 'type': 'text', + 'text': 'hello' + }] + }, + { + 'role': 'user', + 'content': [{ + 'type': 'text', + 'text': 'world' + }] + }, + ] + assert messages_to_structured([ + Message(author=Author(id=1234), content='hello'), + Message(author=Author(id=10), content='world'), + Message(author=Author(id=1234), content='bye') + ]) == [ + { + 'role': 'assistant', + 'content': [{ + 'type': 'text', + 'text': 'hello' + }] + }, + { + 'role': 'user', + 'content': [{ + 'type': 'text', + 'text': 'world' + }] + }, + { + 'role': 'assistant', + 'content': [{ + 'type': 'text', + 'text': 'bye' + }] + }, + ] + assert messages_to_structured([ + Message(author=Author(id=1234), content='hello'), + Message(author=Author(id=10), content='world'), + Message(author=Author(id=10), content='mine'), + Message(author=Author(id=1234), content='bye') + ]) == [ + { + 'role': 'assistant', + 'content': [{ + 'type': 'text', + 'text': 'hello' + }] + }, + { + 'role': 'user', + 'content': [{ + 'type': 'text', + 'text': 'world\nmine' + }] + }, + { + 'role': 'assistant', + 'content': [{ + 'type': 'text', + 'text': 'bye' + }] + }, + ] diff --git a/thenewboston/general/clients/openai.py b/thenewboston/general/clients/llm.py similarity index 51% rename from thenewboston/general/clients/openai.py rename to thenewboston/general/clients/llm.py index d5edfb9..32c7d31 100644 --- a/thenewboston/general/clients/openai.py +++ b/thenewboston/general/clients/llm.py @@ -13,23 +13,32 @@ logger = logging.getLogger(__name__) -class OpenAIClient: - # TODO(dmu) MEDIUM: Maybe we should rename it to LLMClient once we start supporting other AI providers +def make_prompt_kwargs(prompt_name: str) -> dict: + parts = prompt_name.split(':') + return {'prompt_name': parts[0], 'prompt_label': settings.PROMPT_DEFAULT_LABEL if len(parts) < 2 else parts[1]} + + +class LLMClient: """ - This class encapsulates Promptlayer and OpenAI integration logic, so the code that uses it is cleaner + This class encapsulates Promptlayer and LLM integration logic """ _instance = None - def __init__(self, openai_api_key, promptlayer_api_key): - self.openai_api_key = openai_api_key + def __init__(self, promptlayer_api_key, openai_api_key=None, anthropic_api_key=None): self.promptlayer_api_key = promptlayer_api_key + if not openai_api_key and not anthropic_api_key: + raise ValueError('At least one of LLM API keys must be provided: openai_api_key or anthropic_api_key') + + self.openai_api_key = openai_api_key + self.anthropic_api_key = anthropic_api_key @classmethod def get_instance(cls): if (instance := cls._instance) is None: cls._instance = instance = cls( - openai_api_key=settings.OPENAI_API_KEY, promptlayer_api_key=settings.PROMPTLAYER_API_KEY, + openai_api_key=settings.OPENAI_API_KEY, + anthropic_api_key=settings.ANTHROPIC_API_KEY, ) return instance @@ -39,7 +48,11 @@ def promptlayer_client(self): # TODO(dmu) MEDIUM: Consider using async OpenAI client # Since we are using PromptLayer.run() method there is no explicit way to provide OpenAI API key, so # we have to update environment variable to make it read from there - os.environ['OPENAI_API_KEY'] = self.openai_api_key + if openai_api_key := self.openai_api_key: + os.environ['OPENAI_API_KEY'] = openai_api_key + if anthropic_api_key := self.anthropic_api_key: + os.environ['ANTHROPIC_API_KEY'] = anthropic_api_key + return PromptLayer(api_key=self.promptlayer_api_key) @property @@ -52,7 +65,7 @@ def get_chat_completion( prompt_name, *, input_variables=None, - label=settings.PROMPT_TEMPLATE_LABEL, + prompt_label=settings.PROMPT_DEFAULT_LABEL, tracked_user: Optional['User'] = None, tags=None, format_result=True, @@ -69,19 +82,35 @@ def get_chat_completion( promptlayer_result = self.promptlayer_client.run( prompt_name=prompt_name, - prompt_release_label=label, + prompt_release_label=prompt_label, input_variables=input_variables, **kwargs, ) + if format_result: - result = promptlayer_result['raw_response'].choices[0].message.content - prompt_blueprint = promptlayer_result['prompt_blueprint'] - if ((model := prompt_blueprint.get('metadata', {}).get('model', {})) and - model.get('parameters', {}).get('response_format', {}).get('type') == 'json_object'): # noqa: E129 - try: - result = json.loads(result) - except Exception: - result = None + raw_response = promptlayer_result['raw_response'] + model = promptlayer_result['prompt_blueprint']['metadata']['model'] + provider = model['provider'] + match provider: + case 'openai': + result = raw_response.choices[0].message.content + if model.get('parameters', {}).get('response_format', + {}).get('type') == 'json_object': # noqa: E129 + try: + result = json.loads(result) + except Exception: + result = None + case 'anthropic': + # TODO(dmu) MEDIUM: anthropic does have message type, but it is 'text' even for actual JSON + # Figure out how to make it return the correct type and improve this part + result = raw_response.content[0].text + try: + result = json.loads(result) + except Exception: + pass # It was not JSON, see the TODO above for more reliable formatting + case _: + logger.warning('Unsupported LLM provider: %s', provider) + result = promptlayer_result else: result = promptlayer_result diff --git a/thenewboston/general/management/__init__.py b/thenewboston/general/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/thenewboston/general/management/commands/__init__.py b/thenewboston/general/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/thenewboston/general/management/commands/openai.py b/thenewboston/general/management/commands/llm.py similarity index 74% rename from thenewboston/general/management/commands/openai.py rename to thenewboston/general/management/commands/llm.py index 79b784a..078fc19 100644 --- a/thenewboston/general/management/commands/openai.py +++ b/thenewboston/general/management/commands/llm.py @@ -2,8 +2,9 @@ from django.conf import settings -from thenewboston.general.clients.openai import OpenAIClient +from thenewboston.general.clients.llm import LLMClient from thenewboston.general.commands import CustomCommand +from thenewboston.general.utils.json import ResponseEncoder class Command(CustomCommand): @@ -11,13 +12,15 @@ class Command(CustomCommand): def add_arguments(self, parser): subparsers = self.get_subparsers(parser) + parser.add_argument('--json', '-j', action='store_true') + subparsers.required = True complete_chat_response_parser = subparsers.add_parser('chat-completion-response') complete_chat_response_parser.add_argument('template') complete_chat_response_parser.add_argument( '--variables', '-v', help='Input variables in JSON format', default='{}' ) - complete_chat_response_parser.add_argument('--label', '-l', default=settings.PROMPT_TEMPLATE_LABEL) + complete_chat_response_parser.add_argument('--label', '-l', default=settings.PROMPT_DEFAULT_LABEL) complete_chat_response_parser.add_argument('--format-result', '-f', action='store_true') complete_chat_response_parser.add_argument('--track', '-t', action='store_true') complete_chat_response_parser.add_argument('--tag') @@ -28,7 +31,13 @@ def add_arguments(self, parser): @staticmethod def client(): - return OpenAIClient.get_instance() + return LLMClient.get_instance() + + def print_response(self, response, options): + if options['json']: + response = json.dumps(response, cls=ResponseEncoder) + + self.stdout.write(f'Response:\n{response}') def handle_chat_completion_response(self, *args, **options): variables = json.loads(options['variables']) @@ -36,13 +45,12 @@ def handle_chat_completion_response(self, *args, **options): response = self.client().get_chat_completion( options['template'], input_variables=variables, - label=options['label'], + prompt_label=options['label'], format_result=options['format_result'], tags=[tag] if tag else None ) - - self.stdout.write(f'Response:\n{response}') + self.print_response(response, options) def handle_generate_image(self, *args, **options): response = self.client().generate_image(options['prompt'], size=options['size']).dict() - self.stdout.write(f'Response:\n{response}') + self.print_response(response, options) diff --git a/thenewboston/general/tests/fixtures/cassettes/llm_client__get_chat_completion__anthropic.yaml b/thenewboston/general/tests/fixtures/cassettes/llm_client__get_chat_completion__anthropic.yaml new file mode 100644 index 0000000..62572c9 --- /dev/null +++ b/thenewboston/general/tests/fixtures/cassettes/llm_client__get_chat_completion__anthropic.yaml @@ -0,0 +1,211 @@ +interactions: +- request: + body: '{"api_key": "pl_sanitized", "label": "dev", "input_variables": + {"message": "what is your name? return JSON"}, "metadata_filters": {"environment": + "local-unittests-dmu"}}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '192' + Content-Type: + - application/json + User-Agent: + - python-requests/2.31.0 + X-API-KEY: + - sanitized + method: POST + uri: https://api.promptlayer.com/prompt-templates/testing-dmu + response: + body: + string: '{"commit_message":"Initial","id":27629,"llm_kwargs":{"max_tokens":256,"messages":[{"content":[{"text":"what + is your name? return JSON","type":"text"}],"role":"user"}],"model":"claude-3-5-sonnet-20240620","system":"Give + the shortest reply possible: 3 words at most","temperature":1,"top_k":0,"top_p":0},"metadata":{"model":{"name":"claude-3-5-sonnet-20240620","parameters":{"max_tokens":256,"temperature":1,"top_k":0,"top_p":0},"provider":"anthropic"}},"prompt_name":"testing-dmu","prompt_template":{"function_call":"none","functions":[],"input_variables":["message"],"messages":[{"content":[{"text":"Give + the shortest reply possible: 3 words at most","type":"text"}],"input_variables":[],"name":null,"raw_request_display_role":"","role":"system","template_format":"f-string"},{"content":[{"text":"{message}","type":"text"}],"input_variables":["message"],"name":null,"raw_request_display_role":"","role":"user","template_format":"f-string"}],"tool_choice":null,"tools":null,"type":"chat"},"provider_base_url":null,"snippets":[],"tags":[],"version":1,"workspace_id":8353} + + ' + headers: + Access-Control-Allow-Origin: + - '*' + CF-Cache-Status: + - DYNAMIC + CF-Ray: + - 8afd55afe8bf9d9a-DME + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Thu, 08 Aug 2024 06:08:56 GMT + Server: + - cloudflare + Transfer-Encoding: + - chunked + Vary: + - Accept-Encoding + alt-svc: + - h3=":443"; ma=86400 + content-length: + - '1070' + rndr-id: + - 89e922ed-088e-4bed + x-render-origin-server: + - gunicorn + status: + code: 200 + message: OK +- request: + body: '{"max_tokens": 256, "messages": [{"content": [{"text": "what is your name? + return JSON", "type": "text"}], "role": "user"}], "model": "claude-3-5-sonnet-20240620", + "stream": false, "system": "Give the shortest reply possible: 3 words at most", + "temperature": 1, "top_k": 0, "top_p": 0}' + headers: + X-API-KEY: + - sanitized + accept: + - application/json + accept-encoding: + - gzip, deflate + anthropic-version: + - '2023-06-01' + connection: + - keep-alive + content-length: + - '285' + content-type: + - application/json + host: + - api.anthropic.com + user-agent: + - Anthropic/Python 0.32.0 + x-stainless-arch: + - x64 + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - Linux + x-stainless-package-version: + - 0.32.0 + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.10.13 + method: POST + uri: https://api.anthropic.com/v1/messages + response: + body: + string: '{"id":"msg_01AzbQ7d6oSQs9ny858MaaKV","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[{"type":"text","text":"{\"name\":\"Claude\"}"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":26,"output_tokens":8}}' + headers: + CF-Cache-Status: + - DYNAMIC + CF-RAY: + - 8afd55b57cda9d9a-DME + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Thu, 08 Aug 2024 06:08:58 GMT + Server: + - cloudflare + Transfer-Encoding: + - chunked + X-Robots-Tag: + - none + anthropic-ratelimit-requests-limit: + - '50' + anthropic-ratelimit-requests-remaining: + - '49' + anthropic-ratelimit-requests-reset: + - '2024-08-08T06:09:25Z' + anthropic-ratelimit-tokens-limit: + - '40000' + anthropic-ratelimit-tokens-remaining: + - '40000' + anthropic-ratelimit-tokens-reset: + - '2024-08-08T06:08:57Z' + content-length: + - '261' + request-id: + - req_01Hci3jNDccLF4wd1MX7NZJT + via: + - 1.1 google + x-cloud-trace-context: + - b34832edf7c817216d343f7127a09239 + status: + code: 200 + message: OK +- request: + body: '{"function_name": "anthropic.messages.create", "provider_type": "anthropic", + "args": [], "kwargs": {"max_tokens": 256, "messages": [{"content": [{"text": + "what is your name? return JSON", "type": "text"}], "role": "user"}], "model": + "claude-3-5-sonnet-20240620", "system": "Give the shortest reply possible: 3 + words at most", "temperature": 1, "top_k": 0, "top_p": 0, "stream": false}, + "tags": null, "request_start_time": 1723097336.643463, "request_end_time": 1723097338.207317, + "api_key": "pl_sanitized", "metadata": {"environment": + "local-unittests-dmu"}, "prompt_id": 27629, "prompt_version": 1, "prompt_input_variables": + {"message": "what is your name? return JSON"}, "group_id": null, "return_prompt_blueprint": + true, "request_response": {"id": "msg_01AzbQ7d6oSQs9ny858MaaKV", "content": + [{"text": "{\"name\":\"Claude\"}", "type": "text"}], "model": "claude-3-5-sonnet-20240620", + "role": "assistant", "stop_reason": "end_turn", "stop_sequence": null, "type": + "message", "usage": {"input_tokens": 26, "output_tokens": 8}}}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '1050' + Content-Type: + - application/json + User-Agent: + - python-requests/2.31.0 + method: POST + uri: https://api.promptlayer.com/track-request + response: + body: + string: '{"prompt_blueprint":{"commit_message":null,"metadata":{"model":{"name":"claude-3-5-sonnet-20240620","parameters":{"max_tokens":256,"stream":false,"system":"Give + the shortest reply possible: 3 words at most","temperature":1,"top_k":0,"top_p":0},"provider":"anthropic"}},"prompt_template":{"function_call":null,"functions":null,"input_variables":["\"name\""],"messages":[{"content":[{"text":"Give + the shortest reply possible: 3 words at most","type":"text"}],"input_variables":[],"name":null,"raw_request_display_role":"","role":"system","template_format":"f-string"},{"content":[{"text":"what + is your name? return JSON","type":"text"}],"input_variables":[],"name":null,"raw_request_display_role":"user","role":"user","template_format":"f-string"},{"content":[{"text":"{\"name\":\"Claude\"}","type":"text"}],"function_call":null,"input_variables":["\"name\""],"name":null,"raw_request_display_role":"assistant","role":"assistant","template_format":"f-string","tool_calls":null}],"tool_choice":null,"tools":[],"type":"chat"},"provider_base_url_name":null,"report_id":null},"request_id":72305093,"success":true,"success_metadata":true} + + ' + headers: + Access-Control-Allow-Origin: + - '*' + CF-Cache-Status: + - DYNAMIC + CF-Ray: + - 8afd55be4fb89db9-DME + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Thu, 08 Aug 2024 06:08:58 GMT + Server: + - cloudflare + Transfer-Encoding: + - chunked + Vary: + - Accept-Encoding + alt-svc: + - h3=":443"; ma=86400 + content-length: + - '1132' + rndr-id: + - c536449b-c5ad-4d27 + x-render-origin-server: + - gunicorn + status: + code: 200 + message: OK +version: 1 diff --git a/thenewboston/general/tests/fixtures/mocks/misc.py b/thenewboston/general/tests/fixtures/mocks/misc.py index f379c4c..51b0a7e 100644 --- a/thenewboston/general/tests/fixtures/mocks/misc.py +++ b/thenewboston/general/tests/fixtures/mocks/misc.py @@ -12,6 +12,7 @@ def unittest_settings(): # The exposed signing key is used for testing only SIGNING_KEY=settings.SIGNING_KEY or '756eb20e5569a0c906ccb813263aa27159aeafa07d7208f860ae290c03066c51', OPENAI_API_KEY=settings.OPENAI_API_KEY or 'abc123', + ANTHROPIC_API_KEY=settings.ANTHROPIC_API_KEY or 'abc123', PROMPTLAYER_API_KEY=settings.PROMPTLAYER_API_KEY or 'abc123', GITHUB_API_ACCESS_TOKEN=settings.GITHUB_API_ACCESS_TOKEN or 'abc123', ): diff --git a/thenewboston/general/tests/test_llm_client.py b/thenewboston/general/tests/test_llm_client.py new file mode 100644 index 0000000..e6098a2 --- /dev/null +++ b/thenewboston/general/tests/test_llm_client.py @@ -0,0 +1,110 @@ +import json + +from thenewboston.general.clients.llm import LLMClient +from thenewboston.general.tests.vcr import assert_played, yield_cassette +from thenewboston.general.utils.json import ResponseEncoder + + +def test_get_chat_completion__anthropic(): + with ( + yield_cassette('llm_client__get_chat_completion__anthropic.yaml') as cassette, + assert_played(cassette, count=3), + ): + response = LLMClient.get_instance().get_chat_completion( + 'testing-dmu', + input_variables={'message': 'what is your name? return JSON'}, + prompt_label='dev', + ) + + assert response == {'name': 'Claude'} + + with ( + yield_cassette('llm_client__get_chat_completion__anthropic.yaml') as cassette, + assert_played(cassette, count=3), + ): + response = LLMClient.get_instance().get_chat_completion( + 'testing-dmu', + input_variables={'message': 'what is your name? return JSON'}, + prompt_label='dev', + format_result=False, + ) + + assert json.loads(json.dumps(response, cls=ResponseEncoder)) == { + 'request_id': 72305093, + 'raw_response': { + 'id': 'msg_01AzbQ7d6oSQs9ny858MaaKV', + 'content': [{ + 'text': '{"name":"Claude"}', + 'type': 'text' + }], + 'model': 'claude-3-5-sonnet-20240620', + 'role': 'assistant', + 'stop_reason': 'end_turn', + 'stop_sequence': None, + 'type': 'message', + 'usage': { + 'input_tokens': 26, + 'output_tokens': 8 + } + }, + 'prompt_blueprint': { + 'commit_message': None, + 'metadata': { + 'model': { + 'name': 'claude-3-5-sonnet-20240620', + 'parameters': { + 'max_tokens': 256, + 'stream': False, + 'system': 'Give the shortest reply possible: 3 words at most', + 'temperature': 1, + 'top_k': 0, + 'top_p': 0 + }, + 'provider': 'anthropic' + } + }, + 'prompt_template': { + 'function_call': None, + 'functions': None, + 'input_variables': ['"name"'], + 'messages': [{ + 'content': [{ + 'text': 'Give the shortest reply possible: 3 words at most', + 'type': 'text' + }], + 'input_variables': [], + 'name': None, + 'raw_request_display_role': '', + 'role': 'system', + 'template_format': 'f-string' + }, { + 'content': [{ + 'text': 'what is your name? return JSON', + 'type': 'text' + }], + 'input_variables': [], + 'name': None, + 'raw_request_display_role': 'user', + 'role': 'user', + 'template_format': 'f-string' + }, { + 'content': [{ + 'text': '{"name":"Claude"}', + 'type': 'text' + }], + 'function_call': None, + 'input_variables': ['"name"'], + 'name': None, + 'raw_request_display_role': 'assistant', + 'role': 'assistant', + 'template_format': 'f-string', + 'tool_calls': None + }], + 'tool_choice': None, + 'tools': [], + 'type': 'chat' + }, + 'provider_base_url_name': None, + 'report_id': None + } + } diff --git a/thenewboston/general/utils/json.py b/thenewboston/general/utils/json.py new file mode 100644 index 0000000..5f705c7 --- /dev/null +++ b/thenewboston/general/utils/json.py @@ -0,0 +1,13 @@ +import json + +from anthropic import BaseModel as AnthropicBaseModel +from openai import BaseModel as OpenAIBaseModel + + +class ResponseEncoder(json.JSONEncoder): + + def default(self, obj): + if isinstance(obj, (OpenAIBaseModel, AnthropicBaseModel)): + return obj.to_dict() + + return super().default(obj) diff --git a/thenewboston/github/models/pull.py b/thenewboston/github/models/pull.py index f3cf820..f2b40b5 100644 --- a/thenewboston/github/models/pull.py +++ b/thenewboston/github/models/pull.py @@ -1,7 +1,7 @@ from django.conf import settings from django.db import models -from thenewboston.general.clients.openai import OpenAIClient +from thenewboston.general.clients.llm import LLMClient, make_prompt_kwargs from thenewboston.general.models import CreatedModified from thenewboston.general.utils.misc import null_object from thenewboston.github.client import GitHubClient @@ -49,11 +49,11 @@ def fetch_diff(self): def assess(self, save=True): # All potential exceptions must be handled by the caller of the method - result = OpenAIClient.get_instance().get_chat_completion( - settings.GITHUB_PR_ASSESSMENT_PROMPT_NAME, + result = LLMClient.get_instance().get_chat_completion( input_variables={'git_diff': self.fetch_diff()}, tracked_user=(self.github_user or null_object).reward_recipient, tags=['github_pr_assessment'], + **make_prompt_kwargs(settings.GITHUB_PR_ASSESSMENT_PROMPT_NAME), ) self.assessment_points = result['assessment'] self.assessment_explanation = result['explanation'] diff --git a/thenewboston/project/settings/custom.py b/thenewboston/project/settings/custom.py index 464c5f1..27f017c 100644 --- a/thenewboston/project/settings/custom.py +++ b/thenewboston/project/settings/custom.py @@ -17,25 +17,33 @@ LOGGING_MIDDLEWARE_SKIPPED_REQUEST_MEDIA_TYPES = ('multipart/form-data',) LOGGING_MIDDLEWARE_SKIPPED_RESPONSE_MEDIA_TYPES = ('text/html', 'text/javascript') -# OpenAI related -OPENAI_API_KEY = None +# PromptLayer PROMPTLAYER_API_KEY = None +PROMPT_DEFAULT_LABEL = 'prod' + +# OpenAI +OPENAI_API_KEY = None OPENAI_IMAGE_GENERATION_MODEL = 'dall-e-2' OPENAI_IMAGE_GENERATION_DEFAULT_SIZE = '1024x1024' OPENAI_IMAGE_GENERATION_DEFAULT_QUALITY = 'standard' -# Github related +# Anthropic +ANTHROPIC_API_KEY = None + +# Github +GITHUB_API_ACCESS_TOKEN = None + +# Prompts CREATE_MESSAGE_PROMPT_NAME = 'create-message' GITHUB_PR_ASSESSMENT_PROMPT_NAME = 'github-pr-assessment' GITHUB_MANUAL_CONTRIBUTION_ASSESSMENT_PROMPT_NAME = 'manual-assessment' DISCORD_CREATE_RESPONSE_PROMPT_NAME = 'create-response' DISCORD_IS_RESPONSE_WARRANTED_PROMPT_NAME = 'is-response-warranted' -PROMPT_TEMPLATE_LABEL = 'prod' -GITHUB_API_ACCESS_TOKEN = None # Discord DISCORD_BOT_TOKEN = None DISCORD_MESSAGE_HISTORY_LIMIT = 30 +IA_DISCORD_USER_ID = None # Misc DEFAULT_CORE_TICKER = 'TNB' diff --git a/thenewboston/project/settings/templates/template.env b/thenewboston/project/settings/templates/template.env index 8189112..8b36813 100644 --- a/thenewboston/project/settings/templates/template.env +++ b/thenewboston/project/settings/templates/template.env @@ -6,9 +6,11 @@ THENEWBOSTON_SETTING_SIGNING_KEY='' THENEWBOSTON_SETTING_ACCOUNT_NUMBER='' THENEWBOSTON_SETTING_OPENAI_API_KEY='' +THENEWBOSTON_SETTING_ANTHROPIC_API_KEY='' THENEWBOSTON_SETTING_PROMPTLAYER_API_KEY='' THENEWBOSTON_SETTING_GITHUB_API_ACCESS_TOKEN='' THENEWBOSTON_SETTING_DISCORD_BOT_TOKEN='' +THENEWBOSTON_SETTING_IA_DISCORD_USER_ID= THENEWBOSTON_SETTING_AWS_ACCESS_KEY_ID='' THENEWBOSTON_SETTING_AWS_SECRET_ACCESS_KEY='' diff --git a/thenewboston/project/tasks.py b/thenewboston/project/tasks.py index f9ae22e..6654fc3 100644 --- a/thenewboston/project/tasks.py +++ b/thenewboston/project/tasks.py @@ -1,7 +1,7 @@ from celery import shared_task from django.conf import settings -from thenewboston.general.clients.openai import OpenAIClient +from thenewboston.general.clients.llm import LLMClient, make_prompt_kwargs from thenewboston.general.enums import MessageType from thenewboston.ia.consumers.message import MessageConsumer from thenewboston.ia.models import Message @@ -15,10 +15,10 @@ def generate_ias_response(conversation_id): conversation = Conversation.objects.get(id=conversation_id) - chat_completion_text = OpenAIClient.get_instance().get_chat_completion( - settings.CREATE_MESSAGE_PROMPT_NAME, + chat_completion_text = LLMClient.get_instance().get_chat_completion( input_variables={'messages': get_non_system_messages(conversation_id)}, tracked_user=conversation.owner, + **make_prompt_kwargs(settings.CREATE_MESSAGE_PROMPT_NAME), ) message = Message.objects.create(