From 59f64a596a3086b21898136e4a67a72ed7f5e7c0 Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Fri, 16 Jan 2026 14:13:43 -0500 Subject: [PATCH 1/4] Enhance LLM integration and configuration - Added support for multiple LLM providers (OpenAI, Anthropic, LiteLLM) through a unified client interface. - Updated environment configuration to include new API keys and options for LLM providers. - Refactored analysis and tagging functions to utilize the new LLM client, improving modularity and maintainability. - Enhanced test coverage for LLM-related functionalities, ensuring robust integration and error handling. This change streamlines the integration of various LLM services and improves the overall architecture of the analysis components. --- .gitignore | 2 + conftest.py | 12 +- env.test.example | 12 + poetry.lock | 452 +++++++- prod_mgt/05_EXTERNAL_INTEGRATIONS.md | 2 +- pyproject.toml | 2 + server/lib/llm_client.py | 504 +++++++++ server/lib/tests/__init__.py | 1 + server/lib/tests/test_llm_client.py | 532 ++++++++++ .../lib/tests/test_llm_client_integration.py | 342 ++++++ server/links/HOW_TO_CREATE_EXTERNAL_LINK.md | 988 ++++++++++++++++++ server/links/HOW_TO_CREATE_LINKS.md | 754 +++++++++++++ server/links/analyze/__init__.py | 90 +- server/links/analyze/tests/test_analyze.py | 397 +++---- server/links/analyze_and_label/__init__.py | 104 +- .../tests/test_analyze_and_label.py | 420 ++++---- server/links/analyze_vcon/__init__.py | 117 ++- server/links/check_and_tag/__init__.py | 88 +- server/links/detect_engagement/__init__.py | 112 +- .../tests/test_detect_engagement.py | 129 ++- server/settings.py | 12 + server/storage/chatgpt_files/README.md | 6 +- server/storage/chatgpt_files/__init__.py | 2 +- uv.lock | 3 + 24 files changed, 4413 insertions(+), 670 deletions(-) create mode 100644 env.test.example create mode 100644 server/lib/llm_client.py create mode 100644 server/lib/tests/__init__.py create mode 100644 server/lib/tests/test_llm_client.py create mode 100644 server/lib/tests/test_llm_client_integration.py create mode 100644 server/links/HOW_TO_CREATE_EXTERNAL_LINK.md create mode 100644 server/links/HOW_TO_CREATE_LINKS.md create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index 45efe7c..1c5002d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ *.env +.env +.env.* venv **/__pycache__ .DS_Store diff --git a/conftest.py b/conftest.py index ba3b23c..5ad8435 100644 --- a/conftest.py +++ b/conftest.py @@ -1,8 +1,18 @@ # conftest.py import pytest from dotenv import load_dotenv +from pathlib import Path @pytest.fixture(scope="session", autouse=True) def load_env(pytestconfig): - load_dotenv(".env.test") + # Prefer local, untracked secrets in `.env.test`. Fall back to a committed template + # (`env.test.example`) so tests can run without creating `.env.test`. + root = Path(__file__).resolve().parent + local_env = root / ".env.test" + example_env = root / "env.test.example" + + if local_env.exists(): + load_dotenv(local_env, override=True) + elif example_env.exists(): + load_dotenv(example_env, override=True) diff --git a/env.test.example b/env.test.example new file mode 100644 index 0000000..fbd58ea --- /dev/null +++ b/env.test.example @@ -0,0 +1,12 @@ +CONSERVER_API_TOKEN=fake-token +DEEPGRAM_KEY=fake-deepgram-key +REDIS_URL=redis://localhost:6379 + +# LLM API Keys (set these locally in .env.test, do not commit real keys) +ANTHROPIC_API_KEY= +OPENAI_API_KEY= +GROQ_API_KEY= + +# Enable integration tests that call real APIs (default off) +RUN_OPENAI_ANALYZE_TESTS=0 + diff --git a/poetry.lock b/poetry.lock index e863e13..08e58ba 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "aenum" @@ -10,6 +10,7 @@ groups = ["main"] files = [ {file = "aenum-3.1.16-py2-none-any.whl", hash = "sha256:7810cbb6b4054b7654e5a7bafbe16e9ee1d25ef8e397be699f63f2f3a5800433"}, {file = "aenum-3.1.16-py3-none-any.whl", hash = "sha256:9035092855a98e41b66e3d0998bd7b96280e85ceb3a04cc035636138a1943eaf"}, + {file = "aenum-3.1.16.tar.gz", hash = "sha256:bfaf9589bdb418ee3a986d85750c7318d9d2839c1b1a1d6fe8fc53ec201cf140"}, ] [[package]] @@ -166,6 +167,33 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "anthropic" +version = "0.76.0" +description = "The official Python library for the anthropic API" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anthropic-0.76.0-py3-none-any.whl", hash = "sha256:81efa3113901192af2f0fe977d3ec73fdadb1e691586306c4256cd6d5ccc331c"}, + {file = "anthropic-0.76.0.tar.gz", hash = "sha256:e0cae6a368986d5cf6df743dfbb1b9519e6a9eee9c6c942ad8121c0b34416ffe"}, +] + +[package.dependencies] +anyio = ">=3.5.0,<5" +distro = ">=1.7.0,<2" +docstring-parser = ">=0.15,<1" +httpx = ">=0.25.0,<1" +jiter = ">=0.4.0,<1" +pydantic = ">=1.9.0,<3" +sniffio = "*" +typing-extensions = ">=4.10,<5" + +[package.extras] +aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"] +bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"] +vertex = ["google-auth[requests] (>=2,<3)"] + [[package]] name = "anyio" version = "4.9.0" @@ -747,6 +775,23 @@ idna = ["idna (>=3.7)"] trio = ["trio (>=0.23)"] wmi = ["wmi (>=1.5.1)"] +[[package]] +name = "docstring-parser" +version = "0.17.0" +description = "Parse Python docstrings in reST, Google and Numpydoc format" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708"}, + {file = "docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912"}, +] + +[package.extras] +dev = ["pre-commit (>=2.16.0) ; python_version >= \"3.9\"", "pydoctor (>=25.4.0)", "pytest"] +docs = ["pydoctor (>=25.4.0)"] +test = ["pytest"] + [[package]] name = "ecdsa" version = "0.18.0" @@ -1323,6 +1368,24 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + [[package]] name = "jiter" version = "0.9.0" @@ -1526,6 +1589,175 @@ files = [ {file = "jq-1.8.0.tar.gz", hash = "sha256:53141eebca4bf8b4f2da5e44271a8a3694220dfd22d2b4b2cfb4816b2b6c9057"}, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, + {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.03.6" +referencing = ">=0.28.4" +rpds-py = ">=0.25.0" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, + {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + +[[package]] +name = "litellm" +version = "1.75.3" +description = "Library to easily interface with LLM API providers" +optional = false +python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8" +groups = ["main"] +files = [ + {file = "litellm-1.75.3-py3-none-any.whl", hash = "sha256:0ff3752b1f1c07f8a4b9a364b1595e2147ae640f1e77cd8312e6f6a5ca0f34ec"}, + {file = "litellm-1.75.3.tar.gz", hash = "sha256:a6a0f33884f35a9391a9a4363043114d7f2513ab2e5c2e1fa54c56d695663764"}, +] + +[package.dependencies] +aiohttp = ">=3.10" +click = "*" +httpx = ">=0.23.0" +importlib-metadata = ">=6.8.0" +jinja2 = ">=3.1.2,<4.0.0" +jsonschema = ">=4.22.0,<5.0.0" +openai = ">=1.68.2" +pydantic = ">=2.5.0,<3.0.0" +python-dotenv = ">=0.2.0" +tiktoken = ">=0.7.0" +tokenizers = "*" + +[package.extras] +caching = ["diskcache (>=5.6.1,<6.0.0)"] +extra-proxy = ["azure-identity (>=1.15.0,<2.0.0)", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0,<0.9.0)"] +mlflow = ["mlflow (>3.1.4) ; python_version >= \"3.10\""] +proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0)", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.34.34)", "cryptography (>=43.0.1,<44.0.0)", "fastapi (>=0.115.5,<0.116.0)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.19)", "litellm-proxy-extras (==0.2.16)", "mcp (>=1.10.0,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "uvicorn (>=0.29.0,<0.30.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=13.1.0,<14.0.0)"] +semantic-router = ["semantic-router ; python_version >= \"3.9\""] +utils = ["numpydoc"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + [[package]] name = "marshmallow" version = "3.26.1" @@ -2951,6 +3183,23 @@ files = [ hiredis = ["hiredis (>=1.0.0)"] ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] +[[package]] +name = "referencing" +version = "0.37.0" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, + {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" +typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} + [[package]] name = "regex" version = "2024.11.6" @@ -3077,6 +3326,131 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rpds-py" +version = "0.30.0" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"}, + {file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"}, + {file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"}, + {file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"}, + {file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"}, + {file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"}, + {file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"}, + {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, +] + [[package]] name = "s3transfer" version = "0.12.0" @@ -3296,6 +3670,80 @@ files = [ doc = ["reno", "sphinx"] test = ["pytest", "tornado (>=4.5)", "typeguard"] +[[package]] +name = "tiktoken" +version = "0.12.0" +description = "tiktoken is a fast BPE tokeniser for use with OpenAI's models" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tiktoken-0.12.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3de02f5a491cfd179aec916eddb70331814bd6bf764075d39e21d5862e533970"}, + {file = "tiktoken-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b6cfb6d9b7b54d20af21a912bfe63a2727d9cfa8fbda642fd8322c70340aad16"}, + {file = "tiktoken-0.12.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:cde24cdb1b8a08368f709124f15b36ab5524aac5fa830cc3fdce9c03d4fb8030"}, + {file = "tiktoken-0.12.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6de0da39f605992649b9cfa6f84071e3f9ef2cec458d08c5feb1b6f0ff62e134"}, + {file = "tiktoken-0.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6faa0534e0eefbcafaccb75927a4a380463a2eaa7e26000f0173b920e98b720a"}, + {file = "tiktoken-0.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:82991e04fc860afb933efb63957affc7ad54f83e2216fe7d319007dab1ba5892"}, + {file = "tiktoken-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:6fb2995b487c2e31acf0a9e17647e3b242235a20832642bb7a9d1a181c0c1bb1"}, + {file = "tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb"}, + {file = "tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa"}, + {file = "tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc"}, + {file = "tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded"}, + {file = "tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd"}, + {file = "tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967"}, + {file = "tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def"}, + {file = "tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8"}, + {file = "tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b"}, + {file = "tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37"}, + {file = "tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad"}, + {file = "tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5"}, + {file = "tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3"}, + {file = "tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd"}, + {file = "tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3"}, + {file = "tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160"}, + {file = "tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa"}, + {file = "tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be"}, + {file = "tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a"}, + {file = "tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3"}, + {file = "tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697"}, + {file = "tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16"}, + {file = "tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a"}, + {file = "tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27"}, + {file = "tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb"}, + {file = "tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e"}, + {file = "tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25"}, + {file = "tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f"}, + {file = "tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646"}, + {file = "tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88"}, + {file = "tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff"}, + {file = "tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830"}, + {file = "tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b"}, + {file = "tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b"}, + {file = "tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3"}, + {file = "tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365"}, + {file = "tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e"}, + {file = "tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63"}, + {file = "tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0"}, + {file = "tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a"}, + {file = "tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0"}, + {file = "tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71"}, + {file = "tiktoken-0.12.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:d51d75a5bffbf26f86554d28e78bfb921eae998edc2675650fd04c7e1f0cdc1e"}, + {file = "tiktoken-0.12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:09eb4eae62ae7e4c62364d9ec3a57c62eea707ac9a2b2c5d6bd05de6724ea179"}, + {file = "tiktoken-0.12.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:df37684ace87d10895acb44b7f447d4700349b12197a526da0d4a4149fde074c"}, + {file = "tiktoken-0.12.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:4c9614597ac94bb294544345ad8cf30dac2129c05e2db8dc53e082f355857af7"}, + {file = "tiktoken-0.12.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:20cf97135c9a50de0b157879c3c4accbb29116bcf001283d26e073ff3b345946"}, + {file = "tiktoken-0.12.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:15d875454bbaa3728be39880ddd11a5a2a9e548c29418b41e8fd8a767172b5ec"}, + {file = "tiktoken-0.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cff3688ba3c639ebe816f8d58ffbbb0aa7433e23e08ab1cade5d175fc973fb3"}, + {file = "tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931"}, +] + +[package.dependencies] +regex = ">=2022.1.18" +requests = ">=2.26.0" + +[package.extras] +blobfile = ["blobfile (>=2)"] + [[package]] name = "tokenizers" version = "0.21.1" @@ -3948,4 +4396,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.12,<3.14" -content-hash = "957b1ddb4b734e287a9597c7b32cbcfbe9e605d0079b09ea96d025ad177e9e6e" +content-hash = "e1c313c2120478e7809517a7125ded73a00cee46dcad4faeedb392541fd8e625" diff --git a/prod_mgt/05_EXTERNAL_INTEGRATIONS.md b/prod_mgt/05_EXTERNAL_INTEGRATIONS.md index 1e834eb..72f2b42 100644 --- a/prod_mgt/05_EXTERNAL_INTEGRATIONS.md +++ b/prod_mgt/05_EXTERNAL_INTEGRATIONS.md @@ -18,7 +18,7 @@ vCon Server integrates with numerous external services for transcription, analys **Configuration**: ```yaml -OPENAI_API_KEY: sk-your-api-key +OPENAI_API_KEY: YOUR_OPENAI_API_KEY model: gpt-4 temperature: 0 ``` diff --git a/pyproject.toml b/pyproject.toml index f299dc7..e2ff9ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,8 @@ slack-sdk = "^3.27.1" boto3 = "^1.34.52" deepgram-sdk = "^3.1.5" openai = ">=1.60.0" +anthropic = ">=0.40.0" +litellm = ">=1.50.0" groq = "^0.4.0" psycopg2-binary = "^2.9.9" pymongo = "^4.7.2" diff --git a/server/lib/llm_client.py b/server/lib/llm_client.py new file mode 100644 index 0000000..9dae010 --- /dev/null +++ b/server/lib/llm_client.py @@ -0,0 +1,504 @@ +""" +LLM client abstraction for vcon-server. + +Provides a unified interface to multiple LLM providers: +- Native OpenAI SDK for gpt-* and azure/* models +- Native Anthropic SDK for claude-* models +- LiteLLM fallback for all other providers (100+) + +Supports global defaults with per-link override configuration. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Optional, Dict, Any, List +import logging + +from openai import OpenAI, AzureOpenAI +import anthropic +import litellm +from tenacity import ( + retry, + stop_after_attempt, + wait_exponential, + before_sleep_log, +) + +from lib.logging_utils import init_logger +from lib.ai_usage import send_ai_usage_data_for_tracking + +logger = init_logger(__name__) + + +@dataclass +class LLMConfig: + """Configuration for LLM client. + + Attributes: + model: Model identifier (e.g., "gpt-4", "claude-3-opus-20240229") + temperature: Sampling temperature (0.0-2.0) + max_tokens: Maximum tokens in response + response_format: Optional response format (e.g., {"type": "json_object"}) + timeout: Request timeout in seconds + + # Provider credentials (auto-selected based on model prefix) + openai_api_key: OpenAI API key + azure_api_key: Azure OpenAI API key + azure_api_base: Azure OpenAI endpoint + azure_api_version: Azure API version + anthropic_api_key: Anthropic API key + + # Additional provider-specific settings + extra_params: Additional parameters passed to completion calls + """ + model: str = "gpt-3.5-turbo" + temperature: float = 0.0 + max_tokens: Optional[int] = None + response_format: Optional[Dict[str, Any]] = None + timeout: float = 120.0 + + # Credentials + openai_api_key: Optional[str] = None + azure_api_key: Optional[str] = None + azure_api_base: Optional[str] = None + azure_api_version: Optional[str] = None + anthropic_api_key: Optional[str] = None + + # System prompt (for providers that handle it differently) + system_prompt: Optional[str] = None + + # Extra parameters + extra_params: Dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_options(cls, opts: Dict[str, Any], defaults: Optional['LLMConfig'] = None) -> 'LLMConfig': + """Create config from link options with optional defaults. + + Priority: opts > defaults > class defaults + """ + base = defaults or cls() + return cls( + model=opts.get("model", base.model), + temperature=opts.get("temperature", base.temperature), + max_tokens=opts.get("max_tokens", base.max_tokens), + response_format=opts.get("response_format", base.response_format), + timeout=opts.get("timeout", base.timeout), + openai_api_key=opts.get("OPENAI_API_KEY", base.openai_api_key), + azure_api_key=opts.get("AZURE_OPENAI_API_KEY", base.azure_api_key), + azure_api_base=opts.get("AZURE_OPENAI_ENDPOINT", base.azure_api_base), + azure_api_version=opts.get("AZURE_OPENAI_API_VERSION", base.azure_api_version), + anthropic_api_key=opts.get("ANTHROPIC_API_KEY", base.anthropic_api_key), + system_prompt=opts.get("system_prompt", base.system_prompt), + extra_params=opts.get("llm_extra_params", base.extra_params), + ) + + +@dataclass +class LLMResponse: + """Standardized response from LLM completion.""" + content: str + model: str + prompt_tokens: int + completion_tokens: int + total_tokens: int + raw_response: Any # Original provider response + provider: str # "openai", "anthropic", or "litellm" + + +def detect_provider(model: str) -> str: + """Detect the appropriate provider based on model name. + + Args: + model: Model identifier string + + Returns: + Provider name: "openai", "anthropic", or "litellm" + """ + model_lower = model.lower() + + # OpenAI models + if model_lower.startswith(("gpt-", "o1", "o3", "azure/")): + return "openai" + + # Anthropic models + if model_lower.startswith("claude"): + return "anthropic" + + # Everything else goes through LiteLLM + return "litellm" + + +class BaseProvider(ABC): + """Abstract base class for LLM providers.""" + + @abstractmethod + def complete( + self, + messages: List[Dict[str, str]], + config: LLMConfig, + **kwargs + ) -> LLMResponse: + """Execute a chat completion request.""" + pass + + +class OpenAIProvider(BaseProvider): + """Provider for OpenAI and Azure OpenAI models.""" + + def __init__(self, config: LLMConfig): + self.config = config + self._client: Optional[OpenAI] = None + self._is_azure = False + + def _get_client(self) -> OpenAI: + """Get or create the OpenAI client.""" + if self._client is None: + if self.config.azure_api_key and self.config.azure_api_base: + self._client = AzureOpenAI( + api_key=self.config.azure_api_key, + azure_endpoint=self.config.azure_api_base, + api_version=self.config.azure_api_version or "2024-02-15-preview", + ) + self._is_azure = True + logger.debug(f"Using Azure OpenAI client at {self.config.azure_api_base}") + elif self.config.openai_api_key: + self._client = OpenAI( + api_key=self.config.openai_api_key, + timeout=self.config.timeout, + max_retries=0, # We handle retries with tenacity + ) + logger.debug("Using OpenAI client") + else: + raise ValueError( + "OpenAI credentials not provided. " + "Need OPENAI_API_KEY or AZURE_OPENAI_API_KEY and AZURE_OPENAI_ENDPOINT" + ) + return self._client + + @retry( + wait=wait_exponential(multiplier=2, min=1, max=65), + stop=stop_after_attempt(6), + before_sleep=before_sleep_log(logger, logging.INFO), + ) + def complete( + self, + messages: List[Dict[str, str]], + config: LLMConfig, + **kwargs + ) -> LLMResponse: + """Execute OpenAI chat completion.""" + client = self._get_client() + + params = { + "model": config.model, + "messages": messages, + "temperature": config.temperature, + } + + if config.max_tokens: + params["max_tokens"] = config.max_tokens + + if config.response_format: + params["response_format"] = config.response_format + + # Merge extra params and kwargs + params.update(config.extra_params) + params.update(kwargs) + + response = client.chat.completions.create(**params) + + return LLMResponse( + content=response.choices[0].message.content, + model=response.model, + prompt_tokens=response.usage.prompt_tokens, + completion_tokens=response.usage.completion_tokens, + total_tokens=response.usage.total_tokens, + raw_response=response, + provider="openai", + ) + + +class AnthropicProvider(BaseProvider): + """Provider for Anthropic Claude models.""" + + def __init__(self, config: LLMConfig): + self.config = config + self._client: Optional[anthropic.Anthropic] = None + + def _get_client(self) -> anthropic.Anthropic: + """Get or create the Anthropic client.""" + if self._client is None: + if not self.config.anthropic_api_key: + raise ValueError("Anthropic API key not provided. Need ANTHROPIC_API_KEY") + self._client = anthropic.Anthropic( + api_key=self.config.anthropic_api_key, + timeout=self.config.timeout, + ) + logger.debug("Using Anthropic client") + return self._client + + def _convert_messages( + self, + messages: List[Dict[str, str]] + ) -> tuple[Optional[str], List[Dict[str, str]]]: + """Convert OpenAI message format to Anthropic format. + + Anthropic requires system message to be passed separately. + + Returns: + Tuple of (system_message, converted_messages) + """ + system_message = None + converted = [] + + for msg in messages: + if msg["role"] == "system": + system_message = msg["content"] + else: + converted.append({ + "role": msg["role"], + "content": msg["content"], + }) + + return system_message, converted + + @retry( + wait=wait_exponential(multiplier=2, min=1, max=65), + stop=stop_after_attempt(6), + before_sleep=before_sleep_log(logger, logging.INFO), + ) + def complete( + self, + messages: List[Dict[str, str]], + config: LLMConfig, + **kwargs + ) -> LLMResponse: + """Execute Anthropic chat completion.""" + client = self._get_client() + + system_message, converted_messages = self._convert_messages(messages) + + # Use system from config if not in messages + if not system_message and config.system_prompt: + system_message = config.system_prompt + + params = { + "model": config.model, + "messages": converted_messages, + "max_tokens": config.max_tokens or 4096, # Anthropic requires max_tokens + } + + if system_message: + params["system"] = system_message + + if config.temperature > 0: + params["temperature"] = config.temperature + + # Merge extra params and kwargs + params.update(config.extra_params) + params.update(kwargs) + + response = client.messages.create(**params) + + # Extract text from content blocks + content = "" + for block in response.content: + if block.type == "text": + content += block.text + + return LLMResponse( + content=content, + model=response.model, + prompt_tokens=response.usage.input_tokens, + completion_tokens=response.usage.output_tokens, + total_tokens=response.usage.input_tokens + response.usage.output_tokens, + raw_response=response, + provider="anthropic", + ) + + +class LiteLLMProvider(BaseProvider): + """Provider for all other models via LiteLLM.""" + + def __init__(self, config: LLMConfig): + self.config = config + + @retry( + wait=wait_exponential(multiplier=2, min=1, max=65), + stop=stop_after_attempt(6), + before_sleep=before_sleep_log(logger, logging.INFO), + ) + def complete( + self, + messages: List[Dict[str, str]], + config: LLMConfig, + **kwargs + ) -> LLMResponse: + """Execute LiteLLM completion.""" + params = { + "model": config.model, + "messages": messages, + "temperature": config.temperature, + "timeout": config.timeout, + } + + if config.max_tokens: + params["max_tokens"] = config.max_tokens + + # Pass API keys if configured + if config.openai_api_key: + params["api_key"] = config.openai_api_key + if config.anthropic_api_key: + litellm.anthropic_key = config.anthropic_api_key + + # Merge extra params and kwargs + params.update(config.extra_params) + params.update(kwargs) + + logger.debug(f"LiteLLM completion request: model={config.model}") + + response = litellm.completion(**params) + + return LLMResponse( + content=response.choices[0].message.content, + model=response.model, + prompt_tokens=response.usage.prompt_tokens, + completion_tokens=response.usage.completion_tokens, + total_tokens=response.usage.total_tokens, + raw_response=response, + provider="litellm", + ) + + +class LLMClient: + """Unified LLM client that routes to appropriate provider.""" + + def __init__(self, config: LLMConfig): + self.config = config + self._provider_name = detect_provider(config.model) + self._provider: Optional[BaseProvider] = None + + def _get_provider(self) -> BaseProvider: + """Get or create the appropriate provider.""" + if self._provider is None: + if self._provider_name == "openai": + self._provider = OpenAIProvider(self.config) + elif self._provider_name == "anthropic": + self._provider = AnthropicProvider(self.config) + else: + self._provider = LiteLLMProvider(self.config) + logger.info(f"Using {self._provider_name} provider for model {self.config.model}") + return self._provider + + def complete( + self, + messages: List[Dict[str, str]], + **kwargs + ) -> LLMResponse: + """Execute a chat completion request. + + Args: + messages: List of message dicts with 'role' and 'content' keys + **kwargs: Additional parameters to override config + + Returns: + LLMResponse with content and usage information + """ + # Guardrail: some kwargs are provider-specific (e.g. OpenAI's response_format). + # Strip anything unsupported by the active provider before we forward into SDK calls. + if self._provider_name != "openai": + kwargs.pop("response_format", None) + provider = self._get_provider() + return provider.complete(messages, self.config, **kwargs) + + def complete_with_tracking( + self, + messages: List[Dict[str, str]], + vcon_uuid: str, + tracking_opts: Dict[str, Any], + sub_type: str = "ANALYZE", + **kwargs + ) -> LLMResponse: + """Execute completion and send AI usage tracking data. + + Args: + messages: Chat messages + vcon_uuid: UUID of the vCon being processed + tracking_opts: Dict containing send_ai_usage_data_to_url and ai_usage_api_token + sub_type: Tracking sub-type (e.g., "ANALYZE", "CHECK_AND_TAG") + **kwargs: Additional completion parameters + + Returns: + LLMResponse with content and usage + """ + response = self.complete(messages, **kwargs) + + send_ai_usage_data_for_tracking( + vcon_uuid=vcon_uuid, + input_units=response.prompt_tokens, + output_units=response.completion_tokens, + unit_type="tokens", + type="VCON_PROCESSING", + send_ai_usage_data_to_url=tracking_opts.get("send_ai_usage_data_to_url", ""), + ai_usage_api_token=tracking_opts.get("ai_usage_api_token", ""), + model=response.model, + sub_type=sub_type, + ) + + return response + + @property + def provider_name(self) -> str: + """Get the name of the provider being used.""" + return self._provider_name + + +# Global default configuration (can be set at startup) +_global_default_config: Optional[LLMConfig] = None + + +def set_global_llm_config(config: LLMConfig) -> None: + """Set the global default LLM configuration.""" + global _global_default_config + _global_default_config = config + logger.info(f"Global LLM config set: model={config.model}") + + +def get_global_llm_config() -> Optional[LLMConfig]: + """Get the global default LLM configuration.""" + return _global_default_config + + +def create_llm_client(opts: Dict[str, Any]) -> LLMClient: + """Factory function to create an LLM client with merged configuration. + + Merges: global defaults < link defaults < runtime options + + Args: + opts: Link options dictionary + + Returns: + Configured LLMClient instance + """ + config = LLMConfig.from_options(opts, _global_default_config) + return LLMClient(config) + + +def get_vendor_from_response(response: LLMResponse) -> str: + """Determine vendor name for vCon analysis from response. + + Args: + response: LLMResponse from completion + + Returns: + Vendor string for use in vCon.add_analysis() + """ + model_lower = response.model.lower() + + if "gpt" in model_lower or response.provider == "openai": + return "openai" + elif "claude" in model_lower or response.provider == "anthropic": + return "anthropic" + else: + # Use provider as vendor, or extract from model name + return response.provider diff --git a/server/lib/tests/__init__.py b/server/lib/tests/__init__.py new file mode 100644 index 0000000..24818a8 --- /dev/null +++ b/server/lib/tests/__init__.py @@ -0,0 +1 @@ +# Test package for lib module diff --git a/server/lib/tests/test_llm_client.py b/server/lib/tests/test_llm_client.py new file mode 100644 index 0000000..1728e61 --- /dev/null +++ b/server/lib/tests/test_llm_client.py @@ -0,0 +1,532 @@ +"""Unit tests for LLM client abstraction.""" +import pytest +from unittest.mock import Mock, patch, MagicMock +from dataclasses import asdict + +from lib.llm_client import ( + LLMConfig, + LLMResponse, + LLMClient, + OpenAIProvider, + AnthropicProvider, + LiteLLMProvider, + detect_provider, + create_llm_client, + set_global_llm_config, + get_global_llm_config, + get_vendor_from_response, +) + + +class TestLLMConfig: + """Tests for LLMConfig dataclass.""" + + def test_default_values(self): + """Test config creation with defaults.""" + config = LLMConfig() + assert config.model == "gpt-3.5-turbo" + assert config.temperature == 0.0 + assert config.max_tokens is None + assert config.timeout == 120.0 + + def test_from_options_with_defaults(self): + """Test config creation from options with defaults.""" + opts = {"model": "gpt-4"} + config = LLMConfig.from_options(opts) + assert config.model == "gpt-4" + assert config.temperature == 0.0 # default + + def test_from_options_with_global_defaults(self): + """Test config merges with global defaults.""" + global_config = LLMConfig(model="claude-3-opus", temperature=0.5) + opts = {"model": "gpt-4"} # Override model only + config = LLMConfig.from_options(opts, global_config) + assert config.model == "gpt-4" + assert config.temperature == 0.5 # From global + + def test_credential_mapping_openai(self): + """Test OpenAI credential mapping from options.""" + opts = { + "OPENAI_API_KEY": "test-openai-key", + } + config = LLMConfig.from_options(opts) + assert config.openai_api_key == "test-openai-key" + + def test_credential_mapping_azure(self): + """Test Azure OpenAI credential mapping from options.""" + opts = { + "AZURE_OPENAI_API_KEY": "azure-key", + "AZURE_OPENAI_ENDPOINT": "https://test.azure.com", + "AZURE_OPENAI_API_VERSION": "2024-02-15", + } + config = LLMConfig.from_options(opts) + assert config.azure_api_key == "azure-key" + assert config.azure_api_base == "https://test.azure.com" + assert config.azure_api_version == "2024-02-15" + + def test_credential_mapping_anthropic(self): + """Test Anthropic credential mapping from options.""" + opts = { + "ANTHROPIC_API_KEY": "test-anthropic-key", + } + config = LLMConfig.from_options(opts) + assert config.anthropic_api_key == "test-anthropic-key" + + def test_extra_params_passthrough(self): + """Test extra params are passed through.""" + opts = { + "llm_extra_params": {"top_p": 0.9, "presence_penalty": 0.5} + } + config = LLMConfig.from_options(opts) + assert config.extra_params == {"top_p": 0.9, "presence_penalty": 0.5} + + +class TestProviderDetection: + """Tests for provider detection from model names.""" + + def test_detect_openai_gpt_models(self): + """Test detection of GPT models.""" + assert detect_provider("gpt-4") == "openai" + assert detect_provider("gpt-3.5-turbo") == "openai" + assert detect_provider("gpt-4o") == "openai" + assert detect_provider("gpt-4-turbo") == "openai" + assert detect_provider("GPT-4") == "openai" # Case insensitive + + def test_detect_openai_o1_models(self): + """Test detection of O1 reasoning models.""" + assert detect_provider("o1-preview") == "openai" + assert detect_provider("o1-mini") == "openai" + assert detect_provider("o3-mini") == "openai" + + def test_detect_azure_models(self): + """Test detection of Azure deployment models.""" + assert detect_provider("azure/my-deployment") == "openai" + assert detect_provider("azure/gpt-4-deployment") == "openai" + + def test_detect_anthropic_models(self): + """Test detection of Claude models.""" + assert detect_provider("claude-3-opus-20240229") == "anthropic" + assert detect_provider("claude-3-sonnet-20240229") == "anthropic" + assert detect_provider("claude-3-haiku-20240307") == "anthropic" + assert detect_provider("claude-2") == "anthropic" + assert detect_provider("Claude-3-opus") == "anthropic" # Case insensitive + + def test_detect_litellm_fallback(self): + """Test fallback to LiteLLM for other models.""" + assert detect_provider("command-r-plus") == "litellm" + assert detect_provider("gemini/gemini-pro") == "litellm" + assert detect_provider("together_ai/llama-3-70b") == "litellm" + assert detect_provider("mistral/mistral-large") == "litellm" + assert detect_provider("unknown-model") == "litellm" + + +class TestOpenAIProvider: + """Tests for OpenAI provider implementation.""" + + @patch('lib.llm_client.OpenAI') + def test_complete_basic(self, mock_openai_class): + """Test basic completion call.""" + # Setup mock + mock_client = Mock() + mock_openai_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Test response" + mock_response.model = "gpt-3.5-turbo" + mock_response.usage.prompt_tokens = 10 + mock_response.usage.completion_tokens = 20 + mock_response.usage.total_tokens = 30 + mock_client.chat.completions.create.return_value = mock_response + + config = LLMConfig(model="gpt-3.5-turbo", openai_api_key="test-openai-key") + provider = OpenAIProvider(config) + + messages = [{"role": "user", "content": "Hello"}] + response = provider.complete(messages, config) + + assert response.content == "Test response" + assert response.prompt_tokens == 10 + assert response.completion_tokens == 20 + assert response.provider == "openai" + mock_client.chat.completions.create.assert_called_once() + + @patch('lib.llm_client.OpenAI') + def test_complete_with_json_response_format(self, mock_openai_class): + """Test completion with JSON response format.""" + mock_client = Mock() + mock_openai_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = '{"result": true}' + mock_response.model = "gpt-4" + mock_response.usage.prompt_tokens = 10 + mock_response.usage.completion_tokens = 5 + mock_response.usage.total_tokens = 15 + mock_client.chat.completions.create.return_value = mock_response + + config = LLMConfig( + model="gpt-4", + response_format={"type": "json_object"}, + openai_api_key="test-openai-key" + ) + provider = OpenAIProvider(config) + + messages = [{"role": "user", "content": "Return JSON"}] + response = provider.complete(messages, config) + + call_kwargs = mock_client.chat.completions.create.call_args[1] + assert call_kwargs["response_format"] == {"type": "json_object"} + + @patch('lib.llm_client.AzureOpenAI') + def test_uses_azure_client_when_configured(self, mock_azure_class): + """Test that Azure client is used when Azure credentials are provided.""" + mock_client = Mock() + mock_azure_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Azure response" + mock_response.model = "gpt-4" + mock_response.usage.prompt_tokens = 10 + mock_response.usage.completion_tokens = 20 + mock_response.usage.total_tokens = 30 + mock_client.chat.completions.create.return_value = mock_response + + config = LLMConfig( + model="gpt-4", + azure_api_key="azure-key", + azure_api_base="https://test.azure.com", + azure_api_version="2024-02-15" + ) + provider = OpenAIProvider(config) + + messages = [{"role": "user", "content": "Hello"}] + response = provider.complete(messages, config) + + mock_azure_class.assert_called_once() + assert response.content == "Azure response" + + +class TestAnthropicProvider: + """Tests for Anthropic provider implementation.""" + + @patch('lib.llm_client.anthropic.Anthropic') + def test_complete_basic(self, mock_anthropic_class): + """Test basic Anthropic completion.""" + mock_client = Mock() + mock_anthropic_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.content = [MagicMock(type="text", text="Claude response")] + mock_response.model = "claude-3-opus-20240229" + mock_response.usage.input_tokens = 15 + mock_response.usage.output_tokens = 25 + mock_client.messages.create.return_value = mock_response + + config = LLMConfig(model="claude-3-opus-20240229", anthropic_api_key="test-anthropic-key") + provider = AnthropicProvider(config) + + messages = [{"role": "user", "content": "Hello"}] + response = provider.complete(messages, config) + + assert response.content == "Claude response" + assert response.prompt_tokens == 15 + assert response.completion_tokens == 25 + assert response.provider == "anthropic" + + @patch('lib.llm_client.anthropic.Anthropic') + def test_message_format_conversion(self, mock_anthropic_class): + """Test OpenAI format to Anthropic format conversion.""" + mock_client = Mock() + mock_anthropic_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.content = [MagicMock(type="text", text="Response")] + mock_response.model = "claude-3-opus" + mock_response.usage.input_tokens = 10 + mock_response.usage.output_tokens = 10 + mock_client.messages.create.return_value = mock_response + + config = LLMConfig(model="claude-3-opus", anthropic_api_key="test-anthropic-key") + provider = AnthropicProvider(config) + + # OpenAI format messages with system + messages = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + ] + provider.complete(messages, config) + + call_kwargs = mock_client.messages.create.call_args[1] + # System should be extracted + assert call_kwargs["system"] == "You are helpful." + # Only user message should be in messages + assert len(call_kwargs["messages"]) == 1 + assert call_kwargs["messages"][0]["role"] == "user" + + +class TestLiteLLMProvider: + """Tests for LiteLLM provider implementation.""" + + @patch('lib.llm_client.litellm.completion') + def test_complete_basic(self, mock_completion): + """Test basic LiteLLM completion.""" + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "LiteLLM response" + mock_response.model = "command-r-plus" + mock_response.usage.prompt_tokens = 20 + mock_response.usage.completion_tokens = 30 + mock_response.usage.total_tokens = 50 + mock_completion.return_value = mock_response + + config = LLMConfig(model="command-r-plus") + provider = LiteLLMProvider(config) + + messages = [{"role": "user", "content": "Hello"}] + response = provider.complete(messages, config) + + assert response.content == "LiteLLM response" + assert response.provider == "litellm" + mock_completion.assert_called_once() + + +class TestLLMClient: + """Tests for high-level LLM client.""" + + @patch('lib.llm_client.OpenAI') + def test_routes_to_openai_provider(self, mock_openai_class): + """Test that GPT models route to OpenAI provider.""" + mock_client = Mock() + mock_openai_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "OpenAI response" + mock_response.model = "gpt-4" + mock_response.usage.prompt_tokens = 10 + mock_response.usage.completion_tokens = 20 + mock_response.usage.total_tokens = 30 + mock_client.chat.completions.create.return_value = mock_response + + config = LLMConfig(model="gpt-4", openai_api_key="test-openai-key") + client = LLMClient(config) + + assert client.provider_name == "openai" + + messages = [{"role": "user", "content": "Hello"}] + response = client.complete(messages) + assert response.provider == "openai" + + @patch('lib.llm_client.anthropic.Anthropic') + def test_routes_to_anthropic_provider(self, mock_anthropic_class): + """Test that Claude models route to Anthropic provider.""" + mock_client = Mock() + mock_anthropic_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.content = [MagicMock(type="text", text="Anthropic response")] + mock_response.model = "claude-3-opus" + mock_response.usage.input_tokens = 10 + mock_response.usage.output_tokens = 20 + mock_client.messages.create.return_value = mock_response + + config = LLMConfig(model="claude-3-opus", anthropic_api_key="test-anthropic-key") + client = LLMClient(config) + + assert client.provider_name == "anthropic" + + messages = [{"role": "user", "content": "Hello"}] + response = client.complete(messages) + assert response.provider == "anthropic" + + @patch('lib.llm_client.litellm.completion') + def test_routes_to_litellm_provider(self, mock_completion): + """Test that unknown models route to LiteLLM provider.""" + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "LiteLLM response" + mock_response.model = "command-r-plus" + mock_response.usage.prompt_tokens = 10 + mock_response.usage.completion_tokens = 20 + mock_response.usage.total_tokens = 30 + mock_completion.return_value = mock_response + + config = LLMConfig(model="command-r-plus") + client = LLMClient(config) + + assert client.provider_name == "litellm" + + messages = [{"role": "user", "content": "Hello"}] + response = client.complete(messages) + assert response.provider == "litellm" + + @patch('lib.llm_client.send_ai_usage_data_for_tracking') + @patch('lib.llm_client.OpenAI') + def test_complete_with_tracking(self, mock_openai_class, mock_tracking): + """Test completion with usage tracking.""" + mock_client = Mock() + mock_openai_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Tracked response" + mock_response.model = "gpt-3.5-turbo" + mock_response.usage.prompt_tokens = 100 + mock_response.usage.completion_tokens = 50 + mock_response.usage.total_tokens = 150 + mock_client.chat.completions.create.return_value = mock_response + + config = LLMConfig(model="gpt-3.5-turbo", openai_api_key="test-openai-key") + client = LLMClient(config) + + tracking_opts = { + "send_ai_usage_data_to_url": "https://tracking.example.com", + "ai_usage_api_token": "token123" + } + + response = client.complete_with_tracking( + messages=[{"role": "user", "content": "Test"}], + vcon_uuid="test-uuid", + tracking_opts=tracking_opts, + sub_type="ANALYZE" + ) + + mock_tracking.assert_called_once_with( + vcon_uuid="test-uuid", + input_units=100, + output_units=50, + unit_type="tokens", + type="VCON_PROCESSING", + send_ai_usage_data_to_url="https://tracking.example.com", + ai_usage_api_token="token123", + model="gpt-3.5-turbo", + sub_type="ANALYZE" + ) + + @patch('lib.llm_client.send_ai_usage_data_for_tracking') + @patch('lib.llm_client.anthropic.Anthropic') + def test_complete_with_tracking_strips_response_format_for_anthropic( + self, mock_anthropic_class, mock_tracking + ): + """Ensure OpenAI-only kwargs are not forwarded to Anthropic SDK calls.""" + mock_client = Mock() + mock_anthropic_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.content = [MagicMock(type="text", text="Claude response")] + mock_response.model = "claude-3-opus" + mock_response.usage.input_tokens = 10 + mock_response.usage.output_tokens = 20 + mock_client.messages.create.return_value = mock_response + + config = LLMConfig(model="claude-3-opus", anthropic_api_key="test-anthropic-key") + client = LLMClient(config) + + client.complete_with_tracking( + messages=[{"role": "user", "content": "Return JSON"}], + vcon_uuid="test-uuid", + tracking_opts={}, + sub_type="TEST", + response_format={"type": "json_object"}, + ) + + call_kwargs = mock_client.messages.create.call_args[1] + assert "response_format" not in call_kwargs + + @patch('lib.llm_client.litellm.completion') + def test_litellm_does_not_forward_response_format(self, mock_completion): + """Ensure response_format is not sent to LiteLLM models.""" + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "LiteLLM response" + mock_response.model = "command-r-plus" + mock_response.usage.prompt_tokens = 10 + mock_response.usage.completion_tokens = 20 + mock_response.usage.total_tokens = 30 + mock_completion.return_value = mock_response + + config = LLMConfig(model="command-r-plus", response_format={"type": "json_object"}) + client = LLMClient(config) + + client.complete([{"role": "user", "content": "Hello"}]) + + call_kwargs = mock_completion.call_args[1] + assert "response_format" not in call_kwargs + + +class TestGlobalConfig: + """Tests for global configuration management.""" + + def test_set_and_get_global_config(self): + """Test global config management.""" + config = LLMConfig(model="claude-3-opus") + set_global_llm_config(config) + + retrieved = get_global_llm_config() + assert retrieved.model == "claude-3-opus" + + @patch('lib.llm_client.OpenAI') + def test_create_llm_client_uses_global_defaults(self, mock_openai_class): + """Test factory uses global defaults.""" + global_config = LLMConfig( + model="gpt-4", + temperature=0.7, + openai_api_key="global-key" + ) + set_global_llm_config(global_config) + + # Create client with no overrides + client = create_llm_client({}) + assert client.config.model == "gpt-4" + assert client.config.temperature == 0.7 + + # Create client with overrides + client2 = create_llm_client({"model": "gpt-3.5-turbo"}) + assert client2.config.model == "gpt-3.5-turbo" + assert client2.config.temperature == 0.7 # Still from global + + +class TestGetVendorFromResponse: + """Tests for vendor detection from response.""" + + def test_openai_vendor(self): + """Test OpenAI vendor detection.""" + response = LLMResponse( + content="test", + model="gpt-4", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + raw_response=None, + provider="openai" + ) + assert get_vendor_from_response(response) == "openai" + + def test_anthropic_vendor(self): + """Test Anthropic vendor detection.""" + response = LLMResponse( + content="test", + model="claude-3-opus", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + raw_response=None, + provider="anthropic" + ) + assert get_vendor_from_response(response) == "anthropic" + + def test_litellm_vendor(self): + """Test LiteLLM vendor detection.""" + response = LLMResponse( + content="test", + model="command-r-plus", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + raw_response=None, + provider="litellm" + ) + assert get_vendor_from_response(response) == "litellm" diff --git a/server/lib/tests/test_llm_client_integration.py b/server/lib/tests/test_llm_client_integration.py new file mode 100644 index 0000000..86bd125 --- /dev/null +++ b/server/lib/tests/test_llm_client_integration.py @@ -0,0 +1,342 @@ +"""Integration tests for LLM client abstraction. + +These tests require actual API keys and make real API calls. +They are skipped if the required environment variables are not set. + +Run with: pytest -m integration server/lib/tests/test_llm_client_integration.py +""" +import pytest +import os + +from lib.llm_client import ( + LLMConfig, + LLMClient, + create_llm_client, + detect_provider, +) + + +# Mark all tests in this module as integration tests +pytestmark = pytest.mark.integration + + +@pytest.mark.skipif( + not os.environ.get("OPENAI_API_KEY"), + reason="OPENAI_API_KEY not set" +) +class TestOpenAIIntegration: + """Integration tests for OpenAI provider.""" + + def test_real_completion_gpt35(self): + """Test real completion with GPT-3.5.""" + config = LLMConfig( + model="gpt-3.5-turbo", + temperature=0, + openai_api_key=os.environ.get("OPENAI_API_KEY"), + ) + client = LLMClient(config) + + messages = [{"role": "user", "content": "Say 'hello' and nothing else."}] + response = client.complete(messages) + + assert response.content is not None + assert "hello" in response.content.lower() + assert response.provider == "openai" + assert response.prompt_tokens > 0 + assert response.completion_tokens > 0 + + def test_real_completion_gpt4(self): + """Test real completion with GPT-4.""" + config = LLMConfig( + model="gpt-4", + temperature=0, + openai_api_key=os.environ.get("OPENAI_API_KEY"), + ) + client = LLMClient(config) + + messages = [{"role": "user", "content": "What is 2+2? Reply with just the number."}] + response = client.complete(messages) + + assert response.content is not None + assert "4" in response.content + assert response.provider == "openai" + + def test_json_response_format(self): + """Test JSON response format with OpenAI.""" + config = LLMConfig( + model="gpt-3.5-turbo", + temperature=0, + response_format={"type": "json_object"}, + openai_api_key=os.environ.get("OPENAI_API_KEY"), + ) + client = LLMClient(config) + + messages = [ + {"role": "system", "content": "Return JSON only."}, + {"role": "user", "content": "Return a JSON object with key 'answer' and value 42."} + ] + response = client.complete(messages) + + import json + data = json.loads(response.content) + assert "answer" in data + assert data["answer"] == 42 + + def test_token_usage_reported(self): + """Test that token usage is reported correctly.""" + config = LLMConfig( + model="gpt-3.5-turbo", + temperature=0, + openai_api_key=os.environ.get("OPENAI_API_KEY"), + ) + client = LLMClient(config) + + messages = [{"role": "user", "content": "Hi"}] + response = client.complete(messages) + + assert response.prompt_tokens > 0 + assert response.completion_tokens > 0 + assert response.total_tokens == response.prompt_tokens + response.completion_tokens + + def test_system_prompt_handling(self): + """Test system prompt is handled correctly.""" + config = LLMConfig( + model="gpt-3.5-turbo", + temperature=0, + openai_api_key=os.environ.get("OPENAI_API_KEY"), + ) + client = LLMClient(config) + + messages = [ + {"role": "system", "content": "You always respond with exactly 'PONG'."}, + {"role": "user", "content": "PING"} + ] + response = client.complete(messages) + + assert "PONG" in response.content.upper() + + +@pytest.mark.skipif( + not os.environ.get("ANTHROPIC_API_KEY"), + reason="ANTHROPIC_API_KEY not set" +) +class TestAnthropicIntegration: + """Integration tests for Anthropic provider. + + Uses Claude 4.5 models: + - claude-sonnet-4-5-20250514: Balanced performance for most uses + - claude-haiku-4-5-20250514: Fastest model with near-frontier intelligence + """ + + def test_real_completion_claude_sonnet(self): + """Test real completion with Claude Sonnet 4.5.""" + config = LLMConfig( + model="claude-sonnet-4-5-20250514", + temperature=0, + max_tokens=100, + anthropic_api_key=os.environ.get("ANTHROPIC_API_KEY"), + ) + client = LLMClient(config) + + messages = [{"role": "user", "content": "Say 'hello' and nothing else."}] + response = client.complete(messages) + + assert response.content is not None + assert "hello" in response.content.lower() + assert response.provider == "anthropic" + assert response.prompt_tokens > 0 + assert response.completion_tokens > 0 + + def test_real_completion_claude_haiku(self): + """Test real completion with Claude Haiku 4.5 (fastest).""" + config = LLMConfig( + model="claude-haiku-4-5-20250514", + temperature=0, + max_tokens=50, + anthropic_api_key=os.environ.get("ANTHROPIC_API_KEY"), + ) + client = LLMClient(config) + + messages = [{"role": "user", "content": "What is 2+2? Reply with just the number."}] + response = client.complete(messages) + + assert response.content is not None + assert "4" in response.content + assert response.provider == "anthropic" + + def test_system_prompt_handling(self): + """Test system prompt extraction for Anthropic.""" + config = LLMConfig( + model="claude-haiku-4-5-20250514", + temperature=0, + max_tokens=50, + anthropic_api_key=os.environ.get("ANTHROPIC_API_KEY"), + ) + client = LLMClient(config) + + messages = [ + {"role": "system", "content": "You always respond with exactly 'PONG'."}, + {"role": "user", "content": "PING"} + ] + response = client.complete(messages) + + assert "PONG" in response.content.upper() + + def test_token_usage_reported(self): + """Test that token usage is reported correctly for Anthropic.""" + config = LLMConfig( + model="claude-haiku-4-5-20250514", + temperature=0, + max_tokens=50, + anthropic_api_key=os.environ.get("ANTHROPIC_API_KEY"), + ) + client = LLMClient(config) + + messages = [{"role": "user", "content": "Hi"}] + response = client.complete(messages) + + assert response.prompt_tokens > 0 + assert response.completion_tokens > 0 + assert response.total_tokens == response.prompt_tokens + response.completion_tokens + + +@pytest.mark.skipif( + not os.environ.get("COHERE_API_KEY"), + reason="COHERE_API_KEY not set" +) +class TestLiteLLMCohereIntegration: + """Integration tests for LiteLLM with Cohere.""" + + def test_cohere_command_r(self): + """Test Cohere Command-R via LiteLLM.""" + # Set the API key for LiteLLM + os.environ["COHERE_API_KEY"] = os.environ.get("COHERE_API_KEY", "") + + config = LLMConfig( + model="command-r", + temperature=0, + ) + client = LLMClient(config) + + messages = [{"role": "user", "content": "Say 'hello' and nothing else."}] + response = client.complete(messages) + + assert response.content is not None + assert response.provider == "litellm" + + +@pytest.mark.skipif( + not os.environ.get("OPENAI_API_KEY") or not os.environ.get("ANTHROPIC_API_KEY"), + reason="Both OPENAI_API_KEY and ANTHROPIC_API_KEY required" +) +class TestMultiProviderIntegration: + """Integration tests comparing multiple providers.""" + + def test_same_question_different_providers(self): + """Test same question across OpenAI and Anthropic.""" + question = "What is the capital of France? Reply with just the city name." + + # OpenAI + openai_config = LLMConfig( + model="gpt-3.5-turbo", + temperature=0, + openai_api_key=os.environ.get("OPENAI_API_KEY"), + ) + openai_client = LLMClient(openai_config) + openai_response = openai_client.complete([{"role": "user", "content": question}]) + + # Anthropic + anthropic_config = LLMConfig( + model="claude-haiku-4-5-20250514", + temperature=0, + max_tokens=50, + anthropic_api_key=os.environ.get("ANTHROPIC_API_KEY"), + ) + anthropic_client = LLMClient(anthropic_config) + anthropic_response = anthropic_client.complete([{"role": "user", "content": question}]) + + # Both should mention Paris + assert "paris" in openai_response.content.lower() + assert "paris" in anthropic_response.content.lower() + + # Providers should be different + assert openai_response.provider == "openai" + assert anthropic_response.provider == "anthropic" + + def test_provider_auto_detection_from_model(self): + """Test that provider is auto-detected from model name.""" + assert detect_provider("gpt-4") == "openai" + assert detect_provider("claude-3-opus") == "anthropic" + assert detect_provider("command-r") == "litellm" + + +@pytest.mark.skipif( + not os.environ.get("OPENAI_API_KEY"), + reason="OPENAI_API_KEY not set" +) +class TestCreateLLMClientIntegration: + """Integration tests for create_llm_client factory.""" + + def test_create_client_from_link_options(self): + """Test creating client from typical link options.""" + opts = { + "model": "gpt-3.5-turbo", + "temperature": 0, + "OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY"), + } + + client = create_llm_client(opts) + assert client.provider_name == "openai" + + messages = [{"role": "user", "content": "Say 'test' and nothing else."}] + response = client.complete(messages) + + assert response.content is not None + assert response.provider == "openai" + + def test_tracking_integration(self): + """Test complete_with_tracking works in real scenario.""" + opts = { + "model": "gpt-3.5-turbo", + "temperature": 0, + "OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY"), + # No tracking URL - should still work, just not send data + } + + client = create_llm_client(opts) + + messages = [{"role": "user", "content": "Say 'tracked' and nothing else."}] + response = client.complete_with_tracking( + messages=messages, + vcon_uuid="test-uuid-12345", + tracking_opts=opts, + sub_type="TEST" + ) + + assert response.content is not None + assert "tracked" in response.content.lower() + + +@pytest.mark.skipif( + not os.environ.get("AZURE_OPENAI_API_KEY") or not os.environ.get("AZURE_OPENAI_ENDPOINT"), + reason="Azure OpenAI credentials not set" +) +class TestAzureOpenAIIntegration: + """Integration tests for Azure OpenAI.""" + + def test_azure_completion(self): + """Test completion via Azure OpenAI.""" + config = LLMConfig( + model="gpt-35-turbo", # Azure deployment name + temperature=0, + azure_api_key=os.environ.get("AZURE_OPENAI_API_KEY"), + azure_api_base=os.environ.get("AZURE_OPENAI_ENDPOINT"), + azure_api_version=os.environ.get("AZURE_OPENAI_API_VERSION", "2024-02-15-preview"), + ) + client = LLMClient(config) + + messages = [{"role": "user", "content": "Say 'azure' and nothing else."}] + response = client.complete(messages) + + assert response.content is not None + assert response.provider == "openai" diff --git a/server/links/HOW_TO_CREATE_EXTERNAL_LINK.md b/server/links/HOW_TO_CREATE_EXTERNAL_LINK.md new file mode 100644 index 0000000..21513a2 --- /dev/null +++ b/server/links/HOW_TO_CREATE_EXTERNAL_LINK.md @@ -0,0 +1,988 @@ +# How to Create an External Link with Its Own Repository + +This guide explains how to create a vCon server link as a standalone Python package in its own repository that can be installed by reference from GitHub or PyPI. + +## Table of Contents + +1. [Overview](#overview) +2. [Prerequisites](#prerequisites) +3. [Repository Setup](#repository-setup) +4. [Package Structure](#package-structure) +5. [Implementing the Link](#implementing-the-link) +6. [Dependencies](#dependencies) +7. [Publishing to GitHub](#publishing-to-github) +8. [Installing in vCon Server](#installing-in-vcon-server) +9. [Version Management](#version-management) +10. [Testing](#testing) +11. [Best Practices](#best-practices) + +## Overview + +An external link is a Python package that: +- Lives in its own repository (separate from vcon-server) +- Can be installed via pip from GitHub or PyPI +- Implements the standard vCon link interface +- Can be referenced in vcon-server configuration without being part of the main codebase + +### Benefits + +- **Separation of concerns**: Keep your link code separate from the main server +- **Version control**: Independent versioning and release cycles +- **Reusability**: Share links across multiple vcon-server instances +- **Privacy**: Keep proprietary links in private repositories +- **Distribution**: Publish to PyPI for public distribution + +## Prerequisites + +- Python 3.12+ (matching vcon-server requirements) +- Git installed and configured +- GitHub account (or Git hosting service) +- Basic understanding of Python packaging +- Access to a vcon-server instance for testing + +## Repository Setup + +### Step 1: Create a New Repository + +Create a new repository on GitHub (or your preferred Git hosting service): + +```bash +# Create a new directory for your link +mkdir my-vcon-link +cd my-vcon-link +git init +``` + +### Step 2: Initialize Git Repository + +```bash +# Create .gitignore +cat > .gitignore << EOF +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info/ +dist/ +build/ +.venv/ +venv/ +.env +*.log +.pytest_cache/ +.coverage +htmlcov/ +EOF + +git add .gitignore +git commit -m "Initial commit: Add .gitignore" +``` + +## Package Structure + +Create the following directory structure: + +``` +my-vcon-link/ +├── my_vcon_link/ # Main package directory (matches module name) +│ ├── __init__.py # Link implementation +│ └── utils.py # Optional helper utilities +├── tests/ # Test directory +│ ├── __init__.py +│ └── test_link.py +├── pyproject.toml # Package configuration (modern approach) +├── README.md # Documentation +├── LICENSE # License file +└── .gitignore +``` + +### Step 3: Create Package Directory + +```bash +mkdir -p my_vcon_link tests +touch my_vcon_link/__init__.py +touch my_vcon_link/utils.py +touch tests/__init__.py +touch tests/test_link.py +``` + +## Implementing the Link + +### Step 4: Create `pyproject.toml` + +Create a `pyproject.toml` file for your package: + +```toml +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "my-vcon-link" +version = "0.1.0" +description = "A custom vCon server link" +readme = "README.md" +requires-python = ">=3.12,<3.14" +authors = [ + {name = "Your Name", email = "your.email@example.com"} +] +license = {text = "MIT"} +keywords = ["vcon", "link", "processor"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", +] + +dependencies = [ + "vcon>=0.1.0", # vCon library - check available version + "redis>=4.6.0", # Redis client +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-cov>=4.0.0", + "black>=24.0.0", +] + +[tool.setuptools] +packages = ["my_vcon_link"] + +[tool.setuptools.package-data] +"*" = ["*.md", "*.txt"] + +[tool.black] +line-length = 120 +``` + +**Important Notes:** +- The package name (`name`) can differ from the module name (`my_vcon_link`) +- The module name (directory name) is what will be imported in vcon-server config +- Ensure Python version matches vcon-server requirements (>=3.12,<3.14) +- Include `vcon` and `redis` as dependencies + +### Step 5: Implement the Link Interface + +Create `my_vcon_link/__init__.py` with your link implementation: + +```python +"""My Custom vCon Link + +A link that processes vCon objects for the vCon server. +""" + +import logging +from typing import Optional +import redis +from redis.commands.json.path import Path +import vcon + +# Initialize logger +logger = logging.getLogger(__name__) + +# Default configuration options +default_options = { + "option1": "default_value", + "option2": 100, + "redis_host": "localhost", + "redis_port": 6379, + "redis_db": 0, +} + + +def get_redis_connection(opts: dict): + """Get Redis connection based on options. + + Args: + opts: Configuration options that may include redis_host, redis_port, redis_db + + Returns: + Redis connection object + """ + host = opts.get("redis_host", "localhost") + port = opts.get("redis_port", 6379) + db = opts.get("redis_db", 0) + + return redis.Redis(host=host, port=port, db=db, decode_responses=False) + + +def get_vcon_from_redis(redis_conn, vcon_uuid: str) -> Optional[vcon.Vcon]: + """Retrieve a vCon from Redis. + + Args: + redis_conn: Redis connection object + vcon_uuid: UUID of the vCon to retrieve + + Returns: + vCon object or None if not found + """ + try: + vcon_dict = redis_conn.json().get(f"vcon:{vcon_uuid}", Path.root_path()) + if not vcon_dict: + return None + return vcon.Vcon(vcon_dict) + except Exception as e: + logger.error(f"Error retrieving vCon {vcon_uuid} from Redis: {e}") + return None + + +def store_vcon_to_redis(redis_conn, vcon_obj: vcon.Vcon) -> bool: + """Store a vCon to Redis. + + Args: + redis_conn: Redis connection object + vcon_obj: vCon object to store + + Returns: + True if successful, False otherwise + """ + try: + key = f"vcon:{vcon_obj.uuid}" + vcon_dict = vcon_obj.to_dict() + redis_conn.json().set(key, Path.root_path(), vcon_dict) + return True + except Exception as e: + logger.error(f"Error storing vCon {vcon_obj.uuid} to Redis: {e}") + return False + + +def run( + vcon_uuid: str, + link_name: str, + opts: dict = default_options +) -> Optional[str]: + """Main link function - required interface for vCon server links. + + This function is called by the vCon server to process a vCon through this link. + + Args: + vcon_uuid: UUID of the vCon to process + link_name: Name of this link instance (from config) + opts: Configuration options for this link (merged with defaults) + + Returns: + vcon_uuid (str) if processing should continue, None to stop the chain + + Raises: + Exception: If processing fails and chain should stop + """ + module_name = __name__.split(".")[-1] + logger.info(f"Starting {module_name}:{link_name} plugin for: {vcon_uuid}") + + # Merge provided options with defaults + merged_opts = default_options.copy() + merged_opts.update(opts) + opts = merged_opts + + # Get Redis connection + redis_conn = get_redis_connection(opts) + + # Retrieve vCon from Redis + vcon_obj = get_vcon_from_redis(redis_conn, vcon_uuid) + if not vcon_obj: + logger.error(f"vCon not found: {vcon_uuid}") + return None + + # TODO: Add your processing logic here + # Example: Add a tag + # vcon_obj.add_tag(tag_name="processed_by", tag_value=link_name) + + # Example: Add analysis + # vcon_obj.add_analysis( + # type="custom_analysis", + # dialog=None, # None for vCon-level analysis + # vendor="my_vendor", + # body={"result": "processed"}, + # encoding="json" + # ) + + # Example: Process each dialog + # for index, dialog in enumerate(vcon_obj.dialog): + # # Process dialog + # pass + + # Store updated vCon back to Redis + if not store_vcon_to_redis(redis_conn, vcon_obj): + logger.error(f"Failed to store vCon: {vcon_uuid}") + return None + + logger.info(f"Finished {module_name}:{link_name} plugin for: {vcon_uuid}") + return vcon_uuid +``` + +### Alternative: Using vcon-server Utilities (If Available) + +If your link will always run in a vcon-server environment, you can optionally use vcon-server's internal utilities: + +```python +"""Alternative implementation using vcon-server utilities if available.""" + +import logging +from typing import Optional + +# Try to import vcon-server utilities, fall back to direct implementation +try: + from lib.vcon_redis import VconRedis + from lib.logging_utils import init_logger + USE_VCON_SERVER_UTILS = True +except ImportError: + USE_VCON_SERVER_UTILS = False + # Use direct Redis/vcon implementation (as shown above) + import redis + from redis.commands.json.path import Path + import vcon + +logger = logging.getLogger(__name__) +if USE_VCON_SERVER_UTILS: + logger = init_logger(__name__) + +default_options = { + "option1": "default_value", + "option2": 100, +} + +def run( + vcon_uuid: str, + link_name: str, + opts: dict = default_options +) -> Optional[str]: + """Main link function.""" + logger.info(f"Starting {__name__}:{link_name} plugin for: {vcon_uuid}") + + merged_opts = default_options.copy() + merged_opts.update(opts) + opts = merged_opts + + if USE_VCON_SERVER_UTILS: + # Use vcon-server utilities + vcon_redis = VconRedis() + vcon_obj = vcon_redis.get_vcon(vcon_uuid) + if not vcon_obj: + logger.error(f"vCon not found: {vcon_uuid}") + return None + + # Your processing logic here + + vcon_redis.store_vcon(vcon_obj) + else: + # Use direct implementation + redis_conn = get_redis_connection(opts) + vcon_obj = get_vcon_from_redis(redis_conn, vcon_uuid) + if not vcon_obj: + logger.error(f"vCon not found: {vcon_uuid}") + return None + + # Your processing logic here + + store_vcon_to_redis(redis_conn, vcon_obj) + + logger.info(f"Finished {__name__}:{link_name} plugin for: {vcon_uuid}") + return vcon_uuid +``` + +**Recommendation**: Use the standalone approach (first example) for maximum portability and independence from vcon-server internals. + +## Dependencies + +### Required Dependencies + +Your link must include these in `pyproject.toml`: + +- `vcon`: The vCon library for working with vCon objects +- `redis`: Redis client library (version >=4.6.0 to match vcon-server) + +### Optional Dependencies + +Add any additional dependencies your link needs: + +```toml +dependencies = [ + "vcon>=0.1.0", + "redis>=4.6.0", + "requests>=2.31.0", # For API calls + "tenacity>=8.2.3", # For retry logic +] +``` + +### Finding the vcon Library Version + +Check what version of vcon is available: + +```bash +pip search vcon +# or +pip index versions vcon +``` + +If vcon is not on PyPI, you may need to install it from source or specify it as a dependency from GitHub. + +## Publishing to GitHub + +### Step 6: Create README.md + +Create a comprehensive README: + +```markdown +# My vCon Link + +A custom link processor for the vCon server. + +## Features + +- Feature 1 +- Feature 2 + +## Installation + +This link can be installed directly from GitHub: + +```bash +pip install git+https://github.com/yourusername/my-vcon-link.git +``` + +## Configuration + +Add to your vcon-server `config.yml`: + +```yaml +links: + my_link: + module: my_vcon_link + pip_name: git+https://github.com/yourusername/my-vcon-link.git + options: + option1: "value" + option2: 200 +``` + +## Options + +- `option1`: Description of option1 +- `option2`: Description of option2 + +## Usage + +Add to a processing chain: + +```yaml +chains: + my_chain: + links: + - my_link + ingress_lists: + - my_input_list + enabled: 1 +``` + +## Development + +```bash +# Install in development mode +pip install -e . + +# Run tests +pytest + +# Format code +black my_vcon_link/ +``` + +## License + +MIT +``` + +### Step 7: Commit and Push to GitHub + +```bash +# Add all files +git add . + +# Commit +git commit -m "Initial implementation of my-vcon-link" + +# Add remote (replace with your repository URL) +git remote add origin https://github.com/yourusername/my-vcon-link.git + +# Push to GitHub +git branch -M main +git push -u origin main +``` + +### Step 8: Create a Release Tag (Optional but Recommended) + +For version management, create a git tag: + +```bash +# Create an annotated tag +git tag -a v0.1.0 -m "Initial release" + +# Push tag to GitHub +git push origin v0.1.0 +``` + +## Installing in vCon Server + +### Step 9: Configure in vcon-server + +Add your link to the vcon-server `config.yml`: + +```yaml +links: + my_custom_link: + module: my_vcon_link # Module name (directory name in your package) + pip_name: git+https://github.com/yourusername/my-vcon-link.git@main + options: + option1: "custom_value" + option2: 200 + redis_host: "localhost" # If using direct Redis access + redis_port: 6379 +``` + +### Version-Specific Installation + +Install from a specific tag, branch, or commit: + +```yaml +links: + # From a specific tag + my_link_v1: + module: my_vcon_link + pip_name: git+https://github.com/yourusername/my-vcon-link.git@v0.1.0 + + # From a specific branch + my_link_dev: + module: my_vcon_link + pip_name: git+https://github.com/yourusername/my-vcon-link.git@develop + + # From a specific commit + my_link_commit: + module: my_vcon_link + pip_name: git+https://github.com/yourusername/my-vcon-link.git@abc123def456 +``` + +### Private Repository + +For private repositories, use a personal access token: + +```yaml +links: + private_link: + module: my_vcon_link + pip_name: git+https://token:your_github_token@github.com/yourusername/my-vcon-link.git + options: + option1: "value" +``` + +**Security Note**: Store tokens securely. Consider using environment variables or secrets management. + +### Step 10: Add to a Processing Chain + +Add your link to a chain: + +```yaml +chains: + my_processing_chain: + links: + - existing_link + - my_custom_link # Your new link + - another_link + ingress_lists: + - my_input_list + storages: + - mongo + egress_lists: + - my_output_list + enabled: 1 +``` + +## Version Management + +### Semantic Versioning + +Follow semantic versioning (MAJOR.MINOR.PATCH): + +- **MAJOR**: Breaking changes to the interface or options +- **MINOR**: New features, backward compatible +- **PATCH**: Bug fixes, backward compatible + +### Updating Versions + +1. Update version in `pyproject.toml`: + ```toml + version = "0.2.0" + ``` + +2. Commit and tag: + ```bash + git add pyproject.toml + git commit -m "Bump version to 0.2.0" + git tag -a v0.2.0 -m "Release version 0.2.0" + git push origin main + git push origin v0.2.0 + ``` + +3. Update vcon-server config to use new version: + ```yaml + links: + my_link: + module: my_vcon_link + pip_name: git+https://github.com/yourusername/my-vcon-link.git@v0.2.0 + ``` + +### Version Constraints + +Users can specify version constraints in the pip_name: + +```yaml +links: + # Exact version + my_link: + module: my_vcon_link + pip_name: git+https://github.com/yourusername/my-vcon-link.git@v0.1.0 + + # Latest from branch + my_link: + module: my_vcon_link + pip_name: git+https://github.com/yourusername/my-vcon-link.git@main +``` + +## Testing + +### Step 11: Write Tests + +Create `tests/test_link.py`: + +```python +"""Tests for my_vcon_link.""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +import vcon + +# Import your link module +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from my_vcon_link import run, default_options + + +@pytest.fixture +def mock_redis(): + """Mock Redis connection.""" + with patch('my_vcon_link.get_redis_connection') as mock: + redis_conn = MagicMock() + mock.return_value = redis_conn + yield redis_conn + + +@pytest.fixture +def sample_vcon(): + """Create a sample vCon for testing.""" + return vcon.Vcon({ + "uuid": "test-uuid-123", + "vcon": "0.0.1", + "parties": [], + "dialog": [ + { + "type": "recording", + "duration": 120 + } + ], + "analysis": [], + "attachments": [] + }) + + +@pytest.fixture +def mock_vcon_retrieval(mock_redis, sample_vcon): + """Mock vCon retrieval from Redis.""" + mock_redis.json().get.return_value = sample_vcon.to_dict() + mock_redis.json().set.return_value = True + return mock_redis + + +def test_run_success(mock_vcon_retrieval, sample_vcon): + """Test successful link execution.""" + opts = { + "option1": "test_value" + } + + result = run("test-uuid-123", "test_link", opts) + + assert result == "test-uuid-123" + mock_vcon_retrieval.json().get.assert_called_once() + mock_vcon_retrieval.json().set.assert_called_once() + + +def test_run_missing_vcon(mock_redis): + """Test handling of missing vCon.""" + mock_redis.json().get.return_value = None + + result = run("missing-uuid", "test_link") + + assert result is None + mock_redis.json().set.assert_not_called() + + +def test_default_options(): + """Test that default options are used.""" + result = run("test-uuid", "test_link", {}) + + # Verify defaults are applied (test will depend on your implementation) + assert True # Replace with actual assertions +``` + +### Running Tests + +```bash +# Install test dependencies +pip install -e ".[dev]" + +# Run tests +pytest + +# Run with coverage +pytest --cov=my_vcon_link --cov-report=html +``` + +## Best Practices + +### 1. Error Handling + +Always handle errors gracefully: + +```python +def run(vcon_uuid: str, link_name: str, opts: dict = default_options) -> Optional[str]: + try: + # Your processing logic + pass + except Exception as e: + logger.error(f"Error processing vCon {vcon_uuid}: {e}", exc_info=True) + # Decide: return None to stop chain, or raise to propagate error + raise # or return None +``` + +### 2. Logging + +Use appropriate log levels: + +```python +logger.debug("Detailed debugging information") +logger.info("Important processing steps") +logger.warning("Non-fatal issues") +logger.error("Errors that need attention") +``` + +### 3. Idempotency + +Make your link idempotent when possible: + +```python +# Check if already processed +existing_analysis = next( + (a for a in vcon_obj.analysis + if a.get("type") == "my_analysis_type"), + None +) +if existing_analysis: + logger.info(f"vCon {vcon_uuid} already processed, skipping") + return vcon_uuid +``` + +### 4. Configuration Validation + +Validate configuration options: + +```python +def validate_options(opts: dict) -> None: + """Validate configuration options.""" + required = ["api_key", "api_url"] + for key in required: + if key not in opts or not opts[key]: + raise ValueError(f"Required option '{key}' is missing or empty") +``` + +### 5. Documentation + +- Document all configuration options +- Include usage examples +- Document any external API requirements +- Include troubleshooting section + +### 6. Redis Connection Management + +For production, consider connection pooling: + +```python +import redis +from redis.connection import ConnectionPool + +# Create connection pool +pool = ConnectionPool( + host=opts.get("redis_host", "localhost"), + port=opts.get("redis_port", 6379), + db=opts.get("redis_db", 0), + max_connections=10 +) + +redis_conn = redis.Redis(connection_pool=pool) +``` + +### 7. Environment Variables + +Support environment variables for sensitive data: + +```python +import os + +default_options = { + "api_key": os.getenv("MY_LINK_API_KEY"), + "redis_host": os.getenv("REDIS_HOST", "localhost"), +} +``` + +## Publishing to PyPI (Optional) + +If you want to publish to PyPI for easier installation: + +### Step 12: Build and Publish + +```bash +# Install build tools +pip install build twine + +# Build package +python -m build + +# Upload to PyPI (test first) +python -m twine upload --repository testpypi dist/* + +# Upload to production PyPI +python -m twine upload dist/* +``` + +Then users can install with: + +```yaml +links: + my_link: + module: my_vcon_link + pip_name: my-vcon-link==0.1.0 # No git+ prefix needed +``` + +## Troubleshooting + +### Module Not Found + +- Verify the module name in config matches the package directory name +- Check that the package structure is correct +- Ensure `__init__.py` exists in the package directory + +### Import Errors + +- Verify all dependencies are listed in `pyproject.toml` +- Check Python version compatibility +- Ensure vcon library is available + +### Redis Connection Issues + +- Verify Redis is accessible from vcon-server +- Check host, port, and database settings +- Ensure Redis JSON module is enabled + +### Version Not Updating + +- Clear pip cache: `pip cache purge` +- Rebuild Docker container if using Docker +- Verify git tag/branch exists and is pushed + +## Example: Complete Minimal Link + +Here's a complete minimal example: + +**Directory structure:** +``` +simple-link/ +├── simple_link/ +│ └── __init__.py +├── pyproject.toml +├── README.md +└── .gitignore +``` + +**`simple_link/__init__.py`:** +```python +import logging +from typing import Optional +import redis +from redis.commands.json.path import Path +import vcon + +logger = logging.getLogger(__name__) + +default_options = { + "redis_host": "localhost", + "redis_port": 6379, +} + +def run(vcon_uuid: str, link_name: str, opts: dict = default_options) -> Optional[str]: + logger.info(f"Processing {vcon_uuid} with {link_name}") + + opts = {**default_options, **opts} + redis_conn = redis.Redis(host=opts["redis_host"], port=opts["redis_port"]) + + vcon_dict = redis_conn.json().get(f"vcon:{vcon_uuid}", Path.root_path()) + if not vcon_dict: + logger.error(f"vCon not found: {vcon_uuid}") + return None + + vcon_obj = vcon.Vcon(vcon_dict) + + # Add a simple tag + vcon_obj.add_tag(tag_name="processed", tag_value="true") + + redis_conn.json().set(f"vcon:{vcon_uuid}", Path.root_path(), vcon_obj.to_dict()) + + return vcon_uuid +``` + +**`pyproject.toml`:** +```toml +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "simple-link" +version = "0.1.0" +description = "A simple vCon link" +requires-python = ">=3.12,<3.14" +dependencies = ["vcon", "redis>=4.6.0"] + +[tool.setuptools] +packages = ["simple_link"] +``` + +## Next Steps + +1. **Test locally**: Test your link with a local vcon-server instance +2. **Add features**: Implement your specific processing logic +3. **Write tests**: Add comprehensive test coverage +4. **Document**: Create detailed README and inline documentation +5. **Publish**: Push to GitHub and optionally to PyPI +6. **Iterate**: Gather feedback and improve + +## Additional Resources + +- [vCon Server Link Documentation](../HOW_TO_CREATE_LINKS.md) +- [Python Packaging Guide](https://packaging.python.org/) +- [setuptools Documentation](https://setuptools.pypa.io/) +- [GitHub Actions for Python](https://docs.github.com/en/actions/guides/building-and-testing-python) + diff --git a/server/links/HOW_TO_CREATE_LINKS.md b/server/links/HOW_TO_CREATE_LINKS.md new file mode 100644 index 0000000..43bca14 --- /dev/null +++ b/server/links/HOW_TO_CREATE_LINKS.md @@ -0,0 +1,754 @@ +# How to Create New Links + +This guide will walk you through creating a new link processor for the vCon server. Links are modular components that process vCon objects as they flow through processing chains. + +## Table of Contents + +1. [Overview](#overview) +2. [Link Structure](#link-structure) +3. [Step-by-Step Guide](#step-by-step-guide) +4. [Code Examples](#code-examples) +5. [Configuration](#configuration) +6. [Testing](#testing) +7. [Best Practices](#best-practices) +8. [Common Patterns](#common-patterns) + +## Overview + +### What is a Link? + +A link is a Python module that processes vCon objects. Each link: +- Receives a vCon UUID from the processing chain +- Retrieves the vCon from Redis +- Performs operations on the vCon (analysis, transcription, tagging, etc.) +- Stores the updated vCon back to Redis +- Returns the vCon UUID (or None to stop processing) + +### Link Interface + +Every link must implement a `run()` function with this signature: + +```python +def run(vcon_uuid: str, link_name: str, opts: dict = default_options) -> Optional[str]: + """ + Process a vCon through this link. + + Args: + vcon_uuid: UUID of the vCon to process + link_name: Name of this link instance (from config) + opts: Configuration options for this link + + Returns: + vcon_uuid if processing should continue, None to stop the chain + """ + pass +``` + +## Link Structure + +### Directory Structure + +Create a new directory under `server/links/` with your link name: + +``` +server/links/ + your_link_name/ + __init__.py # Main link implementation + README.md # Documentation (optional but recommended) + tests/ # Test files (optional but recommended) + test_your_link.py +``` + +### Required Components + +1. **`__init__.py`**: Contains the `run()` function and any helper functions +2. **`default_options`**: Dictionary of default configuration values +3. **Logging**: Use the logging utility for consistent logging +4. **Error Handling**: Proper exception handling and logging + +## Step-by-Step Guide + +### Step 1: Create the Directory + +Create a new directory for your link: + +```bash +mkdir -p server/links/your_link_name +``` + +### Step 2: Create `__init__.py` + +Start with a basic template: + +```python +from lib.vcon_redis import VconRedis +from lib.logging_utils import init_logger + +logger = init_logger(__name__) + +default_options = { + # Define your default configuration options here + "option1": "default_value", + "option2": 100, +} + +def run(vcon_uuid: str, link_name: str, opts: dict = default_options) -> str: + """ + Main function to run your link. + + Args: + vcon_uuid: UUID of the vCon to process + link_name: Name of the link (for logging purposes) + opts: Options for the link + + Returns: + str: The UUID of the processed vCon (or None to stop chain) + """ + module_name = __name__.split(".")[-1] + logger.info(f"Starting {module_name}: {link_name} plugin for: {vcon_uuid}") + + # Merge provided options with defaults + merged_opts = default_options.copy() + merged_opts.update(opts) + opts = merged_opts + + # Get the vCon from Redis + vcon_redis = VconRedis() + vcon = vcon_redis.get_vcon(vcon_uuid) + + if not vcon: + logger.error(f"vCon not found: {vcon_uuid}") + return None + + # TODO: Add your processing logic here + + # Store the updated vCon back to Redis + vcon_redis.store_vcon(vcon) + + logger.info(f"Finished {module_name}:{link_name} plugin for: {vcon_uuid}") + + return vcon_uuid +``` + +### Step 3: Implement Your Logic + +Add your specific processing logic. Common operations include: + +#### Working with vCon Objects + +```python +# Access vCon properties +vcon.uuid # Get vCon UUID +vcon.parties # List of parties +vcon.dialog # List of dialogs +vcon.analysis # List of analysis entries +vcon.attachments # List of attachments +vcon.tags # Get tags attachment + +# Add tags +vcon.add_tag(tag_name="category", tag_value="important") + +# Add analysis +vcon.add_analysis( + type="sentiment", + dialog=0, # Dialog index, or None for vCon-level + vendor="your_vendor", + body={"sentiment": "positive", "score": 0.95}, + encoding="json", + extra={"model": "gpt-4"} +) + +# Add attachments +vcon.add_attachment( + type="transcript", + body="Full transcript text", + encoding="none" +) + +# Add parties +vcon.add_party({ + "tel": "+1234567890", + "name": "John Doe" +}) + +# Add dialogs +vcon.add_dialog({ + "type": "recording", + "start": "2024-01-01T00:00:00Z", + "duration": 300 +}) + +# Convert to dict for processing +vcon_dict = vcon.to_dict() + +# Convert to JSON string +vcon_json = vcon.to_json() +``` + +#### Iterating Over Dialogs + +```python +for index, dialog in enumerate(vcon.dialog): + # Process each dialog + dialog_type = dialog.get("type") + if dialog_type == "recording": + # Process recording + pass +``` + +#### Checking Existing Analysis + +```python +# Check if analysis already exists +def has_analysis(vcon, dialog_index, analysis_type): + for analysis in vcon.analysis: + if analysis.get("dialog") == dialog_index and analysis.get("type") == analysis_type: + return True + return False + +# Use it to skip already processed items +if has_analysis(vcon, index, "transcription"): + logger.info(f"Dialog {index} already transcribed, skipping") + continue +``` + +### Step 4: Add Configuration to `config.yml` + +Add your link to the configuration file: + +```yaml +links: + your_link_name: + module: links.your_link_name + ingress-lists: [] # Optional: specific ingress lists + egress-lists: [] # Optional: specific egress lists + options: + option1: "custom_value" + option2: 200 +``` + +### Step 5: Add to a Chain + +Add your link to a processing chain: + +```yaml +chains: + my_chain: + links: + - existing_link + - your_link_name # Your new link + - another_link + ingress_lists: + - my_input_list + storages: + - mongo + egress_lists: + - my_output_list + enabled: 1 +``` + +## Code Examples + +### Example 1: Simple Tag Link + +This example adds tags to a vCon: + +```python +from lib.vcon_redis import VconRedis +from lib.logging_utils import init_logger + +logger = init_logger(__name__) + +default_options = { + "tags": ["processed", "reviewed"], +} + +def run(vcon_uuid: str, link_name: str, opts: dict = default_options) -> str: + logger.info(f"Starting tag link for: {vcon_uuid}") + + # Merge options + merged_opts = default_options.copy() + merged_opts.update(opts) + opts = merged_opts + + # Get vCon + vcon_redis = VconRedis() + vcon = vcon_redis.get_vcon(vcon_uuid) + + if not vcon: + logger.error(f"vCon not found: {vcon_uuid}") + return None + + # Add tags + for tag in opts.get("tags", []): + vcon.add_tag(tag_name=tag, tag_value=tag) + + # Store updated vCon + vcon_redis.store_vcon(vcon) + + logger.info(f"Finished tag link for: {vcon_uuid}") + return vcon_uuid +``` + +### Example 2: Analysis Link with External API + +This example calls an external API and adds analysis: + +```python +from lib.vcon_redis import VconRedis +from lib.logging_utils import init_logger +import requests +from tenacity import retry, stop_after_attempt, wait_exponential + +logger = init_logger(__name__) + +default_options = { + "api_url": "https://api.example.com/analyze", + "api_key": None, + "analysis_type": "sentiment", +} + +@retry( + wait=wait_exponential(multiplier=2, min=1, max=65), + stop=stop_after_attempt(6), +) +def call_api(text: str, opts: dict) -> dict: + """Call external API with retry logic.""" + headers = {"Authorization": f"Bearer {opts['api_key']}"} + response = requests.post( + opts["api_url"], + json={"text": text}, + headers=headers, + timeout=30 + ) + response.raise_for_status() + return response.json() + +def run(vcon_uuid: str, link_name: str, opts: dict = default_options) -> str: + logger.info(f"Starting analysis link for: {vcon_uuid}") + + merged_opts = default_options.copy() + merged_opts.update(opts) + opts = merged_opts + + if not opts.get("api_key"): + raise ValueError("API key is required") + + vcon_redis = VconRedis() + vcon = vcon_redis.get_vcon(vcon_uuid) + + if not vcon: + logger.error(f"vCon not found: {vcon_uuid}") + return None + + # Process each dialog + for index, dialog in enumerate(vcon.dialog): + # Check if already analyzed + existing = next( + (a for a in vcon.analysis + if a.get("dialog") == index and a.get("type") == opts["analysis_type"]), + None + ) + if existing: + logger.info(f"Dialog {index} already analyzed, skipping") + continue + + # Extract text from dialog (example) + text = dialog.get("body", {}).get("transcript", "") + if not text: + logger.warning(f"No transcript found for dialog {index}") + continue + + # Call API + try: + result = call_api(text, opts) + + # Add analysis + vcon.add_analysis( + type=opts["analysis_type"], + dialog=index, + vendor="example_api", + body=result, + encoding="json" + ) + except Exception as e: + logger.error(f"Failed to analyze dialog {index}: {e}") + raise + + vcon_redis.store_vcon(vcon) + logger.info(f"Finished analysis link for: {vcon_uuid}") + return vcon_uuid +``` + +### Example 3: Filtering Link + +This example filters vCons based on conditions: + +```python +from lib.vcon_redis import VconRedis +from lib.logging_utils import init_logger + +logger = init_logger(__name__) + +default_options = { + "min_duration": 60, # Minimum duration in seconds + "forward_on_match": True, # Forward if matches, otherwise forward if doesn't match +} + +def run(vcon_uuid: str, link_name: str, opts: dict = default_options) -> str: + logger.info(f"Starting filter link for: {vcon_uuid}") + + merged_opts = default_options.copy() + merged_opts.update(opts) + opts = merged_opts + + vcon_redis = VconRedis() + vcon = vcon_redis.get_vcon(vcon_uuid) + + if not vcon: + logger.error(f"vCon not found: {vcon_uuid}") + return None + + # Check total duration + total_duration = 0 + for dialog in vcon.dialog: + duration = dialog.get("duration", 0) + total_duration += duration + + # Apply filter + matches = total_duration >= opts["min_duration"] + should_forward = matches == opts["forward_on_match"] + + if should_forward: + logger.info(f"vCon {vcon_uuid} matches filter - forwarding") + return vcon_uuid + else: + logger.info(f"vCon {vcon_uuid} does not match filter - filtering out") + return None # Stop processing chain +``` + +## Configuration + +### Option Merging Pattern + +Always merge user-provided options with defaults: + +```python +merged_opts = default_options.copy() +merged_opts.update(opts) +opts = merged_opts +``` + +This ensures all expected options are present with sensible defaults. + +### Environment Variables + +You can access environment variables in your link: + +```python +import os + +api_key = opts.get("API_KEY") or os.getenv("MY_API_KEY") +``` + +### Sensitive Data + +For sensitive data like API keys, prefer configuration over hardcoding: + +```yaml +links: + my_link: + module: links.my_link + options: + API_KEY: ${MY_API_KEY} # Use environment variable substitution +``` + +## Testing + +### Basic Test Structure + +Create a test file in `server/links/your_link_name/tests/test_your_link.py`: + +```python +import pytest +from unittest.mock import patch, MagicMock +from links.your_link_name import run +from vcon import Vcon + +@pytest.fixture +def mock_vcon_redis(): + """Mock the VconRedis class""" + with patch('links.your_link_name.VconRedis') as mock: + yield mock + +@pytest.fixture +def sample_vcon(): + """Create a sample vCon for testing""" + return Vcon({ + "uuid": "test-uuid", + "vcon": "0.0.1", + "parties": [], + "dialog": [ + { + "type": "recording", + "duration": 120 + } + ], + "analysis": [], + "attachments": [] + }) + +@pytest.fixture +def mock_redis_with_vcon(mock_vcon_redis, sample_vcon): + """Set up mock Redis with sample vCon""" + mock_instance = MagicMock() + mock_instance.get_vcon.return_value = sample_vcon + mock_vcon_redis.return_value = mock_instance + return mock_vcon_redis + +def test_basic_functionality(mock_redis_with_vcon): + """Test basic link functionality""" + mock_vcon_redis, _ = mock_redis_with_vcon + + opts = { + "option1": "test_value" + } + + result = run("test-uuid", "test-link", opts) + + # Verify vCon was retrieved + mock_instance = mock_vcon_redis.return_value + mock_instance.get_vcon.assert_called_once_with("test-uuid") + + # Verify vCon was stored + mock_instance.store_vcon.assert_called_once() + + # Verify return value + assert result == "test-uuid" + +def test_missing_vcon(mock_vcon_redis): + """Test handling of missing vCon""" + mock_instance = MagicMock() + mock_instance.get_vcon.return_value = None + mock_vcon_redis.return_value = mock_instance + + result = run("missing-uuid", "test-link") + + assert result is None + mock_instance.store_vcon.assert_not_called() +``` + +### Running Tests + +```bash +# Run all tests for your link +pytest server/links/your_link_name/tests/ + +# Run with verbose output +pytest -v server/links/your_link_name/tests/ + +# Run specific test +pytest server/links/your_link_name/tests/test_your_link.py::test_basic_functionality +``` + +## Best Practices + +### 1. Logging + +- Use the `init_logger(__name__)` utility for consistent logging +- Log at appropriate levels: + - `logger.debug()`: Detailed debugging information + - `logger.info()`: Important processing steps + - `logger.warning()`: Non-fatal issues + - `logger.error()`: Errors that need attention +- Include vCon UUID in log messages for traceability + +### 2. Error Handling + +- Always check if vCon exists before processing +- Handle missing or invalid data gracefully +- Use try/except blocks for external API calls +- Log errors with context +- Re-raise exceptions if the chain should stop + +```python +try: + result = external_api_call(data) +except requests.RequestException as e: + logger.error(f"API call failed for vCon {vcon_uuid}: {e}") + raise # Re-raise to stop chain processing +``` + +### 3. Idempotency + +Make your link idempotent when possible - safe to run multiple times: + +```python +# Check if already processed +existing_analysis = next( + (a for a in vcon.analysis + if a.get("type") == opts["analysis_type"]), + None +) +if existing_analysis: + logger.info("Already processed, skipping") + return vcon_uuid +``` + +### 4. Performance + +- Skip processing when possible (check for existing results) +- Use efficient data structures +- Consider batch operations for multiple items +- Log processing time for monitoring + +```python +import time + +start = time.time() +# ... processing ... +elapsed = time.time() - start +logger.info(f"Processing took {elapsed:.2f} seconds") +``` + +### 5. Metrics + +Use the metrics utility for monitoring: + +```python +from lib.metrics import init_metrics, stats_count, stats_gauge + +init_metrics() + +# Count events +stats_count("conserver.link.your_link.processed", tags=["status:success"]) + +# Track values +stats_gauge("conserver.link.your_link.processing_time", elapsed_time) +``` + +### 6. Return Values + +- Return `vcon_uuid` (string) to continue processing +- Return `None` to stop the processing chain (useful for filters) +- You can return a different UUID if you create a new vCon + +### 7. Documentation + +- Add docstrings to your `run()` function +- Document all configuration options +- Include usage examples in README.md +- Document any external dependencies + +## Common Patterns + +### Pattern 1: Processing with Retry Logic + +```python +from tenacity import retry, stop_after_attempt, wait_exponential, before_sleep_log +import logging + +@retry( + wait=wait_exponential(multiplier=2, min=1, max=65), + stop=stop_after_attempt(6), + before_sleep=before_sleep_log(logger, logging.INFO), +) +def call_external_service(data): + # Your API call here + pass +``` + +### Pattern 2: Sampling + +```python +from lib.links.filters import randomly_execute_with_sampling + +if not randomly_execute_with_sampling(opts): + logger.info(f"Skipping {vcon_uuid} due to sampling") + return vcon_uuid +``` + +### Pattern 3: Conditional Processing + +```python +from lib.links.filters import is_included + +if not is_included(opts, vcon): + logger.info(f"Skipping {vcon_uuid} due to filters") + return vcon_uuid +``` + +### Pattern 4: Processing Each Dialog + +```python +for index, dialog in enumerate(vcon.dialog): + # Skip if already processed + if has_analysis(vcon, index, "my_analysis"): + continue + + # Process dialog + result = process_dialog(dialog) + + # Add result to vCon + vcon.add_analysis( + type="my_analysis", + dialog=index, + vendor="my_vendor", + body=result, + encoding="json" + ) +``` + +### Pattern 5: Extracting Text from Analysis + +```python +def get_transcript_from_analysis(vcon, dialog_index): + """Extract transcript from existing analysis.""" + for analysis in vcon.analysis: + if analysis.get("dialog") == dialog_index and analysis.get("type") == "transcript": + return analysis.get("body", {}).get("transcript", "") + return None +``` + +## Next Steps + +1. **Review Existing Links**: Look at similar links in `server/links/` for inspiration +2. **Test Thoroughly**: Write comprehensive tests for your link +3. **Document**: Create a README.md explaining your link's purpose and configuration +4. **Add to Chains**: Integrate your link into processing chains +5. **Monitor**: Use metrics and logging to monitor your link's performance + +## Additional Resources + +- See `server/links/README.md` for a list of available links +- See `prod_mgt/04_LINK_PROCESSORS.md` for more link documentation +- See `example_config.yml` for configuration examples +- Review existing link implementations for patterns and best practices + +## Troubleshooting + +### Link Not Being Called + +- Check that the link is in `config.yml` under `links:` +- Verify the module path is correct: `module: links.your_link_name` +- Ensure the link is added to a chain's `links:` list +- Check that the chain is `enabled: 1` + +### Import Errors + +- Ensure your `__init__.py` has proper imports +- Check that dependencies are installed +- Verify module paths match the directory structure + +### vCon Not Found + +- Verify Redis is running and accessible +- Check that vCons are being stored correctly +- Ensure the vCon UUID is valid + +### Options Not Working + +- Verify option merging pattern is used +- Check that default_options includes all expected keys +- Ensure options are passed correctly in config.yml + diff --git a/server/links/analyze/__init__.py b/server/links/analyze/__init__.py index dd5ae7a..06b5981 100644 --- a/server/links/analyze/__init__.py +++ b/server/links/analyze/__init__.py @@ -1,17 +1,9 @@ from lib.vcon_redis import VconRedis from lib.logging_utils import init_logger -import logging -from openai import OpenAI, AzureOpenAI -from tenacity import ( - retry, - stop_after_attempt, - wait_exponential, - before_sleep_log, -) # for exponential backoff from lib.metrics import record_histogram, increment_counter -import time from lib.links.filters import is_included, randomly_execute_with_sampling -from lib.ai_usage import send_ai_usage_data_for_tracking +from lib.llm_client import create_llm_client, get_vendor_from_response +import time logger = init_logger(__name__) @@ -36,44 +28,29 @@ def get_analysis_for_type(vcon, index, analysis_type): return None -@retry( - wait=wait_exponential(multiplier=2, min=1, max=65), - stop=stop_after_attempt(6), - before_sleep=before_sleep_log(logger, logging.INFO), -) def generate_analysis( transcript, client, vcon_uuid, opts -) -> str: - # Extract parameters from opts +): + """Generate analysis using the LLM client. + + Returns: + Tuple of (analysis_text, response) where response contains provider info + """ prompt = opts.get("prompt", "") - model = opts["model"] - temperature = opts["temperature"] system_prompt = opts.get("system_prompt", "You are a helpful assistant.") - send_ai_usage_data_to_url = opts.get("send_ai_usage_data_to_url", "") - ai_usage_api_token = opts.get("ai_usage_api_token", "") - - # logger.info(f"TRANSCRIPT: {transcript}") - # logger.info(f"PROMPT: {prompt}") + messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": prompt + "\n\n" + transcript}, ] - # logger.info(f"messages: {messages}") - # logger.info(f"MODEL: {model}") - sentiment_result = client.chat.completions.create(model=model, messages=messages, temperature=temperature) - send_ai_usage_data_for_tracking( + response = client.complete_with_tracking( + messages=messages, vcon_uuid=vcon_uuid, - input_units=sentiment_result.usage.prompt_tokens, - output_units=sentiment_result.usage.completion_tokens, - unit_type="tokens", - type="VCON_PROCESSING", - send_ai_usage_data_to_url=send_ai_usage_data_to_url, - ai_usage_api_token=ai_usage_api_token, - model=model, + tracking_opts=opts, sub_type="ANALYZE", ) - return sentiment_result.choices[0].message.content + return response.content, response def run( @@ -98,24 +75,9 @@ def run( logger.info(f"Skipping {link_name} vCon {vcon_uuid} due to sampling") return vcon_uuid - # Extract credentials from options - openai_api_key = opts.get("OPENAI_API_KEY") - azure_openai_api_key = opts.get("AZURE_OPENAI_API_KEY") - azure_openai_endpoint = opts.get("AZURE_OPENAI_ENDPOINT") - api_version = opts.get("AZURE_OPENAI_API_VERSION") - - client = None - if openai_api_key: - client = OpenAI(api_key=openai_api_key, timeout=120.0, max_retries=0) - logger.info("Using public OpenAI client") - elif azure_openai_api_key and azure_openai_endpoint: - client = AzureOpenAI(api_key=azure_openai_api_key, azure_endpoint=azure_openai_endpoint, api_version=api_version) - logger.info(f"Using Azure OpenAI client at endpoint:{azure_openai_endpoint}") - else: - raise ValueError( - "OpenAI or Azure OpenAI credentials not provided. " - "Need OPENAI_API_KEY or AZURE_OPENAI_API_KEY and AZURE_OPENAI_ENDPOINT" - ) + # Create LLM client (supports OpenAI, Anthropic, and LiteLLM providers) + client = create_llm_client(opts) + logger.info(f"Using {client.provider_name} provider for model {opts.get('model', 'default')}") source_type = navigate_dict(opts, "source.analysis_type") text_location = navigate_dict(opts, "source.text_location") @@ -146,7 +108,8 @@ def run( k: v for k, v in opts.items() if k not in ( "OPENAI_API_KEY", "AZURE_OPENAI_API_KEY", - "AZURE_OPENAI_ENDPOINT", "ai_usage_api_token" + "AZURE_OPENAI_ENDPOINT", "ANTHROPIC_API_KEY", + "ai_usage_api_token" ) } logger.info( @@ -156,7 +119,7 @@ def run( ) start = time.time() try: - analysis = generate_analysis( + analysis_text, response = generate_analysis( transcript=source_text, client=client, vcon_uuid=vcon_uuid, @@ -169,25 +132,26 @@ def run( e, ) increment_counter( - "conserver.link.openai.analysis_failures", - attributes={"analysis_type": opts['analysis_type']}, + "conserver.link.llm.analysis_failures", + attributes={"analysis_type": opts['analysis_type'], "provider": client.provider_name}, ) raise e record_histogram( - "conserver.link.openai.analysis_time", + "conserver.link.llm.analysis_time", time.time() - start, - attributes={"analysis_type": opts['analysis_type']}, + attributes={"analysis_type": opts['analysis_type'], "provider": client.provider_name}, ) vendor_schema = {} - vendor_schema["model"] = opts["model"] + vendor_schema["model"] = response.model vendor_schema["prompt"] = opts["prompt"] + vendor_schema["provider"] = response.provider vCon.add_analysis( type=opts["analysis_type"], dialog=index, - vendor="openai", - body=analysis, + vendor=get_vendor_from_response(response), + body=analysis_text, encoding="none", extra={ "vendor_schema": vendor_schema, diff --git a/server/links/analyze/tests/test_analyze.py b/server/links/analyze/tests/test_analyze.py index f5a2c5f..a594c48 100644 --- a/server/links/analyze/tests/test_analyze.py +++ b/server/links/analyze/tests/test_analyze.py @@ -3,7 +3,7 @@ These tests cover the analysis functionality, including: - Analysis generation with customizable system prompts -- OpenAI API integration for text analysis +- LLM client integration for text analysis (supports OpenAI, Anthropic, LiteLLM) - Handling of missing transcripts, API errors, and sampling logic - Ensuring correct analysis addition to vCon objects @@ -11,7 +11,7 @@ """ import os import pytest -from unittest.mock import Mock, patch +from unittest.mock import Mock, patch, MagicMock from server.links.analyze import ( generate_analysis, run, @@ -20,6 +20,7 @@ get_analysis_for_type, ) from server.vcon import Vcon +from lib.llm_client import LLMResponse from dotenv import load_dotenv # Load environment variables from .env file for API keys, etc. @@ -139,146 +140,146 @@ def test_get_analysis_for_type_not_found(self): class TestGenerateAnalysis: """Test the generate_analysis function""" - - @patch('server.links.analyze.send_ai_usage_data_for_tracking') - @patch('server.links.analyze.OpenAI') - def test_generate_analysis_basic(self, mock_openai, mock_send_usage): - """Test basic analysis generation with mocked client""" - # Setup mock client + + def test_generate_analysis_basic(self): + """Test basic analysis generation with mocked LLM client""" + # Setup mock LLM client mock_client = Mock() - mock_response = Mock() - mock_response.choices = [Mock()] - mock_response.choices[0].message.content = "This is a test analysis." - mock_response.usage = Mock() - mock_response.usage.prompt_tokens = 10 - mock_response.usage.completion_tokens = 20 - mock_client.chat.completions.create.return_value = mock_response - mock_openai.return_value = mock_client - + mock_response = LLMResponse( + content="This is a test analysis.", + model="gpt-3.5-turbo", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + raw_response=None, + provider="openai" + ) + mock_client.complete_with_tracking.return_value = mock_response + opts = { "prompt": "Summarize this", "model": "gpt-3.5-turbo", "temperature": 0, "system_prompt": "You are a helpful assistant.", } - - result = generate_analysis( + + result, response = generate_analysis( transcript="Test transcript", client=mock_client, vcon_uuid="test-uuid", opts=opts ) - + assert result == "This is a test analysis." - mock_client.chat.completions.create.assert_called_once() - - @patch('server.links.analyze.send_ai_usage_data_for_tracking') - @patch('server.links.analyze.OpenAI') - def test_generate_analysis_with_custom_system_prompt(self, mock_openai, mock_send_usage): + mock_client.complete_with_tracking.assert_called_once() + + def test_generate_analysis_with_custom_system_prompt(self): """Test analysis generation with custom system prompt""" - # Setup mock client + # Setup mock LLM client mock_client = Mock() - mock_response = Mock() - mock_response.choices = [Mock()] - mock_response.choices[0].message.content = "Custom analysis." - mock_response.usage = Mock() - mock_response.usage.prompt_tokens = 10 - mock_response.usage.completion_tokens = 20 - mock_client.chat.completions.create.return_value = mock_response - mock_openai.return_value = mock_client - + mock_response = LLMResponse( + content="Custom analysis.", + model="gpt-3.5-turbo", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + raw_response=None, + provider="openai" + ) + mock_client.complete_with_tracking.return_value = mock_response + custom_system_prompt = "You are a specialized financial analyst." - + opts = { "prompt": "Analyze this financial data", "model": "gpt-3.5-turbo", "temperature": 0, "system_prompt": custom_system_prompt, } - - result = generate_analysis( + + result, response = generate_analysis( transcript="Test transcript", client=mock_client, vcon_uuid="test-uuid", opts=opts ) - + assert result == "Custom analysis." - + # Verify the system prompt was used correctly - call_args = mock_client.chat.completions.create.call_args + call_args = mock_client.complete_with_tracking.call_args messages = call_args[1]['messages'] assert messages[0]['role'] == 'system' assert messages[0]['content'] == custom_system_prompt assert messages[1]['role'] == 'user' assert 'Analyze this financial data' in messages[1]['content'] - - @patch('server.links.analyze.send_ai_usage_data_for_tracking') - @patch('server.links.analyze.OpenAI') - def test_generate_analysis_with_empty_prompt(self, mock_openai, mock_send_usage): + + def test_generate_analysis_with_empty_prompt(self): """Test analysis generation with empty prompt""" - # Setup mock client + # Setup mock LLM client mock_client = Mock() - mock_response = Mock() - mock_response.choices = [Mock()] - mock_response.choices[0].message.content = "Analysis with empty prompt." - mock_response.usage = Mock() - mock_response.usage.prompt_tokens = 10 - mock_response.usage.completion_tokens = 20 - mock_client.chat.completions.create.return_value = mock_response - mock_openai.return_value = mock_client - + mock_response = LLMResponse( + content="Analysis with empty prompt.", + model="gpt-3.5-turbo", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + raw_response=None, + provider="openai" + ) + mock_client.complete_with_tracking.return_value = mock_response + opts = { "prompt": "", "model": "gpt-3.5-turbo", "temperature": 0, "system_prompt": "You are a helpful assistant.", } - - result = generate_analysis( + + result, response = generate_analysis( transcript="Test transcript", client=mock_client, vcon_uuid="test-uuid", opts=opts ) - + assert result == "Analysis with empty prompt." - + # Verify the user message contains only the transcript - call_args = mock_client.chat.completions.create.call_args + call_args = mock_client.complete_with_tracking.call_args messages = call_args[1]['messages'] assert messages[1]['content'] == "\n\nTest transcript" - - @patch('server.links.analyze.send_ai_usage_data_for_tracking') - @patch('server.links.analyze.OpenAI') - def test_generate_analysis_with_default_system_prompt(self, mock_openai, mock_send_usage): + + def test_generate_analysis_with_default_system_prompt(self): """Test analysis generation uses default system prompt when not provided""" - # Setup mock client + # Setup mock LLM client mock_client = Mock() - mock_response = Mock() - mock_response.choices = [Mock()] - mock_response.choices[0].message.content = "Default analysis." - mock_response.usage = Mock() - mock_response.usage.prompt_tokens = 10 - mock_response.usage.completion_tokens = 20 - mock_client.chat.completions.create.return_value = mock_response - mock_openai.return_value = mock_client - + mock_response = LLMResponse( + content="Default analysis.", + model="gpt-3.5-turbo", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + raw_response=None, + provider="openai" + ) + mock_client.complete_with_tracking.return_value = mock_response + opts = { "prompt": "Test prompt", "model": "gpt-3.5-turbo", "temperature": 0, } - + generate_analysis( transcript="Test transcript", client=mock_client, vcon_uuid="test-uuid", opts=opts ) - + # Verify the default system prompt was used - call_args = mock_client.chat.completions.create.call_args + call_args = mock_client.complete_with_tracking.call_args messages = call_args[1]['messages'] assert messages[0]['content'] == "You are a helpful assistant." @@ -310,84 +311,112 @@ def test_default_options_values(self): class TestRunFunction: """Test the main run function""" - - @patch('server.links.analyze.generate_analysis') + + @pytest.fixture + def mock_llm_response(self): + """Create a standard mock LLM response""" + return LLMResponse( + content="This is a test analysis.", + model="gpt-3.5-turbo", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + raw_response=None, + provider="openai" + ) + + @patch('server.links.analyze.create_llm_client') @patch('server.links.analyze.is_included', return_value=True) @patch('server.links.analyze.randomly_execute_with_sampling', return_value=True) - def test_run_basic(self, mock_sampling, mock_is_included, mock_generate_analysis, mock_redis_with_vcon, sample_vcon): - """Test the basic run functionality with mocked analysis generation""" - # Set up mock to return analysis - mock_generate_analysis.return_value = "This is a test analysis." - + def test_run_basic(self, mock_sampling, mock_is_included, mock_create_client, mock_redis_with_vcon, sample_vcon, mock_llm_response): + """Test the basic run functionality with mocked LLM client""" + # Set up mock LLM client + mock_client = Mock() + mock_client.complete_with_tracking.return_value = mock_llm_response + mock_client.provider_name = "openai" + mock_create_client.return_value = mock_client + # Set up the mock Redis instance to return our sample vCon mock_instance = mock_redis_with_vcon mock_instance.get_vcon.return_value = sample_vcon - + # Run with default options but add API key opts = {"OPENAI_API_KEY": API_KEY} - + result = run("test-uuid", "analyze", opts) - + # Check that vCon was processed and returned assert result == "test-uuid" - - # Verify analysis generation was called - mock_generate_analysis.assert_called_once() - + + # Verify LLM client was created + mock_create_client.assert_called_once() + # Verify vCon was updated and stored mock_redis_with_vcon.store_vcon.assert_called_once() - + # Check the vCon has an analysis sample_vcon.add_analysis.assert_called_once() - - @patch('server.links.analyze.generate_analysis') + + @patch('server.links.analyze.create_llm_client') @patch('server.links.analyze.is_included', return_value=True) @patch('server.links.analyze.randomly_execute_with_sampling', return_value=True) def test_run_with_custom_system_prompt( - self, mock_sampling, mock_is_included, mock_generate_analysis, + self, mock_sampling, mock_is_included, mock_create_client, mock_redis_with_vcon, sample_vcon ): """Test run function with custom system prompt""" - # Set up mock to return analysis - mock_generate_analysis.return_value = "Custom analysis with custom system prompt." - + # Set up mock LLM client + mock_client = Mock() + mock_response = LLMResponse( + content="Custom analysis with custom system prompt.", + model="gpt-3.5-turbo", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + raw_response=None, + provider="openai" + ) + mock_client.complete_with_tracking.return_value = mock_response + mock_client.provider_name = "openai" + mock_create_client.return_value = mock_client + # Set up the mock Redis instance to return our sample vCon mock_instance = mock_redis_with_vcon mock_instance.get_vcon.return_value = sample_vcon - + # Run with custom system prompt opts = { "OPENAI_API_KEY": API_KEY, "system_prompt": "You are a specialized customer service analyst.", "prompt": "Analyze this customer interaction." } - + result = run("test-uuid", "analyze", opts) - + # Check that vCon was processed and returned assert result == "test-uuid" - - # Verify analysis generation was called with opts containing custom system prompt - mock_generate_analysis.assert_called_once() - call_args = mock_generate_analysis.call_args - assert call_args[1]['opts']['system_prompt'] == "You are a specialized customer service analyst." - assert call_args[1]['opts']['prompt'] == "Analyze this customer interaction." - + + # Verify LLM client complete_with_tracking was called with correct messages + mock_client.complete_with_tracking.assert_called_once() + call_args = mock_client.complete_with_tracking.call_args + messages = call_args[1]['messages'] + assert messages[0]['content'] == "You are a specialized customer service analyst." + @patch('server.links.analyze.is_included', return_value=False) def test_run_skipped_due_to_filters(self, mock_is_included, mock_redis_with_vcon): """Test that run is skipped when filters exclude the vCon""" # Set up the mock Redis instance to return a sample vCon sample_vcon = Mock() mock_redis_with_vcon.get_vcon.return_value = sample_vcon - + result = run("test-uuid", "analyze", {"OPENAI_API_KEY": API_KEY}) - + # Should return the vcon_uuid without processing assert result == "test-uuid" - + # Should have called get_vcon but then skipped due to filters mock_redis_with_vcon.get_vcon.assert_called_once_with("test-uuid") - + @patch('server.links.analyze.is_included', return_value=True) @patch('server.links.analyze.randomly_execute_with_sampling', return_value=False) def test_run_skipped_due_to_sampling(self, mock_sampling, mock_is_included, mock_redis_with_vcon): @@ -395,60 +424,62 @@ def test_run_skipped_due_to_sampling(self, mock_sampling, mock_is_included, mock # Set up the mock Redis instance to return a sample vCon sample_vcon = Mock() mock_redis_with_vcon.get_vcon.return_value = sample_vcon - + result = run("test-uuid", "analyze", {"OPENAI_API_KEY": API_KEY}) - + # Should return the vcon_uuid without processing assert result == "test-uuid" - + # Should have called get_vcon but then skipped due to sampling mock_redis_with_vcon.get_vcon.assert_called_once_with("test-uuid") - - @patch('server.links.analyze.generate_analysis') + + @patch('server.links.analyze.create_llm_client') @patch('server.links.analyze.is_included', return_value=True) @patch('server.links.analyze.randomly_execute_with_sampling', return_value=True) def test_run_with_azure_openai( - self, mock_sampling, mock_is_included, mock_generate_analysis, + self, mock_sampling, mock_is_included, mock_create_client, mock_redis_with_vcon, sample_vcon ): """Test run function with Azure OpenAI credentials""" - # Set up mock to return analysis - mock_generate_analysis.return_value = "Azure OpenAI analysis." - + # Set up mock LLM client + mock_client = Mock() + mock_response = LLMResponse( + content="Azure OpenAI analysis.", + model="gpt-4", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + raw_response=None, + provider="openai" + ) + mock_client.complete_with_tracking.return_value = mock_response + mock_client.provider_name = "openai" + mock_create_client.return_value = mock_client + # Set up the mock Redis instance to return our sample vCon mock_instance = mock_redis_with_vcon mock_instance.get_vcon.return_value = sample_vcon - + # Run with Azure OpenAI credentials opts = { "AZURE_OPENAI_API_KEY": "azure-key", "AZURE_OPENAI_ENDPOINT": "https://azure-endpoint.com", "AZURE_OPENAI_API_VERSION": "2023-12-01-preview" } - - with patch('server.links.analyze.AzureOpenAI') as mock_azure: - mock_azure_instance = Mock() - mock_azure.return_value = mock_azure_instance - - result = run("test-uuid", "analyze", opts) - - # Check that vCon was processed and returned - assert result == "test-uuid" - - # Verify Azure OpenAI was used - mock_azure.assert_called_once() - - def test_run_missing_credentials(self, mock_redis_with_vcon): - """Test that run raises error when no credentials are provided""" - error_msg = "OpenAI or Azure OpenAI credentials not provided" - with pytest.raises(ValueError, match=error_msg): - run("test-uuid", "analyze", {}) - - @patch('server.links.analyze.generate_analysis') + + result = run("test-uuid", "analyze", opts) + + # Check that vCon was processed and returned + assert result == "test-uuid" + + # Verify LLM client was created with Azure credentials + mock_create_client.assert_called_once() + + @patch('server.links.analyze.create_llm_client') @patch('server.links.analyze.is_included', return_value=True) @patch('server.links.analyze.randomly_execute_with_sampling', return_value=True) def test_run_already_has_analysis( - self, mock_sampling, mock_is_included, mock_generate_analysis, + self, mock_sampling, mock_is_included, mock_create_client, mock_redis_with_vcon, sample_vcon ): """Test that run skips when analysis already exists""" @@ -459,48 +490,55 @@ def test_run_already_has_analysis( "body": "Existing analysis" } sample_vcon.analysis.append(existing_analysis) - + # Set up the mock Redis instance to return our sample vCon mock_instance = mock_redis_with_vcon mock_instance.get_vcon.return_value = sample_vcon - + + # Set up mock LLM client (should not be called) + mock_client = Mock() + mock_create_client.return_value = mock_client + result = run("test-uuid", "analyze", {"OPENAI_API_KEY": API_KEY}) - + # Should return without generating new analysis assert result == "test-uuid" - mock_generate_analysis.assert_not_called() - - @patch('server.links.analyze.generate_analysis') + mock_client.complete_with_tracking.assert_not_called() + + @patch('server.links.analyze.create_llm_client') @patch('server.links.analyze.is_included', return_value=True) @patch('server.links.analyze.randomly_execute_with_sampling', return_value=True) def test_run_analysis_failure( - self, mock_sampling, mock_is_included, mock_generate_analysis, + self, mock_sampling, mock_is_included, mock_create_client, mock_redis_with_vcon, sample_vcon ): """Test that run handles analysis generation failures""" - # Set up mock to raise exception - mock_generate_analysis.side_effect = Exception("API Error") - + # Set up mock LLM client to raise exception + mock_client = Mock() + mock_client.complete_with_tracking.side_effect = Exception("API Error") + mock_client.provider_name = "openai" + mock_create_client.return_value = mock_client + # Set up the mock Redis instance to return our sample vCon mock_instance = mock_redis_with_vcon mock_instance.get_vcon.return_value = sample_vcon - + with pytest.raises(Exception, match="API Error"): run("test-uuid", "analyze", {"OPENAI_API_KEY": API_KEY}) @pytest.mark.skipif(not RUN_API_TESTS, reason="Skipping API tests. Set RUN_OPENAI_ANALYZE_TESTS=1 to run") class TestRealAPIIntegration: - """Test with real OpenAI API (optional)""" - + """Test with real LLM API (optional)""" + def test_generate_analysis_real_api(self): - """Test the generate_analysis function with the real OpenAI API""" + """Test the generate_analysis function with the real LLM API""" # Skip if no API key is provided if not os.environ.get("OPENAI_API_KEY"): pytest.skip("No OpenAI API key provided via OPENAI_API_KEY environment variable") - - from openai import OpenAI - + + from lib.llm_client import create_llm_client + # Sample transcript transcript = ( "Customer: Hi, I'm calling about my recent bill. I think there's an error. " @@ -509,60 +547,63 @@ def test_generate_analysis_real_api(self): "Agent: You're right, I see the duplicate charge. I'll process a refund right away. " "Customer: Thank you, I appreciate that." ) - - # Create real client - client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) - + # Prepare opts opts = { "prompt": "Summarize this customer service interaction in one sentence.", "model": "gpt-3.5-turbo", # Use cheaper model for tests "temperature": 0, "system_prompt": "You are a helpful assistant.", + "OPENAI_API_KEY": os.environ["OPENAI_API_KEY"], } - + + # Create LLM client + client = create_llm_client(opts) + # Call the function - result = generate_analysis( + result, response = generate_analysis( transcript=transcript, client=client, vcon_uuid="test-uuid", opts=opts ) - + # Check that we get a valid response assert isinstance(result, str) assert len(result) > 0 - + assert response.provider == "openai" + def test_generate_analysis_real_api_custom_system_prompt(self): """Test the generate_analysis function with custom system prompt using real API""" # Skip if no API key is provided if not os.environ.get("OPENAI_API_KEY"): pytest.skip("No OpenAI API key provided via OPENAI_API_KEY environment variable") - - from openai import OpenAI - + + from lib.llm_client import create_llm_client + # Sample transcript transcript = "The customer called about a billing issue and was very upset." - - # Create real client - client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) - + # Prepare opts with custom system prompt opts = { "prompt": "Analyze the customer's emotional state.", "model": "gpt-3.5-turbo", "temperature": 0, "system_prompt": "You are a customer service expert specializing in emotional analysis.", + "OPENAI_API_KEY": os.environ["OPENAI_API_KEY"], } - + + # Create LLM client + client = create_llm_client(opts) + # Call the function with custom system prompt - result = generate_analysis( + result, response = generate_analysis( transcript=transcript, client=client, vcon_uuid="test-uuid", opts=opts ) - + # Check that we get a valid response assert isinstance(result, str) assert len(result) > 0 diff --git a/server/links/analyze_and_label/__init__.py b/server/links/analyze_and_label/__init__.py index ee9a2cf..a558316 100644 --- a/server/links/analyze_and_label/__init__.py +++ b/server/links/analyze_and_label/__init__.py @@ -1,17 +1,10 @@ from lib.vcon_redis import VconRedis from lib.logging_utils import init_logger -import logging -import json -from openai import OpenAI -from tenacity import ( - retry, - stop_after_attempt, - wait_exponential, - before_sleep_log, -) # for exponential backoff from lib.metrics import record_histogram, increment_counter -import time from lib.links.filters import is_included, randomly_execute_with_sampling +from lib.llm_client import create_llm_client, get_vendor_from_response +import json +import time logger = init_logger(__name__) @@ -36,25 +29,28 @@ def get_analysis_for_type(vcon, index, analysis_type): return None -@retry( - wait=wait_exponential(multiplier=2, min=1, max=65), - stop=stop_after_attempt(6), - before_sleep=before_sleep_log(logger, logging.INFO), -) -def generate_analysis_with_labels(transcript, prompt, model, temperature, client, response_format) -> dict: +def generate_analysis_with_labels(transcript, client, vcon_uuid, opts): + """Generate analysis with labels using the LLM client. + + Returns: + Tuple of (analysis_json_str, response) where response contains provider info + """ + prompt = opts.get("prompt", "") + messages = [ {"role": "system", "content": "You are a helpful assistant that analyzes text and provides relevant labels."}, {"role": "user", "content": prompt + "\n\n" + transcript}, ] - response = client.chat.completions.create( - model=model, - messages=messages, - temperature=temperature, - response_format=response_format + response = client.complete_with_tracking( + messages=messages, + vcon_uuid=vcon_uuid, + tracking_opts=opts, + sub_type="ANALYZE_AND_LABEL", + response_format=opts.get("response_format", {"type": "json_object"}), ) - - return response.choices[0].message.content + + return response.content, response def run( @@ -79,7 +75,10 @@ def run( logger.info(f"Skipping {link_name} vCon {vcon_uuid} due to sampling") return vcon_uuid - client = OpenAI(api_key=opts["OPENAI_API_KEY"], timeout=120.0, max_retries=0) + # Create LLM client (supports OpenAI, Anthropic, and LiteLLM providers) + client = create_llm_client(opts) + logger.info(f"Using {client.provider_name} provider for model {opts.get('model', 'default')}") + source_type = navigate_dict(opts, "source.analysis_type") text_location = navigate_dict(opts, "source.text_location") @@ -104,76 +103,85 @@ def run( ) continue + # Filter out sensitive keys from logging + filtered_opts = { + k: v for k, v in opts.items() + if k not in ( + "OPENAI_API_KEY", "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_ENDPOINT", "ANTHROPIC_API_KEY", + "ai_usage_api_token" + ) + } logger.info( "Analysing dialog %s with options: %s", index, - {k: v for k, v in opts.items() if k != "OPENAI_API_KEY"}, + filtered_opts, ) start = time.time() try: # Get the structured analysis with labels - analysis_json_str = generate_analysis_with_labels( + analysis_json_str, response = generate_analysis_with_labels( transcript=source_text, - prompt=opts["prompt"], - model=opts["model"], - temperature=opts["temperature"], client=client, - response_format=opts.get("response_format", {"type": "json_object"}) + vcon_uuid=vcon_uuid, + opts=opts, ) - + # Parse the response to get labels try: analysis_data = json.loads(analysis_json_str) labels = analysis_data.get("labels", []) - + # Add the structured analysis to the vCon vendor_schema = {} - vendor_schema["model"] = opts["model"] + vendor_schema["model"] = response.model vendor_schema["prompt"] = opts["prompt"] + vendor_schema["provider"] = response.provider vCon.add_analysis( type=opts["analysis_type"], dialog=index, - vendor="openai", + vendor=get_vendor_from_response(response), body=analysis_json_str, encoding="json", extra={ "vendor_schema": vendor_schema, }, ) - + # Apply each label as a tag for label in labels: vCon.add_tag(tag_name=label, tag_value=label) logger.info(f"Applied label as tag: {label}") - + increment_counter( - "conserver.link.openai.labels_added", + "conserver.link.llm.labels_added", value=len(labels), - attributes={"analysis_type": opts['analysis_type']}, + attributes={"analysis_type": opts['analysis_type'], "provider": client.provider_name}, ) - + except json.JSONDecodeError as e: logger.error(f"Failed to parse JSON response for vCon {vcon_uuid}: {e}") increment_counter( - "conserver.link.openai.json_parse_failures", - attributes={"analysis_type": opts['analysis_type']}, + "conserver.link.llm.json_parse_failures", + attributes={"analysis_type": opts['analysis_type'], "provider": client.provider_name}, ) # Add the raw text anyway as the analysis vCon.add_analysis( type=opts["analysis_type"], dialog=index, - vendor="openai", + vendor=get_vendor_from_response(response), body=analysis_json_str, encoding="none", extra={ "vendor_schema": { - "model": opts["model"], + "model": response.model, "prompt": opts["prompt"], + "provider": response.provider, "parse_error": str(e) }, }, ) - + except Exception as e: logger.error( "Failed to generate analysis for vCon %s after multiple retries: %s", @@ -181,15 +189,15 @@ def run( e, ) increment_counter( - "conserver.link.openai.analysis_failures", - attributes={"analysis_type": opts['analysis_type']}, + "conserver.link.llm.analysis_failures", + attributes={"analysis_type": opts['analysis_type'], "provider": client.provider_name}, ) raise e record_histogram( - "conserver.link.openai.analysis_time", + "conserver.link.llm.analysis_time", time.time() - start, - attributes={"analysis_type": opts['analysis_type']}, + attributes={"analysis_type": opts['analysis_type'], "provider": client.provider_name}, ) vcon_redis.store_vcon(vCon) diff --git a/server/links/analyze_and_label/tests/test_analyze_and_label.py b/server/links/analyze_and_label/tests/test_analyze_and_label.py index 09e4bd0..ddfa08e 100644 --- a/server/links/analyze_and_label/tests/test_analyze_and_label.py +++ b/server/links/analyze_and_label/tests/test_analyze_and_label.py @@ -1,11 +1,12 @@ import os import json import pytest -from unittest.mock import patch, MagicMock +from unittest.mock import patch, MagicMock, Mock from server.links.analyze_and_label import run, generate_analysis_with_labels, get_analysis_for_type, navigate_dict from server.vcon import Vcon from lib.vcon_redis import VconRedis +from lib.llm_client import LLMResponse # Use a specific environment variable to control whether to run the real API tests RUN_API_TESTS = os.environ.get("RUN_OPENAI_ANALYZE_LABEL_TESTS", "0").lower() in ("1", "true", "yes") @@ -157,29 +158,23 @@ def mock_redis_with_vcon(mock_vcon_redis, sample_vcon): @pytest.fixture -def mock_openai_client(): - """Mock the OpenAI client""" - with patch('server.links.analyze_and_label.OpenAI') as mock_openai: +def mock_llm_client(): + """Mock the LLM client""" + with patch('server.links.analyze_and_label.create_llm_client') as mock_create_client: mock_client = MagicMock() - mock_openai.return_value = mock_client - - # Create mock chat completions - mock_chat = MagicMock() - mock_client.chat = mock_chat - - # Create mock completions service - mock_completions = MagicMock() - mock_chat.completions = mock_completions - - # Mock the create method to return a successful response - mock_response = MagicMock() - mock_choice = MagicMock() - mock_message = MagicMock() - mock_message.content = json.dumps({"labels": ["customer_service", "billing_issue", "refund"]}) - mock_choice.message = mock_message - mock_response.choices = [mock_choice] - mock_completions.create.return_value = mock_response - + mock_response = LLMResponse( + content=json.dumps({"labels": ["customer_service", "billing_issue", "refund"]}), + model="gpt-3.5-turbo", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + raw_response=None, + provider="openai" + ) + mock_client.complete_with_tracking.return_value = mock_response + mock_client.provider_name = "openai" + mock_create_client.return_value = mock_client + yield mock_client @@ -222,40 +217,51 @@ def test_navigate_dict(): assert navigate_dict(test_dict, "z") is None -@patch('server.links.analyze_and_label.generate_analysis_with_labels') +@patch('server.links.analyze_and_label.create_llm_client') @patch('server.links.analyze_and_label.is_included', return_value=True) @patch('server.links.analyze_and_label.randomly_execute_with_sampling', return_value=True) -def test_run_basic(mock_sampling, mock_is_included, mock_generate_analysis, mock_redis_with_vcon, sample_vcon): - """Test the basic run functionality with mocked analysis generation""" - # Set up mock to return analysis JSON - mock_generate_analysis.return_value = json.dumps({ - "labels": ["customer_service", "billing_issue", "refund"] - }) - +def test_run_basic(mock_sampling, mock_is_included, mock_create_client, mock_redis_with_vcon, sample_vcon): + """Test the basic run functionality with mocked LLM client""" + # Set up mock LLM client + mock_client = Mock() + mock_response = LLMResponse( + content=json.dumps({"labels": ["customer_service", "billing_issue", "refund"]}), + model="gpt-3.5-turbo", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + raw_response=None, + provider="openai" + ) + mock_client.complete_with_tracking.return_value = mock_response + mock_client.provider_name = "openai" + mock_create_client.return_value = mock_client + # Set up the mock Redis instance to return our sample vCon - mock_instance = mock_redis_with_vcon.return_value + mock_instance = mock_redis_with_vcon mock_instance.get_vcon.return_value = sample_vcon - + # Run with default options but add API key opts = {"OPENAI_API_KEY": API_KEY} - + result = run("test-uuid", "analyze_and_label", opts) - + # Check that vCon was processed and returned assert result == "test-uuid" - - # Verify analysis generation was called - mock_generate_analysis.assert_called_once() - + + # Verify LLM client was created and used + mock_create_client.assert_called_once() + mock_client.complete_with_tracking.assert_called_once() + # Verify vCon was updated and stored mock_redis_with_vcon.store_vcon.assert_called_once() - + # Check the vCon has a labeled analysis assert any( - a["type"] == "labeled_analysis" and a["vendor"] == "openai" + a["type"] == "labeled_analysis" and a["vendor"] == "openai" for a in sample_vcon.analysis ) - + # Check that tags were added tags_attachment = sample_vcon.tags assert tags_attachment is not None @@ -265,16 +271,16 @@ def test_run_basic(mock_sampling, mock_is_included, mock_generate_analysis, mock @patch('server.links.analyze_and_label.get_analysis_for_type') -@patch('server.links.analyze_and_label.generate_analysis_with_labels') +@patch('server.links.analyze_and_label.create_llm_client') @patch('server.links.analyze_and_label.is_included', return_value=True) @patch('server.links.analyze_and_label.randomly_execute_with_sampling', return_value=True) -def test_run_skip_existing_analysis(mock_sampling, mock_is_included, mock_generate_analysis, mock_get_analysis, mock_redis_with_vcon, sample_vcon_with_analysis): +def test_run_skip_existing_analysis(mock_sampling, mock_is_included, mock_create_client, mock_get_analysis, mock_redis_with_vcon, sample_vcon_with_analysis): """Test that run skips dialogs with existing labeled analysis""" - # Set up mock for generate_analysis_with_labels - mock_generate_analysis.return_value = json.dumps({ - "labels": ["new_label_that_should_not_be_added"] - }) - + # Set up mock LLM client (should not be called) + mock_client = Mock() + mock_client.provider_name = "openai" + mock_create_client.return_value = mock_client + # Mock get_analysis_for_type to return an existing labeled_analysis # This will cause the run function to skip processing this dialog mock_get_analysis.side_effect = lambda vcon, index, analysis_type: { @@ -284,214 +290,228 @@ def test_run_skip_existing_analysis(mock_sampling, mock_is_included, mock_genera "body": json.dumps({"labels": ["customer_service", "billing_issue", "refund"]}), "encoding": "json" } if analysis_type == "labeled_analysis" else None - + # Set up Redis mock to return vCon with existing analysis - mock_instance = mock_redis_with_vcon.return_value + mock_instance = mock_redis_with_vcon mock_instance.get_vcon.return_value = sample_vcon_with_analysis - + # Count existing analyses before the run analysis_count_before = len(sample_vcon_with_analysis.analysis) - + # Run with default options but add API key opts = {"OPENAI_API_KEY": API_KEY} - + result = run("test-uuid", "analyze_and_label", opts) - + # Check that the result is correct assert result == "test-uuid" - + # Verify the analysis was skipped - analysis count should remain the same assert len(sample_vcon_with_analysis.analysis) == analysis_count_before - - # Verify generate_analysis_with_labels was not called - mock_generate_analysis.assert_not_called() + + # Verify LLM client was not used to generate analysis + mock_client.complete_with_tracking.assert_not_called() -@patch('server.links.analyze_and_label.generate_analysis_with_labels') +@patch('server.links.analyze_and_label.create_llm_client') @patch('server.links.analyze_and_label.is_included', return_value=True) @patch('server.links.analyze_and_label.randomly_execute_with_sampling', return_value=True) -def test_run_json_parse_error(mock_sampling, mock_is_included, mock_generate_analysis, mock_redis_with_vcon, sample_vcon): +def test_run_json_parse_error(mock_sampling, mock_is_included, mock_create_client, mock_redis_with_vcon, sample_vcon): """Test handling of JSON parse errors""" - # Set up mock to return invalid JSON - mock_generate_analysis.return_value = "This is not valid JSON" - + # Set up mock LLM client to return invalid JSON + mock_client = Mock() + mock_response = LLMResponse( + content="This is not valid JSON", + model="gpt-3.5-turbo", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + raw_response=None, + provider="openai" + ) + mock_client.complete_with_tracking.return_value = mock_response + mock_client.provider_name = "openai" + mock_create_client.return_value = mock_client + # Set up the mock Redis instance to return our sample vCon - mock_instance = mock_redis_with_vcon.return_value + mock_instance = mock_redis_with_vcon mock_instance.get_vcon.return_value = sample_vcon - + # Run with default options but add API key opts = {"OPENAI_API_KEY": API_KEY} - + result = run("test-uuid", "analyze_and_label", opts) - + # Check that vCon was processed and returned despite the error assert result == "test-uuid" - + # Verify analysis was still added but with encoding="none" assert any( a["type"] == "labeled_analysis" and a["vendor"] == "openai" and a["encoding"] == "none" for a in sample_vcon.analysis ) - + # Check that no tags were added since JSON parsing failed tags_attachment = sample_vcon.tags assert tags_attachment is None or len(tags_attachment["body"]) == 0 -@patch('server.links.analyze_and_label.generate_analysis_with_labels') +@patch('server.links.analyze_and_label.create_llm_client') @patch('server.links.analyze_and_label.is_included', return_value=True) @patch('server.links.analyze_and_label.randomly_execute_with_sampling', return_value=True) -def test_run_analysis_exception(mock_sampling, mock_is_included, mock_generate_analysis, mock_redis_with_vcon, sample_vcon): +def test_run_analysis_exception(mock_sampling, mock_is_included, mock_create_client, mock_redis_with_vcon, sample_vcon): """Test handling of analysis generation exceptions""" - # Make analysis function raise an exception - mock_generate_analysis.side_effect = Exception("Analysis generation failed") - + # Set up mock LLM client to raise exception + mock_client = Mock() + mock_client.complete_with_tracking.side_effect = Exception("Analysis generation failed") + mock_client.provider_name = "openai" + mock_create_client.return_value = mock_client + # Set up the mock Redis instance to return our sample vCon - mock_instance = mock_redis_with_vcon.return_value + mock_instance = mock_redis_with_vcon mock_instance.get_vcon.return_value = sample_vcon - + # Run with default options but add API key opts = {"OPENAI_API_KEY": API_KEY} - + # The exception should be propagated with pytest.raises(Exception, match="Analysis generation failed"): run("test-uuid", "analyze_and_label", opts) -@patch('server.links.analyze_and_label.generate_analysis_with_labels') +@patch('server.links.analyze_and_label.create_llm_client') @patch('server.links.analyze_and_label.is_included', return_value=True) @patch('server.links.analyze_and_label.randomly_execute_with_sampling', return_value=True) -def test_run_message_format(mock_sampling, mock_is_included, mock_generate_analysis, mock_redis_with_vcon, sample_vcon_message_format): +def test_run_message_format(mock_sampling, mock_is_included, mock_create_client, mock_redis_with_vcon, sample_vcon_message_format): """Test analyzing a dialog with message format""" + # Set up mock LLM client + mock_client = Mock() + mock_response = LLMResponse( + content=json.dumps({"labels": ["account_access", "password_reset", "login_issues"]}), + model="gpt-3.5-turbo", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + raw_response=None, + provider="openai" + ) + mock_client.complete_with_tracking.return_value = mock_response + mock_client.provider_name = "openai" + mock_create_client.return_value = mock_client + # Set up the mock Redis instance to return our sample vCon with message format - mock_instance = mock_redis_with_vcon.return_value + mock_instance = mock_redis_with_vcon mock_instance.get_vcon.return_value = sample_vcon_message_format - - # Mock successful analysis generation with labels relevant to account access issues - mock_generate_analysis.return_value = json.dumps({"labels": ["account_access", "password_reset", "login_issues"]}) - + # Run with default options but add API key opts = {"OPENAI_API_KEY": API_KEY} - + result = run("test-uuid", "analyze_and_label", opts) - + # Check that vCon was processed and returned assert result == "test-uuid" - - # Verify OpenAI API was called - mock_generate_analysis.assert_called_once() - - # Don't check exact transcript content in the tests as the mock structure might vary - # Just verify the function was called and returns our mocked labels - - # The test focus is on verifying that the tags were correctly added - # Skip checking if analysis was added since mock objects might not properly simulate this behavior - retrieved_vcon = mock_instance.get_vcon.return_value - + + # Verify LLM client was used + mock_client.complete_with_tracking.assert_called_once() + # Verify tags were added expected_tags = ["account_access", "password_reset", "login_issues"] - tags_attachment = sample_vcon_message_format.tags - - # Mock how tags are added and verified since actual tag structure may vary mock_tags = [] for label in expected_tags: sample_vcon_message_format.add_tag(tag_name=label, tag_value=label) mock_tags.append(label) - - # Just verify add_tag was called with the expected tags + assert len(mock_tags) == len(expected_tags) - for tag in expected_tags: - assert tag in mock_tags -@patch('server.links.analyze_and_label.generate_analysis_with_labels') +@patch('server.links.analyze_and_label.create_llm_client') @patch('server.links.analyze_and_label.is_included', return_value=True) @patch('server.links.analyze_and_label.randomly_execute_with_sampling', return_value=True) -def test_run_chat_format(mock_sampling, mock_is_included, mock_generate_analysis, mock_redis_with_vcon, sample_vcon_chat_format): +def test_run_chat_format(mock_sampling, mock_is_included, mock_create_client, mock_redis_with_vcon, sample_vcon_chat_format): """Test analyzing a dialog with chat format""" + # Set up mock LLM client + mock_client = Mock() + mock_response = LLMResponse( + content=json.dumps({"labels": ["order_issue", "wrong_item", "return_request"]}), + model="gpt-3.5-turbo", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + raw_response=None, + provider="openai" + ) + mock_client.complete_with_tracking.return_value = mock_response + mock_client.provider_name = "openai" + mock_create_client.return_value = mock_client + # Set up the mock Redis instance to return our sample vCon with chat format - mock_instance = mock_redis_with_vcon.return_value + mock_instance = mock_redis_with_vcon mock_instance.get_vcon.return_value = sample_vcon_chat_format - - # Mock successful analysis generation with labels relevant to order issues - mock_generate_analysis.return_value = json.dumps({"labels": ["order_issue", "wrong_item", "return_request"]}) - + # Run with default options but add API key opts = {"OPENAI_API_KEY": API_KEY} - + result = run("test-uuid", "analyze_and_label", opts) - + # Check that vCon was processed and returned assert result == "test-uuid" - - # Verify OpenAI API was called - mock_generate_analysis.assert_called_once() - - # Don't check exact transcript content in the tests as the mock structure might vary - # Just verify the function was called and returns our mocked labels - - # The test focus is on verifying that the tags were correctly added - # Skip checking if analysis was added since mock objects might not properly simulate this behavior - retrieved_vcon = mock_instance.get_vcon.return_value - + + # Verify LLM client was used + mock_client.complete_with_tracking.assert_called_once() + # Verify tags were added expected_tags = ["order_issue", "wrong_item", "return_request"] - - # Mock how tags are added and verified since actual tag structure may vary mock_tags = [] for label in expected_tags: sample_vcon_chat_format.add_tag(tag_name=label, tag_value=label) mock_tags.append(label) - - # Just verify add_tag was called with the expected tags + assert len(mock_tags) == len(expected_tags) - for tag in expected_tags: - assert tag in mock_tags -@patch('server.links.analyze_and_label.generate_analysis_with_labels') +@patch('server.links.analyze_and_label.create_llm_client') @patch('server.links.analyze_and_label.is_included', return_value=True) @patch('server.links.analyze_and_label.randomly_execute_with_sampling', return_value=True) -def test_run_email_format(mock_sampling, mock_is_included, mock_generate_analysis, mock_redis_with_vcon, sample_vcon_email_format): +def test_run_email_format(mock_sampling, mock_is_included, mock_create_client, mock_redis_with_vcon, sample_vcon_email_format): """Test analyzing a dialog with email format""" + # Set up mock LLM client + mock_client = Mock() + mock_response = LLMResponse( + content=json.dumps({"labels": ["product_quality", "shipping_damage", "delivery_delay", "refund_request"]}), + model="gpt-3.5-turbo", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + raw_response=None, + provider="openai" + ) + mock_client.complete_with_tracking.return_value = mock_response + mock_client.provider_name = "openai" + mock_create_client.return_value = mock_client + # Set up the mock Redis instance to return our sample vCon with email format - mock_instance = mock_redis_with_vcon.return_value + mock_instance = mock_redis_with_vcon mock_instance.get_vcon.return_value = sample_vcon_email_format - - # Mock successful analysis generation with labels relevant to product complaints - mock_generate_analysis.return_value = json.dumps({"labels": ["product_quality", "shipping_damage", "delivery_delay", "refund_request"]}) - + # Run with default options but add API key opts = {"OPENAI_API_KEY": API_KEY} - + result = run("test-uuid", "analyze_and_label", opts) - + # Check that vCon was processed and returned assert result == "test-uuid" - - # Verify OpenAI API was called - mock_generate_analysis.assert_called_once() - - # Don't check exact transcript content in the tests as the mock structure might vary - # Just verify the function was called and returns our mocked labels - - # The test focus is on verifying that the tags were correctly added - # Skip checking if analysis was added since mock objects might not properly simulate this behavior - retrieved_vcon = mock_instance.get_vcon.return_value - + + # Verify LLM client was used + mock_client.complete_with_tracking.assert_called_once() + # Verify tags were added expected_tags = ["product_quality", "shipping_damage", "delivery_delay", "refund_request"] - - # Mock how tags are added and verified since actual tag structure may vary mock_tags = [] for label in expected_tags: sample_vcon_email_format.add_tag(tag_name=label, tag_value=label) mock_tags.append(label) - - # Just verify add_tag was called with the expected tags + assert len(mock_tags) == len(expected_tags) - for tag in expected_tags: - assert tag in mock_tags @pytest.mark.skipif(not RUN_API_TESTS, reason="Skipping API tests. Set RUN_OPENAI_ANALYZE_LABEL_TESTS=1 to run") @@ -500,9 +520,9 @@ def test_generate_analysis_with_labels_real_api(): # Skip if no API key is provided if not os.environ.get("OPENAI_API_KEY"): pytest.skip("No OpenAI API key provided via OPENAI_API_KEY environment variable") - - from openai import OpenAI - + + from lib.llm_client import create_llm_client + # Sample transcript transcript = ( "Customer: Hi, I'm calling about my recent bill. I think there's an error. " @@ -511,25 +531,31 @@ def test_generate_analysis_with_labels_real_api(): "Agent: You're right, I see the duplicate charge. I'll process a refund right away. " "Customer: Thank you, I appreciate that." ) - - # Create real client - client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) - - # Call the function - result = generate_analysis_with_labels( + + # Create LLM client using the factory + opts = { + "prompt": "Analyze this transcript and provide a list of relevant labels for categorization. Return your response as a JSON object with a single key 'labels' containing an array of strings.", + "model": "gpt-3.5-turbo", # Use cheaper model for tests + "temperature": 0, + "OPENAI_API_KEY": os.environ["OPENAI_API_KEY"], + "response_format": {"type": "json_object"} + } + client = create_llm_client(opts) + + # Call the function with new signature + result, response = generate_analysis_with_labels( transcript=transcript, - prompt="Analyze this transcript and provide a list of relevant labels for categorization. Return your response as a JSON object with a single key 'labels' containing an array of strings.", - model="gpt-3.5-turbo", # Use cheaper model for tests - temperature=0, client=client, - response_format={"type": "json_object"} + vcon_uuid="test-uuid-real-api", + opts=opts, ) - + # Check that we get valid JSON with labels json_result = json.loads(result) assert "labels" in json_result assert isinstance(json_result["labels"], list) assert len(json_result["labels"]) > 0 + assert response.provider == "openai" @pytest.mark.skipif(not RUN_API_TESTS, reason="Skipping API tests. Set RUN_OPENAI_ANALYZE_LABEL_TESTS=1 to run") @@ -538,42 +564,52 @@ def test_generate_analysis_with_labels_real_api_with_dialog_formats(): # Skip if no API key is provided if not os.environ.get("OPENAI_API_KEY"): pytest.skip("No OpenAI API key provided via OPENAI_API_KEY environment variable") - - from openai import OpenAI - - # Create real client - client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) - + + from lib.llm_client import create_llm_client + + # Create LLM client using the factory + base_opts = { + "model": "gpt-3.5-turbo", # Use cheaper model for tests + "temperature": 0, + "OPENAI_API_KEY": os.environ["OPENAI_API_KEY"], + "response_format": {"type": "json_object"} + } + client = create_llm_client(base_opts) + # Test with message format message_text = "FROM: customer@example.com\nTO: support@company.com\nSUBJECT: Urgent: Account Access Issue\n\nHello Support Team,\n\nI've been trying to log into my account for the past 2 days but keep getting 'Invalid Password' errors. I'm certain I'm using the correct password as it's saved in my password manager. Could you please reset my account or help me troubleshoot this issue?\n\nThanks,\nJohn Smith" - - result = generate_analysis_with_labels( + + opts = { + **base_opts, + "prompt": "Analyze this message and provide a list of relevant labels for categorization. Return your response as a JSON object with a single key 'labels' containing an array of strings.", + } + result, response = generate_analysis_with_labels( transcript=message_text, - prompt="Analyze this message and provide a list of relevant labels for categorization. Return your response as a JSON object with a single key 'labels' containing an array of strings.", - model="gpt-3.5-turbo", # Use cheaper model for tests - temperature=0, client=client, - response_format={"type": "json_object"} + vcon_uuid="test-uuid-message-format", + opts=opts, ) - + # Check that we get valid JSON with labels json_result = json.loads(result) assert "labels" in json_result assert isinstance(json_result["labels"], list) assert len(json_result["labels"]) > 0 - + # Test with email format email_text = "From: sarah.johnson@example.com\nTo: feedback@retailstore.com\nSubject: Disappointed with Product Quality and Delivery\n\nDear Customer Service Team,\n\nI am writing to express my dissatisfaction with my recent purchase..." - - result = generate_analysis_with_labels( + + opts = { + **base_opts, + "prompt": "Analyze this email and provide a list of relevant labels for categorization. Return your response as a JSON object with a single key 'labels' containing an array of strings.", + } + result, response = generate_analysis_with_labels( transcript=email_text, - prompt="Analyze this email and provide a list of relevant labels for categorization. Return your response as a JSON object with a single key 'labels' containing an array of strings.", - model="gpt-3.5-turbo", # Use cheaper model for tests - temperature=0, client=client, - response_format={"type": "json_object"} + vcon_uuid="test-uuid-email-format", + opts=opts, ) - + # Check that we get valid JSON with labels json_result = json.loads(result) assert "labels" in json_result diff --git a/server/links/analyze_vcon/__init__.py b/server/links/analyze_vcon/__init__.py index 929ed68..a3528c9 100644 --- a/server/links/analyze_vcon/__init__.py +++ b/server/links/analyze_vcon/__init__.py @@ -1,18 +1,11 @@ from lib.vcon_redis import VconRedis from lib.logging_utils import init_logger -import logging -from openai import OpenAI -from tenacity import ( - retry, - stop_after_attempt, - wait_exponential, - before_sleep_log, -) # for exponential backoff from lib.metrics import record_histogram, increment_counter +from lib.links.filters import is_included, randomly_execute_with_sampling +from lib.llm_client import create_llm_client, get_vendor_from_response import time import json import copy -from lib.links.filters import is_included, randomly_execute_with_sampling logger = init_logger(__name__) @@ -24,6 +17,7 @@ "temperature": 0, "system_prompt": "You are a helpful assistant that analyzes conversation data and returns structured JSON output.", "remove_body_properties": True, + "response_format": {"type": "json_object"}, } @@ -34,36 +28,35 @@ def get_analysis_for_type(vcon, analysis_type): return None -@retry( - wait=wait_exponential(multiplier=2, min=1, max=65), - stop=stop_after_attempt(6), - before_sleep=before_sleep_log(logger, logging.INFO), -) -def generate_analysis(vcon_data, prompt, system_prompt, model, temperature, client) -> str: +def generate_analysis(vcon_data, client, vcon_uuid, opts): + """Generate analysis using the LLM client. + + Returns: + Tuple of (analysis_result, response) where response contains provider info + """ + prompt = opts.get("prompt", "") + system_prompt = opts.get("system_prompt", "You are a helpful assistant.") + # Convert vcon_data to a JSON string vcon_data_json = json.dumps(vcon_data) - # Check and replace the system_prompt in the JSON string - if system_prompt in vcon_data_json: - vcon_data_json = vcon_data_json.replace(system_prompt, "") - # Log that the system_prompt was found and replaced - logger.info(f"Replaced system_prompt in vcon_data for vcon_uuid: {vcon_uuid}") - + # Do not modify vCon data. If the system prompt text appears in the vCon itself, + # it should be preserved for analysis. messages = [ {"role": "system", "content": system_prompt}, - {"role": "user", "content": prompt + "\n\n" + json.dumps(vcon_data)}, + {"role": "user", "content": prompt + "\n\n" + vcon_data_json}, ] - - - response = client.chat.completions.create( - model=model, - messages=messages, - temperature=temperature, - response_format={"type": "json_object"} + response = client.complete_with_tracking( + messages=messages, + vcon_uuid=vcon_uuid, + tracking_opts=opts, + sub_type="ANALYZE_VCON", + response_format=opts.get("response_format", {"type": "json_object"}), ) - return response.choices[0].message.content + + return response.content, response def is_valid_json(json_string): @@ -77,13 +70,13 @@ def is_valid_json(json_string): def prepare_vcon_for_analysis(vcon, remove_body_properties=True): """Create a copy of vCon with optional removal of body properties to save space""" vcon_copy = copy.deepcopy(vcon.to_dict()) - + if remove_body_properties: if 'dialog' in vcon_copy: for dialog in vcon_copy['dialog']: if 'body' in dialog: dialog.pop('body', None) - + return vcon_copy @@ -119,39 +112,48 @@ def run( ) return vcon_uuid - client = OpenAI(api_key=opts["OPENAI_API_KEY"], timeout=120.0, max_retries=0) - + # Create LLM client (supports OpenAI, Anthropic, and LiteLLM providers) + client = create_llm_client(opts) + logger.info(f"Using {client.provider_name} provider for model {opts.get('model', 'default')}") + # Prepare vCon data for analysis (removing body properties if specified) vcon_data = prepare_vcon_for_analysis(vCon, opts["remove_body_properties"]) - + + # Filter out sensitive keys from logging + filtered_opts = { + k: v for k, v in opts.items() + if k not in ( + "OPENAI_API_KEY", "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_ENDPOINT", "ANTHROPIC_API_KEY", + "ai_usage_api_token" + ) + } logger.info( "Analyzing entire vCon with options: %s", - {k: v for k, v in opts.items() if k != "OPENAI_API_KEY"}, + filtered_opts, ) - + start = time.time() try: - analysis_result = generate_analysis( + analysis_result, response = generate_analysis( vcon_data=vcon_data, - prompt=opts["prompt"], - system_prompt=opts["system_prompt"], - model=opts["model"], - temperature=opts["temperature"], client=client, + vcon_uuid=vcon_uuid, + opts=opts, ) - + # Validate JSON response if not is_valid_json(analysis_result): logger.error( - "Invalid JSON response from OpenAI for vCon %s", + "Invalid JSON response from LLM for vCon %s", vcon_uuid, ) increment_counter( - "conserver.link.openai.invalid_json", - attributes={"analysis_type": opts['analysis_type']}, + "conserver.link.llm.invalid_json", + attributes={"analysis_type": opts['analysis_type'], "provider": client.provider_name}, ) - raise ValueError("Invalid JSON response from OpenAI") - + raise ValueError("Invalid JSON response from LLM") + except Exception as e: logger.error( "Failed to generate analysis for vCon %s after multiple retries: %s", @@ -159,33 +161,34 @@ def run( e, ) increment_counter( - "conserver.link.openai.analysis_failures", - attributes={"analysis_type": opts['analysis_type']}, + "conserver.link.llm.analysis_failures", + attributes={"analysis_type": opts['analysis_type'], "provider": client.provider_name}, ) raise e record_histogram( - "conserver.link.openai.analysis_time", + "conserver.link.llm.analysis_time", time.time() - start, - attributes={"analysis_type": opts['analysis_type']}, + attributes={"analysis_type": opts['analysis_type'], "provider": client.provider_name}, ) vendor_schema = {} - vendor_schema["model"] = opts["model"] + vendor_schema["model"] = response.model vendor_schema["prompt"] = opts["prompt"] vendor_schema["system_prompt"] = opts["system_prompt"] - + vendor_schema["provider"] = response.provider + # Add analysis to vCon with no dialog index (applies to entire vCon) vCon.add_analysis( type=opts["analysis_type"], - vendor="openai", - body=json.loads(analysis_result), # Pass the raw JSON string instead of parsing it + vendor=get_vendor_from_response(response), + body=json.loads(analysis_result), # Pass the parsed JSON dialog=0, # Use dialog=0 to indicate it applies to the first/main dialog extra={ "vendor_schema": vendor_schema, }, ) - + vcon_redis.store_vcon(vCon) logger.info(f"Finished analyze - {module_name}:{link_name} plugin for: {vcon_uuid}") diff --git a/server/links/check_and_tag/__init__.py b/server/links/check_and_tag/__init__.py index 255fff9..c1a5e73 100644 --- a/server/links/check_and_tag/__init__.py +++ b/server/links/check_and_tag/__init__.py @@ -1,17 +1,10 @@ from lib.vcon_redis import VconRedis from lib.logging_utils import init_logger -import logging -import json -from openai import OpenAI -from tenacity import ( - retry, - stop_after_attempt, - wait_exponential, - before_sleep_log, -) # for exponential backoff from lib.metrics import record_histogram, increment_counter -import time from lib.links.filters import is_included, randomly_execute_with_sampling +from lib.llm_client import create_llm_client, get_vendor_from_response +import json +import time logger = init_logger(__name__) @@ -38,19 +31,19 @@ def get_analysis_for_type(vcon, index, analysis_type): return None -@retry( - wait=wait_exponential(multiplier=2, min=1, max=65), - stop=stop_after_attempt(6), - before_sleep=before_sleep_log(logger, logging.INFO), -) -def generate_tag_evaluation(transcript, evaluation_question, model, client, response_format) -> str: +def generate_tag_evaluation(transcript, evaluation_question, client, vcon_uuid, opts): + """Generate tag evaluation using the LLM client. + + Returns: + Tuple of (analysis_json_str, response) where response contains provider info + """ messages = [ { - "role": "system", + "role": "system", "content": "You are a helpful assistant that evaluates text against specific questions and provides yes/no answers." }, { - "role": "user", + "role": "user", "content": ( f"Question: {evaluation_question}\n\nText to evaluate:\n{transcript}\n\n" "Please answer with a JSON object containing a single key 'applies' with a boolean value " @@ -59,13 +52,15 @@ def generate_tag_evaluation(transcript, evaluation_question, model, client, res }, ] - response = client.chat.completions.create( - model=model, - messages=messages, - response_format=response_format + response = client.complete_with_tracking( + messages=messages, + vcon_uuid=vcon_uuid, + tracking_opts=opts, + sub_type="CHECK_AND_TAG", + response_format=opts.get("response_format", {"type": "json_object"}), ) - - return response.choices[0].message.content + + return response.content, response def run( @@ -101,7 +96,10 @@ def run( logger.info(f"Skipping {link_name} vCon {vcon_uuid} due to sampling") return vcon_uuid - client = OpenAI(api_key=opts["OPENAI_API_KEY"], timeout=120.0, max_retries=0) + # Create LLM client (supports OpenAI, Anthropic, and LiteLLM providers) + client = create_llm_client(opts) + logger.info(f"Using {client.provider_name} provider for model {opts.get('model', 'default')}") + source_type = navigate_dict(opts, "source.analysis_type") text_location = navigate_dict(opts, "source.text_location") @@ -135,22 +133,31 @@ def run( ) continue + # Filter out sensitive keys from logging + filtered_opts = { + k: v for k, v in opts.items() + if k not in ( + "OPENAI_API_KEY", "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_ENDPOINT", "ANTHROPIC_API_KEY", + "ai_usage_api_token" + ) + } logger.info( "Analysing dialog %s with options: %s", index, - {k: v for k, v in opts.items() if k != "OPENAI_API_KEY"}, + filtered_opts, ) start = time.time() try: # Get the tag evaluation - analysis_json_str = generate_tag_evaluation( + analysis_json_str, response = generate_tag_evaluation( transcript=source_text, evaluation_question=opts["evaluation_question"], - model=opts["model"], client=client, - response_format=opts.get("response_format", {"type": "json_object"}) + vcon_uuid=vcon_uuid, + opts=opts, ) - + # Parse the response to get evaluation result analysis_data = json.loads(analysis_json_str) applies = analysis_data.get("applies", False) @@ -160,29 +167,30 @@ def run( "tag": f"{opts['tag_name']}:{opts['tag_value']}", "applies": applies, } - + # Add the structured analysis to the vCon vendor_schema = {} - vendor_schema["model"] = opts["model"] + vendor_schema["model"] = response.model vendor_schema["evaluation_question"] = opts["evaluation_question"] + vendor_schema["provider"] = response.provider vCon.add_analysis( type=opts["analysis_type"], dialog=index, - vendor="openai", + vendor=get_vendor_from_response(response), body=body, encoding="none", extra={ "vendor_schema": vendor_schema, }, ) - + # Apply tag if evaluation is positive if applies: vCon.add_tag(tag_name=opts["tag_name"], tag_value=opts["tag_value"]) logger.info(f"Applied tag: {opts['tag_name']}:{opts['tag_value']} (evaluation: {applies})") increment_counter( - "conserver.link.openai.tags_applied", - attributes={"analysis_type": opts['analysis_type'], "tag_name": opts['tag_name'], "tag_value": opts['tag_value']}, + "conserver.link.llm.tags_applied", + attributes={"analysis_type": opts['analysis_type'], "tag_name": opts['tag_name'], "tag_value": opts['tag_value'], "provider": client.provider_name}, ) else: logger.info(f"Tag not applied: {opts['tag_name']}:{opts['tag_value']} (evaluation: {applies})") @@ -193,15 +201,15 @@ def run( e, ) increment_counter( - "conserver.link.openai.evaluation_failures", - attributes={"analysis_type": opts['analysis_type'], "tag_name": opts['tag_name'], "tag_value": opts['tag_value']}, + "conserver.link.llm.evaluation_failures", + attributes={"analysis_type": opts['analysis_type'], "tag_name": opts['tag_name'], "tag_value": opts['tag_value'], "provider": client.provider_name}, ) raise e record_histogram( - "conserver.link.openai.evaluation_time", + "conserver.link.llm.evaluation_time", time.time() - start, - attributes={"analysis_type": opts['analysis_type'], "tag_name": opts['tag_name'], "tag_value": opts['tag_value']}, + attributes={"analysis_type": opts['analysis_type'], "tag_name": opts['tag_name'], "tag_value": opts['tag_value'], "provider": client.provider_name}, ) vcon_redis.store_vcon(vCon) diff --git a/server/links/detect_engagement/__init__.py b/server/links/detect_engagement/__init__.py index 75a4185..8ba46ba 100644 --- a/server/links/detect_engagement/__init__.py +++ b/server/links/detect_engagement/__init__.py @@ -1,17 +1,11 @@ from lib.vcon_redis import VconRedis from lib.logging_utils import init_logger -import logging -from openai import OpenAI -from tenacity import ( - retry, - stop_after_attempt, - wait_exponential, - before_sleep_log, -) from lib.metrics import record_histogram, increment_counter -import time from lib.links.filters import is_included, randomly_execute_with_sampling +from lib.llm_client import create_llm_client, get_vendor_from_response +import time import os + logger = init_logger(__name__) default_options = { @@ -27,30 +21,38 @@ "OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", "") # Make it optional with empty default } + def get_analysis_for_type(vcon, index, analysis_type): for a in vcon.analysis: if a["dialog"] == index and a["type"] == analysis_type: return a return None -@retry( - wait=wait_exponential(multiplier=2, min=1, max=65), - stop=stop_after_attempt(6), - before_sleep=before_sleep_log(logger, logging.INFO), -) -def check_engagement(transcript, prompt, model, temperature, client) -> bool: - # The new responses API expects a single string or a list of message dicts as 'input' - input_text = f"{prompt}\n\nTranscript: {transcript}" - - response = client.responses.create( - model=model, - input=input_text, - temperature=temperature + +def check_engagement(transcript, prompt, client, vcon_uuid, opts) -> tuple: + """Check engagement using the LLM client. + + Returns: + Tuple of (is_engaged: bool, response) where response contains provider info + """ + # Convert from Responses API format to Chat Completions format + messages = [ + {"role": "user", "content": f"{prompt}\n\nTranscript: {transcript}"} + ] + + response = client.complete_with_tracking( + messages=messages, + vcon_uuid=vcon_uuid, + tracking_opts=opts, + sub_type="DETECT_ENGAGEMENT", ) - - # The new API returns the result in response.output_text - answer = response.output_text.strip().lower() - return answer == "true" + + # The response content should be "true" or "false" + answer = response.content.strip().lower() + is_engaged = answer == "true" + + return is_engaged, response + def run( vcon_uuid, @@ -63,12 +65,15 @@ def run( merged_opts.update(opts) opts = merged_opts - # Check for OPENAI_API_KEY in opts or environment - openai_key = opts.get("OPENAI_API_KEY") or os.getenv("OPENAI_API_KEY") - if not openai_key: - logger.warning("OPENAI_API_KEY not defined, skipping analysis for vCon: %s", vcon_uuid) + # Check for API key in opts or environment (for backward compatibility) + api_key = opts.get("OPENAI_API_KEY") or opts.get("ANTHROPIC_API_KEY") or os.getenv("OPENAI_API_KEY") + if not api_key: + logger.warning("No LLM API key defined, skipping analysis for vCon: %s", vcon_uuid) return vcon_uuid - opts["OPENAI_API_KEY"] = openai_key + + # Ensure at least one API key is set in opts for the llm_client + if not opts.get("OPENAI_API_KEY") and not opts.get("ANTHROPIC_API_KEY"): + opts["OPENAI_API_KEY"] = api_key vcon_redis = VconRedis() vCon = vcon_redis.get_vcon(vcon_uuid) @@ -81,7 +86,10 @@ def run( logger.info(f"Skipping {link_name} vCon {vcon_uuid} due to sampling") return vcon_uuid - client = OpenAI(api_key=opts["OPENAI_API_KEY"], timeout=120.0, max_retries=0) + # Create LLM client (supports OpenAI, Anthropic, and LiteLLM providers) + client = create_llm_client(opts) + logger.info(f"Using {client.provider_name} provider for model {opts.get('model', 'default')}") + source_type = opts["source"]["analysis_type"] text_location = opts["source"]["text_location"] @@ -106,34 +114,44 @@ def run( ) continue + # Filter out sensitive keys from logging + filtered_opts = { + k: v for k, v in opts.items() + if k not in ( + "OPENAI_API_KEY", "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_ENDPOINT", "ANTHROPIC_API_KEY", + "ai_usage_api_token" + ) + } logger.info( "Analyzing engagement for dialog %s with options: %s", index, - {k: v for k, v in opts.items() if k != "OPENAI_API_KEY"}, + filtered_opts, ) start = time.time() try: - is_engaged = check_engagement( + is_engaged, response = check_engagement( transcript=source_text, prompt=opts["prompt"], - model=opts["model"], - temperature=opts["temperature"], - client=client + client=client, + vcon_uuid=vcon_uuid, + opts=opts, ) # Always use string 'true'/'false' for tag and body is_engaged_str = "true" if is_engaged else "false" vendor_schema = { - "model": opts["model"], + "model": response.model, "prompt": opts["prompt"], - "is_engaged": is_engaged_str + "is_engaged": is_engaged_str, + "provider": response.provider, } vCon.add_analysis( type=opts["analysis_type"], dialog=index, - vendor="openai", + vendor=get_vendor_from_response(response), body=is_engaged_str, encoding="none", extra={ @@ -145,9 +163,9 @@ def run( logger.info(f"Applied engagement tag: {is_engaged_str}") increment_counter( - "conserver.link.openai.engagement_detected", + "conserver.link.llm.engagement_detected", value=1 if is_engaged else 0, - attributes={"analysis_type": opts['analysis_type']}, + attributes={"analysis_type": opts['analysis_type'], "provider": client.provider_name}, ) except Exception as e: @@ -160,15 +178,15 @@ def run( traceback.format_exc() ) increment_counter( - "conserver.link.openai.engagement_analysis_failures", - attributes={"analysis_type": opts['analysis_type']}, + "conserver.link.llm.engagement_analysis_failures", + attributes={"analysis_type": opts['analysis_type'], "provider": client.provider_name}, ) raise e record_histogram( - "conserver.link.openai.engagement_analysis_time", + "conserver.link.llm.engagement_analysis_time", time.time() - start, - attributes={"analysis_type": opts['analysis_type']}, + attributes={"analysis_type": opts['analysis_type'], "provider": client.provider_name}, ) vcon_redis.store_vcon(vCon) @@ -185,4 +203,4 @@ def navigate_dict(dictionary, path): current = current[key] else: return None - return current \ No newline at end of file + return current diff --git a/server/links/detect_engagement/tests/test_detect_engagement.py b/server/links/detect_engagement/tests/test_detect_engagement.py index df59248..b82370a 100644 --- a/server/links/detect_engagement/tests/test_detect_engagement.py +++ b/server/links/detect_engagement/tests/test_detect_engagement.py @@ -3,7 +3,7 @@ These tests cover the engagement detection logic, including: - Analysis extraction and navigation -- OpenAI API integration for engagement detection +- LLM client integration for engagement detection (supports OpenAI, Anthropic, LiteLLM) - Handling of missing transcripts, API errors, and sampling logic - Ensuring correct tagging and analysis addition @@ -11,9 +11,8 @@ """ import os import pytest -from unittest.mock import Mock, patch +from unittest.mock import Mock, patch, MagicMock import json -from tenacity import RetryError from server.links.detect_engagement import ( check_engagement, get_analysis_for_type, @@ -21,8 +20,7 @@ run, default_options, ) -import openai -from openai import OpenAI +from lib.llm_client import LLMResponse, create_llm_client from dotenv import load_dotenv # Load environment variables from .env file for API keys, etc. @@ -114,37 +112,65 @@ def skip_if_no_openai_key(): if not os.getenv("OPENAI_API_KEY"): pytest.skip("OPENAI_API_KEY not set in environment, skipping test.") -@pytest.mark.asyncio -async def test_check_engagement_engaged(): + +def test_check_engagement_engaged(): """ Test that check_engagement returns True for a transcript with both agent and customer speaking. + Uses mocked LLM client. """ - skip_if_no_openai_key() - client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) - result = check_engagement( + # Set up mock LLM client + mock_client = Mock() + mock_response = LLMResponse( + content="true", + model="gpt-4.1", + prompt_tokens=10, + completion_tokens=5, + total_tokens=15, + raw_response=None, + provider="openai" + ) + mock_client.complete_with_tracking.return_value = mock_response + + opts = {"prompt": default_options["prompt"]} + is_engaged, response = check_engagement( MOCK_TRANSCRIPT, default_options["prompt"], - default_options["model"], - default_options["temperature"], - client + mock_client, + "test-uuid", + opts ) - assert result is True + assert is_engaged is True + assert response.content == "true" + -@pytest.mark.asyncio -async def test_check_engagement_not_engaged(): +def test_check_engagement_not_engaged(): """ Test that check_engagement returns False for a transcript with only the agent speaking. + Uses mocked LLM client. """ - skip_if_no_openai_key() - client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) - result = check_engagement( + # Set up mock LLM client + mock_client = Mock() + mock_response = LLMResponse( + content="false", + model="gpt-4.1", + prompt_tokens=10, + completion_tokens=5, + total_tokens=15, + raw_response=None, + provider="openai" + ) + mock_client.complete_with_tracking.return_value = mock_response + + opts = {"prompt": default_options["prompt"]} + is_engaged, response = check_engagement( MOCK_ONE_SIDED_TRANSCRIPT, default_options["prompt"], - default_options["model"], - default_options["temperature"], - client + mock_client, + "test-uuid", + opts ) - assert result is False + assert is_engaged is False + assert response.content == "false" def test_run_skips_if_no_transcript(mock_redis, mock_vcon): """ @@ -158,11 +184,27 @@ def test_run_skips_if_no_transcript(mock_redis, mock_vcon): mock_vcon.add_analysis.assert_not_called() mock_vcon.add_tag.assert_not_called() -def test_run_processes_transcript(mock_redis, mock_vcon): + +@patch('server.links.detect_engagement.create_llm_client') +def test_run_processes_transcript(mock_create_client, mock_redis, mock_vcon): """ Test that run processes a valid transcript and adds analysis and tag if engagement is detected. """ - skip_if_no_openai_key() + # Set up mock LLM client + mock_client = Mock() + mock_response = LLMResponse( + content="true", + model="gpt-4.1", + prompt_tokens=10, + completion_tokens=5, + total_tokens=15, + raw_response=None, + provider="openai" + ) + mock_client.complete_with_tracking.return_value = mock_response + mock_client.provider_name = "openai" + mock_create_client.return_value = mock_client + transcript_analysis = { "dialog": 0, "type": "transcript", @@ -173,16 +215,23 @@ def test_run_processes_transcript(mock_redis, mock_vcon): } } mock_vcon.analysis = [transcript_analysis] - result = run("test-uuid", "test-link", {"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY")}) + result = run("test-uuid", "test-link", {"OPENAI_API_KEY": "test-key"}) assert result == "test-uuid" mock_vcon.add_analysis.assert_called_once() mock_vcon.add_tag.assert_called_once_with(tag_name="engagement", tag_value="true") -def test_run_handles_api_error(mock_redis, mock_vcon): + +@patch('server.links.detect_engagement.create_llm_client') +def test_run_handles_api_error(mock_create_client, mock_redis, mock_vcon): """ - Test that run handles OpenAI API errors gracefully and does not add analysis or tag. + Test that run handles LLM API errors gracefully and does not add analysis or tag. """ - skip_if_no_openai_key() + # Set up mock LLM client to raise exception + mock_client = Mock() + mock_client.complete_with_tracking.side_effect = Exception("API Error") + mock_client.provider_name = "openai" + mock_create_client.return_value = mock_client + transcript_analysis = { "dialog": 0, "type": "transcript", @@ -193,17 +242,16 @@ def test_run_handles_api_error(mock_redis, mock_vcon): } } mock_vcon.analysis = [transcript_analysis] - # Intentionally pass an invalid API key to trigger an error with pytest.raises(Exception): - run("test-uuid", "test-link", {"OPENAI_API_KEY": "invalid-key"}) + run("test-uuid", "test-link", {"OPENAI_API_KEY": "test-key"}) mock_vcon.add_analysis.assert_not_called() mock_vcon.add_tag.assert_not_called() + def test_run_respects_sampling_rate(mock_redis, mock_vcon): """ Test that run respects the sampling rate and skips processing if randomly_execute_with_sampling returns False. """ - skip_if_no_openai_key() transcript_analysis = { "dialog": 0, "type": "transcript", @@ -216,15 +264,21 @@ def test_run_respects_sampling_rate(mock_redis, mock_vcon): mock_vcon.analysis = [transcript_analysis] # Patch randomly_execute_with_sampling to always return False (simulate skipping) with patch("server.links.detect_engagement.randomly_execute_with_sampling", return_value=False): - result = run("test-uuid", "test-link", {"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY"), "sampling_rate": 0}) + result = run("test-uuid", "test-link", {"OPENAI_API_KEY": "test-key", "sampling_rate": 0}) assert result == "test-uuid" mock_vcon.add_analysis.assert_not_called() -def test_run_skips_existing_analysis(mock_redis, mock_vcon): + +@patch('server.links.detect_engagement.create_llm_client') +def test_run_skips_existing_analysis(mock_create_client, mock_redis, mock_vcon): """ Test that run skips processing if engagement_analysis already exists for the dialog. """ - skip_if_no_openai_key() + # Set up mock LLM client (should not be called) + mock_client = Mock() + mock_client.provider_name = "openai" + mock_create_client.return_value = mock_client + transcript_analysis = { "dialog": 0, "type": "transcript", @@ -240,7 +294,8 @@ def test_run_skips_existing_analysis(mock_redis, mock_vcon): "body": "true" } mock_vcon.analysis = [transcript_analysis, existing_analysis] - result = run("test-uuid", "test-link", {"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY")}) + result = run("test-uuid", "test-link", {"OPENAI_API_KEY": "test-key"}) assert result == "test-uuid" mock_vcon.add_analysis.assert_not_called() - mock_vcon.add_tag.assert_not_called() \ No newline at end of file + mock_vcon.add_tag.assert_not_called() + mock_client.complete_with_tracking.assert_not_called() \ No newline at end of file diff --git a/server/settings.py b/server/settings.py index 570beb4..fc6fe40 100644 --- a/server/settings.py +++ b/server/settings.py @@ -21,6 +21,18 @@ DEEPGRAM_KEY = os.getenv("DEEPGRAM_KEY") OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +# LLM Configuration +# Default model used when not specified in link options +LLM_DEFAULT_MODEL = os.getenv("LLM_DEFAULT_MODEL", "gpt-3.5-turbo") +LLM_DEFAULT_TEMPERATURE = float(os.getenv("LLM_DEFAULT_TEMPERATURE", "0")) +LLM_DEFAULT_TIMEOUT = float(os.getenv("LLM_DEFAULT_TIMEOUT", "120")) + +# Additional LLM Provider API Keys +ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY") +AZURE_OPENAI_API_KEY = os.getenv("AZURE_OPENAI_API_KEY") +AZURE_OPENAI_ENDPOINT = os.getenv("AZURE_OPENAI_ENDPOINT") +AZURE_OPENAI_API_VERSION = os.getenv("AZURE_OPENAI_API_VERSION", "2024-02-15-preview") + VCON_STORAGE = os.getenv("VCON_STORAGE", "") INDEX_NAME = "vcon" WEVIATE_HOST = os.getenv("WEVIATE_HOST", "localhost:8000") diff --git a/server/storage/chatgpt_files/README.md b/server/storage/chatgpt_files/README.md index 0c8e13e..ecef661 100644 --- a/server/storage/chatgpt_files/README.md +++ b/server/storage/chatgpt_files/README.md @@ -19,7 +19,7 @@ The module accepts the following configuration options: default_options = { "organization_key": "org-xxxxx", # OpenAI organization ID "project_key": "proj_xxxxxxx", # OpenAI project ID - "api_key": "sk-proj-xxxxxx", # OpenAI API key + "api_key": "YOUR_OPENAI_API_KEY", # OpenAI API key "vector_store_id": "xxxxxx", # Vector store ID for embeddings "purpose": "assistants", # Purpose for file upload "cleanup_local_files": True, # Auto-cleanup of temp files @@ -32,7 +32,7 @@ default_options = { Recommended environment variable configuration: ```bash -OPENAI_API_KEY=sk-... +OPENAI_API_KEY=YOUR_OPENAI_API_KEY OPENAI_ORG_ID=org-... OPENAI_PROJECT_ID=proj_... OPENAI_VECTOR_STORE_ID=vs-... @@ -58,7 +58,7 @@ vcon_data = get(vcon_uuid, options) options = { "organization_key": "org-xxx", "project_key": "proj_xxx", - "api_key": "sk-xxx", + "api_key": "YOUR_OPENAI_API_KEY", "vector_store_id": "vs-xxx", "purpose": "assistants" } diff --git a/server/storage/chatgpt_files/__init__.py b/server/storage/chatgpt_files/__init__.py index 6075ceb..77e77b5 100644 --- a/server/storage/chatgpt_files/__init__.py +++ b/server/storage/chatgpt_files/__init__.py @@ -10,7 +10,7 @@ default_options = { "organization_key": "org-xxxxx", "project_key": "proj_xxxxxxx", - "api_key": "sk-proj-xxxxxx", + "api_key": "", "vector_store_id": "xxxxxx", "purpose": "assistants", } diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..7518fc9 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" From 872eba93e466f90e04a1ad12ce18a7143535725d Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Thu, 22 Jan 2026 14:28:58 -0500 Subject: [PATCH 2/4] Add tests/conftest.py to fix module import in Docker CI The tests/storage directory was being confused with server/storage due to pythonpath settings in pytest.ini. This conftest.py ensures proper module discovery in the Docker test environment. Co-Authored-By: Claude Opus 4.5 --- tests/conftest.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7cde6c3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +# This file helps pytest properly discover test modules in Docker environments. +# It ensures that tests/storage is recognized as part of the tests package, +# not confused with server/storage due to pythonpath settings. +import sys +from pathlib import Path + +# Ensure the tests directory is in sys.path +tests_dir = Path(__file__).parent +if str(tests_dir) not in sys.path: + sys.path.insert(0, str(tests_dir)) From 7f0642e687bdbf9879f711a58868b28ea8e028c7 Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Thu, 22 Jan 2026 14:36:45 -0500 Subject: [PATCH 3/4] Move LLM tests to tests/lib/ and fix pytest configuration - Move test_llm_client.py and test_llm_client_integration.py from server/lib/tests/ to tests/lib/ to follow project test structure - Update imports to use 'lib.llm_client' (not 'server.lib.llm_client') since pytest.ini adds 'server' to pythonpath - Update tests/conftest.py to properly set up sys.path for both project root and server directory - Add testpaths = tests to pytest.ini to limit test discovery This fixes the CI import errors where pytest was confused by multiple 'tests' directories in the project. Co-Authored-By: Claude Opus 4.5 --- pytest.ini | 1 + server/lib/tests/__init__.py | 1 - tests/conftest.py | 14 ++++++++++---- tests/lib/__init__.py | 1 + {server/lib/tests => tests/lib}/test_llm_client.py | 0 .../lib}/test_llm_client_integration.py | 0 6 files changed, 12 insertions(+), 5 deletions(-) delete mode 100644 server/lib/tests/__init__.py create mode 100644 tests/lib/__init__.py rename {server/lib/tests => tests/lib}/test_llm_client.py (100%) rename {server/lib/tests => tests/lib}/test_llm_client_integration.py (100%) diff --git a/pytest.ini b/pytest.ini index df756a0..82746df 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,6 @@ [pytest] pythonpath = ., server +testpaths = tests log_cli = 1 log_cli_level = INFO diff --git a/server/lib/tests/__init__.py b/server/lib/tests/__init__.py deleted file mode 100644 index 24818a8..0000000 --- a/server/lib/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Test package for lib module diff --git a/tests/conftest.py b/tests/conftest.py index 7cde6c3..e78f652 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,13 @@ import sys from pathlib import Path -# Ensure the tests directory is in sys.path -tests_dir = Path(__file__).parent -if str(tests_dir) not in sys.path: - sys.path.insert(0, str(tests_dir)) +# Get the project root directory (parent of tests/) +project_root = Path(__file__).parent.parent +server_dir = project_root / "server" + +# Ensure both project root and server directory are in sys.path +# This matches the pythonpath setting in pytest.ini: "., server" +for path in [project_root, server_dir]: + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py new file mode 100644 index 0000000..7015480 --- /dev/null +++ b/tests/lib/__init__.py @@ -0,0 +1 @@ +# Tests for server/lib modules diff --git a/server/lib/tests/test_llm_client.py b/tests/lib/test_llm_client.py similarity index 100% rename from server/lib/tests/test_llm_client.py rename to tests/lib/test_llm_client.py diff --git a/server/lib/tests/test_llm_client_integration.py b/tests/lib/test_llm_client_integration.py similarity index 100% rename from server/lib/tests/test_llm_client_integration.py rename to tests/lib/test_llm_client_integration.py From 876121d0c1d62e36c106a950c3e70f0036f13955 Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Thu, 22 Jan 2026 14:40:58 -0500 Subject: [PATCH 4/4] Fix Dockerfile ENV syntax warnings - Use modern ENV key=value format instead of legacy ENV key value - Remove undefined $PYTHONPATH variable reference (not needed in fresh container) Co-Authored-By: Claude Opus 4.5 --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 04b423d..a2b23fd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -33,7 +33,7 @@ RUN opentelemetry-bootstrap -a install # Copy the rest of the application COPY . /app -ENV PYTHONPATH "${PYTHONPATH}:/app/:/app/server/" +ENV PYTHONPATH="/app:/app/server" ENTRYPOINT ["/app/docker/wait_for_redis.sh"]