diff --git a/.env.example b/.env.example
index 6cddda2..749c598 100644
--- a/.env.example
+++ b/.env.example
@@ -1,11 +1,22 @@
# Claude CLI Configuration
CLAUDE_CLI_PATH=claude
+# Authentication Method (optional - explicit selection)
+# Set this to override auto-detection. Values: cli, api_key, bedrock, vertex
+# If not set, auto-detects based on available env vars (ANTHROPIC_API_KEY, etc.)
+# CLAUDE_AUTH_METHOD=cli
+
# API Configuration
# If API_KEY is not set, server will prompt for interactive API key protection on startup
# Leave commented out to enable interactive prompt, or uncomment to use a fixed API key
# API_KEY=your-optional-api-key-here
+
+# Server Configuration
PORT=8000
+# Host binding address - use 127.0.0.1 for local-only access, 0.0.0.0 for all interfaces
+# CLAUDE_WRAPPER_HOST=0.0.0.0
+# Maximum request body size in bytes (default: 10MB)
+# MAX_REQUEST_SIZE=10485760
# Timeout Configuration (milliseconds)
MAX_TIMEOUT=600000
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..7df474a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,66 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ["3.10", "3.11", "3.12"]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install Poetry
+ uses: snok/install-poetry@v1
+ with:
+ virtualenvs-create: true
+ virtualenvs-in-project: true
+
+ - name: Load cached venv
+ id: cached-poetry-dependencies
+ uses: actions/cache@v4
+ with:
+ path: .venv
+ key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
+
+ - name: Install dependencies
+ if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
+ run: poetry install --no-interaction --no-root
+
+ - name: Install project
+ run: poetry install --no-interaction
+
+ - name: Run linting
+ run: poetry run black --check src tests
+
+ - name: Type checking
+ run: poetry run mypy src --ignore-missing-imports
+ continue-on-error: true
+
+ - name: Security scan
+ run: poetry run bandit -r src/ -ll -x tests
+
+ - name: Dependency vulnerability scan
+ run: poetry run safety check || true
+ continue-on-error: true
+
+ - name: Run tests
+ run: poetry run pytest tests/ -v --cov=src --cov-report=xml --cov-report=term-missing
+
+ - name: Upload coverage to Codecov
+ if: matrix.python-version == '3.11'
+ uses: codecov/codecov-action@v4
+ with:
+ files: ./coverage.xml
+ fail_ci_if_error: false
diff --git a/README.md b/README.md
index 8fbd42b..72ee5c4 100644
--- a/README.md
+++ b/README.md
@@ -408,7 +408,9 @@ Env vars override defaults and can be set at runtime with `-e` flags or in `dock
- **Core Server Settings**:
- `PORT=9000`: Changes the internal listening port (default: 8000; update port mapping accordingly).
+ - `CLAUDE_WRAPPER_HOST=127.0.0.1`: Sets the host binding address (default: 0.0.0.0 for all interfaces; use 127.0.0.1 for local-only access).
- `MAX_TIMEOUT=600`: Sets the request timeout in seconds (default: 300; increase for complex Claude queries).
+ - `MAX_REQUEST_SIZE=10485760`: Maximum request body size in bytes (default: 10MB; increase for large payloads).
- `CLAUDE_CWD=/path/to/workspace`: Sets Claude Code's working directory (default: isolated temp directory for security).
- **Authentication and Providers**:
diff --git a/poetry.lock b/poetry.lock
index b039ce4..03d8e92 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -47,6 +47,104 @@ files = [
{file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"},
]
+[[package]]
+name = "authlib"
+version = "1.6.6"
+description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd"},
+ {file = "authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e"},
+]
+
+[package.dependencies]
+cryptography = "*"
+
+[[package]]
+name = "backports-datetime-fromisoformat"
+version = "2.0.3"
+description = "Backport of Python 3.11's datetime.fromisoformat"
+optional = false
+python-versions = ">3"
+groups = ["dev"]
+markers = "python_version == \"3.10\""
+files = [
+ {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e81b26497a17c29595bc7df20bc6a872ceea5f8c9d6537283945d4b6396aec10"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:5ba00ead8d9d82fd6123eb4891c566d30a293454e54e32ff7ead7644f5f7e575"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:24d574cb4072e1640b00864e94c4c89858033936ece3fc0e1c6f7179f120d0a8"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9735695a66aad654500b0193525e590c693ab3368478ce07b34b443a1ea5e824"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63d39709e17eb72685d052ac82acf0763e047f57c86af1b791505b1fec96915d"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:1ea2cc84224937d6b9b4c07f5cb7c667f2bde28c255645ba27f8a675a7af8234"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4024e6d35a9fdc1b3fd6ac7a673bd16cb176c7e0b952af6428b7129a70f72cce"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5e2dcc94dc9c9ab8704409d86fcb5236316e9dcef6feed8162287634e3568f4c"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fa2de871801d824c255fac7e5e7e50f2be6c9c376fd9268b40c54b5e9da91f42"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:1314d4923c1509aa9696712a7bc0c7160d3b7acf72adafbbe6c558d523f5d491"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:b750ecba3a8815ad8bc48311552f3f8ab99dd2326d29df7ff670d9c49321f48f"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d5117dce805d8a2f78baeddc8c6127281fa0a5e2c40c6dd992ba6b2b367876"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb35f607bd1cbe37b896379d5f5ed4dc298b536f4b959cb63180e05cacc0539d"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:61c74710900602637d2d145dda9720c94e303380803bf68811b2a151deec75c2"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ece59af54ebf67ecbfbbf3ca9066f5687879e36527ad69d8b6e3ac565d565a62"},
+ {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:d0a7c5f875068efe106f62233bc712d50db4d07c13c7db570175c7857a7b5dbd"},
+ {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039"},
+ {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06"},
+ {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7100adcda5e818b5a894ad0626e38118bb896a347f40ebed8981155675b9ba7b"},
+ {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e410383f5d6a449a529d074e88af8bc80020bb42b402265f9c02c8358c11da5"},
+ {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2797593760da6bcc32c4a13fa825af183cd4bfd333c60b3dbf84711afca26ef"},
+ {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35a144fd681a0bea1013ccc4cd3fd4dc758ea17ee23dca019c02b82ec46fc0c4"},
+ {file = "backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d"},
+]
+
+[[package]]
+name = "bandit"
+version = "1.9.2"
+description = "Security oriented static analyser for python code."
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "bandit-1.9.2-py3-none-any.whl", hash = "sha256:bda8d68610fc33a6e10b7a8f1d61d92c8f6c004051d5e946406be1fb1b16a868"},
+ {file = "bandit-1.9.2.tar.gz", hash = "sha256:32410415cd93bf9c8b91972159d5cf1e7f063a9146d70345641cd3877de348ce"},
+]
+
+[package.dependencies]
+colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""}
+PyYAML = ">=5.3.1"
+rich = "*"
+stevedore = ">=1.20.0"
+
+[package.extras]
+baseline = ["GitPython (>=3.1.30)"]
+sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"]
+test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"]
+toml = ["tomli (>=1.1.0) ; python_version < \"3.11\""]
+yaml = ["PyYAML"]
+
[[package]]
name = "black"
version = "24.10.0"
@@ -112,7 +210,7 @@ version = "2.0.0"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.9"
-groups = ["main"]
+groups = ["main", "dev"]
markers = "platform_python_implementation != \"PyPy\""
files = [
{file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
@@ -357,13 +455,121 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
+[[package]]
+name = "coverage"
+version = "7.13.1"
+description = "Code coverage measurement for Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147"},
+ {file = "coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d"},
+ {file = "coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0"},
+ {file = "coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90"},
+ {file = "coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d"},
+ {file = "coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b"},
+ {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6"},
+ {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e"},
+ {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae"},
+ {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29"},
+ {file = "coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f"},
+ {file = "coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1"},
+ {file = "coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88"},
+ {file = "coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3"},
+ {file = "coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9"},
+ {file = "coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee"},
+ {file = "coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf"},
+ {file = "coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3"},
+ {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef"},
+ {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851"},
+ {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb"},
+ {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba"},
+ {file = "coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19"},
+ {file = "coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a"},
+ {file = "coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c"},
+ {file = "coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3"},
+ {file = "coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e"},
+ {file = "coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c"},
+ {file = "coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62"},
+ {file = "coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968"},
+ {file = "coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e"},
+ {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f"},
+ {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee"},
+ {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf"},
+ {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c"},
+ {file = "coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7"},
+ {file = "coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6"},
+ {file = "coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c"},
+ {file = "coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78"},
+ {file = "coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b"},
+ {file = "coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd"},
+ {file = "coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992"},
+ {file = "coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4"},
+ {file = "coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a"},
+ {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766"},
+ {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4"},
+ {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398"},
+ {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784"},
+ {file = "coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461"},
+ {file = "coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500"},
+ {file = "coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9"},
+ {file = "coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc"},
+ {file = "coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a"},
+ {file = "coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4"},
+ {file = "coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6"},
+ {file = "coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1"},
+ {file = "coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd"},
+ {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c"},
+ {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0"},
+ {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e"},
+ {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53"},
+ {file = "coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842"},
+ {file = "coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2"},
+ {file = "coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09"},
+ {file = "coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894"},
+ {file = "coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a"},
+ {file = "coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f"},
+ {file = "coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909"},
+ {file = "coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4"},
+ {file = "coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75"},
+ {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9"},
+ {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465"},
+ {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864"},
+ {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9"},
+ {file = "coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5"},
+ {file = "coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a"},
+ {file = "coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0"},
+ {file = "coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a"},
+ {file = "coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6"},
+ {file = "coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673"},
+ {file = "coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5"},
+ {file = "coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d"},
+ {file = "coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8"},
+ {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486"},
+ {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564"},
+ {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7"},
+ {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416"},
+ {file = "coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f"},
+ {file = "coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79"},
+ {file = "coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4"},
+ {file = "coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573"},
+ {file = "coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd"},
+]
+
+[package.dependencies]
+tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
+
+[package.extras]
+toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
+
[[package]]
name = "cryptography"
version = "46.0.3"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
-groups = ["main"]
+groups = ["main", "dev"]
files = [
{file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"},
@@ -465,6 +671,28 @@ files = [
{file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"},
]
+[[package]]
+name = "dparse"
+version = "0.6.4"
+description = "A parser for Python dependency files"
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "dparse-0.6.4-py3-none-any.whl", hash = "sha256:fbab4d50d54d0e739fbb4dedfc3d92771003a5b9aa8545ca7a7045e3b174af57"},
+ {file = "dparse-0.6.4.tar.gz", hash = "sha256:90b29c39e3edc36c6284c82c4132648eaf28a01863eb3c231c2512196132201a"},
+]
+
+[package.dependencies]
+packaging = "*"
+tomli = {version = "*", markers = "python_version < \"3.11\""}
+
+[package.extras]
+all = ["pipenv", "poetry", "pyyaml"]
+conda = ["pyyaml"]
+pipenv = ["pipenv"]
+poetry = ["poetry"]
+
[[package]]
name = "exceptiongroup"
version = "1.3.0"
@@ -505,6 +733,18 @@ typing-extensions = ">=4.8.0"
all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
+[[package]]
+name = "filelock"
+version = "3.20.1"
+description = "A platform independent file lock."
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a"},
+ {file = "filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c"},
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -633,6 +873,40 @@ files = [
{file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"},
]
+[[package]]
+name = "hypothesis"
+version = "6.148.8"
+description = "The property-based testing library for Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "hypothesis-6.148.8-py3-none-any.whl", hash = "sha256:c1842f47f974d74661b3779a26032f8b91bc1eb30d84741714d3712d7f43e85e"},
+ {file = "hypothesis-6.148.8.tar.gz", hash = "sha256:fa6b2ae029bc02f9d2d6c2257b0cbf2dc3782362457d2027a038ad7f4209c385"},
+]
+
+[package.dependencies]
+exceptiongroup = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
+sortedcontainers = ">=2.1.0,<3.0.0"
+
+[package.extras]
+all = ["black (>=20.8b0)", "click (>=7.0)", "crosshair-tool (>=0.0.101)", "django (>=4.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.27)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.21.6)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2025.3) ; sys_platform == \"win32\" or sys_platform == \"emscripten\"", "watchdog (>=4.0.0)"]
+cli = ["black (>=20.8b0)", "click (>=7.0)", "rich (>=9.0.0)"]
+codemods = ["libcst (>=0.3.16)"]
+crosshair = ["crosshair-tool (>=0.0.101)", "hypothesis-crosshair (>=0.0.27)"]
+dateutil = ["python-dateutil (>=1.4)"]
+django = ["django (>=4.2)"]
+dpcontracts = ["dpcontracts (>=0.4)"]
+ghostwriter = ["black (>=20.8b0)"]
+lark = ["lark (>=0.10.1)"]
+numpy = ["numpy (>=1.21.6)"]
+pandas = ["pandas (>=1.1)"]
+pytest = ["pytest (>=4.6)"]
+pytz = ["pytz (>=2014.1)"]
+redis = ["redis (>=3.0.0)"]
+watchdog = ["watchdog (>=4.0.0)"]
+zoneinfo = ["tzdata (>=2025.3) ; sys_platform == \"win32\" or sys_platform == \"emscripten\""]
+
[[package]]
name = "idna"
version = "3.10"
@@ -660,6 +934,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 = ["dev"]
+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.10.0"
@@ -747,6 +1039,18 @@ files = [
{file = "jiter-0.10.0.tar.gz", hash = "sha256:07a7142c38aacc85194391108dc91b5b57093c978a9932bd86a36862759d9500"},
]
+[[package]]
+name = "joblib"
+version = "1.5.3"
+description = "Lightweight pipelining with Python functions"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713"},
+ {file = "joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3"},
+]
+
[[package]]
name = "jsonschema"
version = "4.25.1"
@@ -784,6 +1088,93 @@ files = [
[package.dependencies]
referencing = ">=0.31.0"
+[[package]]
+name = "librt"
+version = "0.7.5"
+description = "Mypyc runtime library"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+markers = "platform_python_implementation != \"PyPy\""
+files = [
+ {file = "librt-0.7.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81056e01bba1394f1d92904ec61a4078f66df785316275edbaf51d90da8c6e26"},
+ {file = "librt-0.7.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d7c72c8756eeb3aefb1b9e3dac7c37a4a25db63640cac0ab6fc18e91a0edf05a"},
+ {file = "librt-0.7.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ddc4a16207f88f9597b397fc1f60781266d13b13de922ff61c206547a29e4bbd"},
+ {file = "librt-0.7.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63055d3dda433ebb314c9f1819942f16a19203c454508fdb2d167613f7017169"},
+ {file = "librt-0.7.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f85f9b5db87b0f52e53c68ad2a0c5a53e00afa439bd54a1723742a2b1021276"},
+ {file = "librt-0.7.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c566a4672564c5d54d8ab65cdaae5a87ee14c1564c1a2ddc7a9f5811c750f023"},
+ {file = "librt-0.7.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fee15c2a190ef389f14928135c6fb2d25cd3fdb7887bfd9a7b444bbdc8c06b96"},
+ {file = "librt-0.7.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:584cb3e605ec45ba350962cec853e17be0a25a772f21f09f1e422f7044ae2a7d"},
+ {file = "librt-0.7.5-cp310-cp310-win32.whl", hash = "sha256:9c08527055fbb03c641c15bbc5b79dd2942fb6a3bd8dabf141dd7e97eeea4904"},
+ {file = "librt-0.7.5-cp310-cp310-win_amd64.whl", hash = "sha256:dd810f2d39c526c42ea205e0addad5dc08ef853c625387806a29d07f9d150d9b"},
+ {file = "librt-0.7.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f952e1a78c480edee8fb43aa2bf2e84dcd46c917d44f8065b883079d3893e8fc"},
+ {file = "librt-0.7.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75965c1f4efb7234ff52a58b729d245a21e87e4b6a26a0ec08052f02b16274e4"},
+ {file = "librt-0.7.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:732e0aa0385b59a1b2545159e781c792cc58ce9c134249233a7c7250a44684c4"},
+ {file = "librt-0.7.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdde31759bd8888f3ef0eebda80394a48961328a17c264dce8cc35f4b9cde35d"},
+ {file = "librt-0.7.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3146d52465b3b6397d25d513f428cb421c18df65b7378667bb5f1e3cc45805"},
+ {file = "librt-0.7.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:29c8d2fae11d4379ea207ba7fc69d43237e42cf8a9f90ec6e05993687e6d648b"},
+ {file = "librt-0.7.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bb41f04046b4f22b1e7ba5ef513402cd2e3477ec610e5f92d38fe2bba383d419"},
+ {file = "librt-0.7.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8bb7883c1e94ceb87c2bf81385266f032da09cd040e804cc002f2c9d6b842e2f"},
+ {file = "librt-0.7.5-cp311-cp311-win32.whl", hash = "sha256:84d4a6b9efd6124f728558a18e79e7cc5c5d4efc09b2b846c910de7e564f5bad"},
+ {file = "librt-0.7.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab4b0d3bee6f6ff7017e18e576ac7e41a06697d8dea4b8f3ab9e0c8e1300c409"},
+ {file = "librt-0.7.5-cp311-cp311-win_arm64.whl", hash = "sha256:730be847daad773a3c898943cf67fb9845a3961d06fb79672ceb0a8cd8624cfa"},
+ {file = "librt-0.7.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba1077c562a046208a2dc6366227b3eeae8f2c2ab4b41eaf4fd2fa28cece4203"},
+ {file = "librt-0.7.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:654fdc971c76348a73af5240d8e2529265b9a7ba6321e38dd5bae7b0d4ab3abe"},
+ {file = "librt-0.7.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6b7b58913d475911f6f33e8082f19dd9b120c4f4a5c911d07e395d67b81c6982"},
+ {file = "librt-0.7.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e0fd344bad57026a8f4ccfaf406486c2fc991838050c2fef156170edc3b775"},
+ {file = "librt-0.7.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46aa91813c267c3f60db75d56419b42c0c0b9748ec2c568a0e3588e543fb4233"},
+ {file = "librt-0.7.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ddc0ab9dbc5f9ceaf2bf7a367bf01f2697660e908f6534800e88f43590b271db"},
+ {file = "librt-0.7.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7a488908a470451338607650f1c064175094aedebf4a4fa37890682e30ce0b57"},
+ {file = "librt-0.7.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e47fc52602ffc374e69bf1b76536dc99f7f6dd876bd786c8213eaa3598be030a"},
+ {file = "librt-0.7.5-cp312-cp312-win32.whl", hash = "sha256:cda8b025875946ffff5a9a7590bf9acde3eb02cb6200f06a2d3e691ef3d9955b"},
+ {file = "librt-0.7.5-cp312-cp312-win_amd64.whl", hash = "sha256:b591c094afd0ffda820e931148c9e48dc31a556dc5b2b9b3cc552fa710d858e4"},
+ {file = "librt-0.7.5-cp312-cp312-win_arm64.whl", hash = "sha256:532ddc6a8a6ca341b1cd7f4d999043e4c71a212b26fe9fd2e7f1e8bb4e873544"},
+ {file = "librt-0.7.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b1795c4b2789b458fa290059062c2f5a297ddb28c31e704d27e161386469691a"},
+ {file = "librt-0.7.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2fcbf2e135c11f721193aa5f42ba112bb1046afafbffd407cbc81d8d735c74d0"},
+ {file = "librt-0.7.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c039bbf79a9a2498404d1ae7e29a6c175e63678d7a54013a97397c40aee026c5"},
+ {file = "librt-0.7.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3919c9407faeeee35430ae135e3a78acd4ecaaaa73767529e2c15ca1d73ba325"},
+ {file = "librt-0.7.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26b46620e1e0e45af510d9848ea0915e7040605dd2ae94ebefb6c962cbb6f7ec"},
+ {file = "librt-0.7.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9bbb8facc5375476d392990dd6a71f97e4cb42e2ac66f32e860f6e47299d5e89"},
+ {file = "librt-0.7.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e9e9c988b5ffde7be02180f864cbd17c0b0c1231c235748912ab2afa05789c25"},
+ {file = "librt-0.7.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:edf6b465306215b19dbe6c3fb63cf374a8f3e1ad77f3b4c16544b83033bbb67b"},
+ {file = "librt-0.7.5-cp313-cp313-win32.whl", hash = "sha256:060bde69c3604f694bd8ae21a780fe8be46bb3dbb863642e8dfc75c931ca8eee"},
+ {file = "librt-0.7.5-cp313-cp313-win_amd64.whl", hash = "sha256:a82d5a0ee43aeae2116d7292c77cc8038f4841830ade8aa922e098933b468b9e"},
+ {file = "librt-0.7.5-cp313-cp313-win_arm64.whl", hash = "sha256:3c98a8d0ac9e2a7cb8ff8c53e5d6e8d82bfb2839abf144fdeaaa832f2a12aa45"},
+ {file = "librt-0.7.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9937574e6d842f359b8585903d04f5b4ab62277a091a93e02058158074dc52f2"},
+ {file = "librt-0.7.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5cd3afd71e9bc146203b6c8141921e738364158d4aa7cdb9a874e2505163770f"},
+ {file = "librt-0.7.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9cffa3ef0af29687455161cb446eff059bf27607f95163d6a37e27bcb37180f6"},
+ {file = "librt-0.7.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f3f088482e2229387eadf8215c03f7726d56f69cce8c0c40f0795aebc9b361"},
+ {file = "librt-0.7.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7aa33153a5bb0bac783d2c57885889b1162823384e8313d47800a0e10d0070e"},
+ {file = "librt-0.7.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:265729b551a2dd329cc47b323a182fb7961af42abf21e913c9dd7d3331b2f3c2"},
+ {file = "librt-0.7.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:168e04663e126416ba712114050f413ac306759a1791d87b7c11d4428ba75760"},
+ {file = "librt-0.7.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:553dc58987d1d853adda8aeadf4db8e29749f0b11877afcc429a9ad892818ae2"},
+ {file = "librt-0.7.5-cp314-cp314-win32.whl", hash = "sha256:263f4fae9eba277513357c871275b18d14de93fd49bf5e43dc60a97b81ad5eb8"},
+ {file = "librt-0.7.5-cp314-cp314-win_amd64.whl", hash = "sha256:85f485b7471571e99fab4f44eeb327dc0e1f814ada575f3fa85e698417d8a54e"},
+ {file = "librt-0.7.5-cp314-cp314-win_arm64.whl", hash = "sha256:49c596cd18e90e58b7caa4d7ca7606049c1802125fcff96b8af73fa5c3870e4d"},
+ {file = "librt-0.7.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:54d2aef0b0f5056f130981ad45081b278602ff3657fe16c88529f5058038e802"},
+ {file = "librt-0.7.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0b4791202296ad51ac09a3ff58eb49d9da8e3a4009167a6d76ac418a974e5fd4"},
+ {file = "librt-0.7.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e860909fea75baef941ee6436e0453612505883b9d0d87924d4fda27865b9a2"},
+ {file = "librt-0.7.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f02c4337bf271c4f06637f5ff254fad2238c0b8e32a3a480ebb2fc5e26f754a5"},
+ {file = "librt-0.7.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f51ffe59f4556243d3cc82d827bde74765f594fa3ceb80ec4de0c13ccd3416"},
+ {file = "librt-0.7.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0b7f080ba30601dfa3e3deed3160352273e1b9bc92e652f51103c3e9298f7899"},
+ {file = "librt-0.7.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fb565b4219abc8ea2402e61c7ba648a62903831059ed3564fa1245cc245d58d7"},
+ {file = "librt-0.7.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a3cfb15961e7333ea6ef033dc574af75153b5c230d5ad25fbcd55198f21e0cf"},
+ {file = "librt-0.7.5-cp314-cp314t-win32.whl", hash = "sha256:118716de5ad6726332db1801bc90fa6d94194cd2e07c1a7822cebf12c496714d"},
+ {file = "librt-0.7.5-cp314-cp314t-win_amd64.whl", hash = "sha256:3dd58f7ce20360c6ce0c04f7bd9081c7f9c19fc6129a3c705d0c5a35439f201d"},
+ {file = "librt-0.7.5-cp314-cp314t-win_arm64.whl", hash = "sha256:08153ea537609d11f774d2bfe84af39d50d5c9ca3a4d061d946e0c9d8bce04a1"},
+ {file = "librt-0.7.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:df2e210400b28e50994477ebf82f055698c79797b6ee47a1669d383ca33263e1"},
+ {file = "librt-0.7.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d2cc7d187e8c6e9b7bdbefa9697ce897a704ea7a7ce844f2b4e0e2aa07ae51d3"},
+ {file = "librt-0.7.5-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39183abee670bc37b85f11e86c44a9cad1ed6efa48b580083e89ecee13dd9717"},
+ {file = "librt-0.7.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191cbd42660446d67cf7a95ac7bfa60f49b8b3b0417c64f216284a1d86fc9335"},
+ {file = "librt-0.7.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea1b60b86595a5dc1f57b44a801a1c4d8209c0a69518391d349973a4491408e6"},
+ {file = "librt-0.7.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:af69d9e159575e877c7546d1ee817b4ae089aa221dd1117e20c24ad8dc8659c7"},
+ {file = "librt-0.7.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0e2bf8f91093fac43e3eaebacf777f12fd539dce9ec5af3efc6d8424e96ccd49"},
+ {file = "librt-0.7.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8dcae24de1bc9da93aa689cb6313c70e776d7cea2fcf26b9b6160fedfe6bd9af"},
+ {file = "librt-0.7.5-cp39-cp39-win32.whl", hash = "sha256:cdb001a1a0e4f41e613bca2c0fc147fc8a7396f53fc94201cbfd8ec7cd69ca4b"},
+ {file = "librt-0.7.5-cp39-cp39-win_amd64.whl", hash = "sha256:a9eacbf983319b26b5f340a2e0cd47ac1ee4725a7f3a72fd0f15063c934b69d6"},
+ {file = "librt-0.7.5.tar.gz", hash = "sha256:de4221a1181fa9c8c4b5f35506ed6f298948f44003d84d2a8b9885d7e01e6cfa"},
+]
+
[[package]]
name = "limits"
version = "5.4.0"
@@ -813,6 +1204,150 @@ redis = ["redis (>3,!=4.5.2,!=4.5.3,<6.0.0)"]
rediscluster = ["redis (>=4.2.0,!=4.5.2,!=4.5.3)"]
valkey = ["valkey (>=6)"]
+[[package]]
+name = "markdown-it-py"
+version = "4.0.0"
+description = "Python port of markdown-it. Markdown parsing, done right!"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"},
+ {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
+]
+
+[package.dependencies]
+mdurl = ">=0.1,<1.0"
+
+[package.extras]
+benchmarking = ["psutil", "pytest", "pytest-benchmark"]
+compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"]
+linkify = ["linkify-it-py (>=1,<3)"]
+plugins = ["mdit-py-plugins (>=0.5.0)"]
+profiling = ["gprof2dot"]
+rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"]
+testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+description = "Safely add untrusted strings to HTML/XML markup."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+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 = "4.1.2"
+description = "A lightweight library for converting complex datatypes to and from native Python datatypes."
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "marshmallow-4.1.2-py3-none-any.whl", hash = "sha256:a8cfa18bd8d0e5f7339e734edf84815fe8db1bdb57358c7ccc05472b746eeadc"},
+ {file = "marshmallow-4.1.2.tar.gz", hash = "sha256:083f250643d2e75fd363f256aeb6b1af369a7513ad37647ce4a601f6966e3ba5"},
+]
+
+[package.dependencies]
+backports-datetime-fromisoformat = {version = "*", markers = "python_version < \"3.11\""}
+typing-extensions = {version = "*", markers = "python_version < \"3.11\""}
+
+[package.extras]
+dev = ["marshmallow[tests]", "pre-commit (>=3.5,<5.0)", "tox"]
+docs = ["autodocsumm (==0.2.14)", "furo (==2025.9.25)", "sphinx (==8.2.3)", "sphinx-copybutton (==0.5.2)", "sphinx-issues (==5.0.1)", "sphinxext-opengraph (==0.13.0)"]
+tests = ["pytest", "simplejson"]
+
[[package]]
name = "mcp"
version = "1.20.0"
@@ -844,6 +1379,80 @@ cli = ["python-dotenv (>=1.0.0)", "typer (>=0.16.0)"]
rich = ["rich (>=13.9.4)"]
ws = ["websockets (>=15.0.1)"]
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+description = "Markdown URL utilities"
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
+ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
+]
+
+[[package]]
+name = "mypy"
+version = "1.19.1"
+description = "Optional static typing for Python"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"},
+ {file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"},
+ {file = "mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6"},
+ {file = "mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74"},
+ {file = "mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1"},
+ {file = "mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac"},
+ {file = "mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288"},
+ {file = "mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab"},
+ {file = "mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6"},
+ {file = "mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331"},
+ {file = "mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925"},
+ {file = "mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042"},
+ {file = "mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1"},
+ {file = "mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"},
+ {file = "mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2"},
+ {file = "mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8"},
+ {file = "mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a"},
+ {file = "mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13"},
+ {file = "mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250"},
+ {file = "mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b"},
+ {file = "mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e"},
+ {file = "mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef"},
+ {file = "mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75"},
+ {file = "mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd"},
+ {file = "mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1"},
+ {file = "mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718"},
+ {file = "mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b"},
+ {file = "mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045"},
+ {file = "mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957"},
+ {file = "mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f"},
+ {file = "mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3"},
+ {file = "mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a"},
+ {file = "mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67"},
+ {file = "mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e"},
+ {file = "mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376"},
+ {file = "mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24"},
+ {file = "mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247"},
+ {file = "mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba"},
+]
+
+[package.dependencies]
+librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""}
+mypy_extensions = ">=1.0.0"
+pathspec = ">=0.9.0"
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+typing_extensions = ">=4.6.0"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+faster-cache = ["orjson"]
+install-types = ["pip"]
+mypyc = ["setuptools (>=50)"]
+reports = ["lxml"]
+
[[package]]
name = "mypy-extensions"
version = "1.1.0"
@@ -856,6 +1465,32 @@ files = [
{file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
]
+[[package]]
+name = "nltk"
+version = "3.9.2"
+description = "Natural Language Toolkit"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a"},
+ {file = "nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419"},
+]
+
+[package.dependencies]
+click = "*"
+joblib = "*"
+regex = ">=2021.8.3"
+tqdm = "*"
+
+[package.extras]
+all = ["matplotlib", "numpy", "pyparsing", "python-crfsuite", "requests", "scikit-learn", "scipy", "twython"]
+corenlp = ["requests"]
+machine-learning = ["numpy", "python-crfsuite", "scikit-learn", "scipy"]
+plot = ["matplotlib"]
+tgrep = ["pyparsing"]
+twitter = ["twython"]
+
[[package]]
name = "openai"
version = "1.93.0"
@@ -947,7 +1582,7 @@ version = "2.23"
description = "C parser in Python"
optional = false
python-versions = ">=3.8"
-groups = ["main"]
+groups = ["main", "dev"]
markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
files = [
{file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
@@ -1191,6 +1826,26 @@ pytest = ">=7.0.0,<9"
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
+[[package]]
+name = "pytest-cov"
+version = "7.0.0"
+description = "Pytest plugin for measuring coverage."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"},
+ {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"},
+]
+
+[package.dependencies]
+coverage = {version = ">=7.10.6", extras = ["toml"]}
+pluggy = ">=1.2"
+pytest = ">=7"
+
+[package.extras]
+testing = ["process-tests", "pytest-xdist", "virtualenv"]
+
[[package]]
name = "python-dotenv"
version = "1.1.1"
@@ -1255,7 +1910,7 @@ version = "6.0.2"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.8"
-groups = ["main"]
+groups = ["main", "dev"]
files = [
{file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
{file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
@@ -1329,6 +1984,131 @@ attrs = ">=22.2.0"
rpds-py = ">=0.7.0"
typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""}
+[[package]]
+name = "regex"
+version = "2025.11.3"
+description = "Alternative regular expression module, to replace re."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "regex-2025.11.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2b441a4ae2c8049106e8b39973bfbddfb25a179dda2bdb99b0eeb60c40a6a3af"},
+ {file = "regex-2025.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2fa2eed3f76677777345d2f81ee89f5de2f5745910e805f7af7386a920fa7313"},
+ {file = "regex-2025.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d8b4a27eebd684319bdf473d39f1d79eed36bf2cd34bd4465cdb4618d82b3d56"},
+ {file = "regex-2025.11.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cf77eac15bd264986c4a2c63353212c095b40f3affb2bc6b4ef80c4776c1a28"},
+ {file = "regex-2025.11.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f9ee819f94c6abfa56ec7b1dbab586f41ebbdc0a57e6524bd5e7f487a878c7"},
+ {file = "regex-2025.11.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:838441333bc90b829406d4a03cb4b8bf7656231b84358628b0406d803931ef32"},
+ {file = "regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfe6d3f0c9e3b7e8c0c694b24d25e677776f5ca26dce46fd6b0489f9c8339391"},
+ {file = "regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2ab815eb8a96379a27c3b6157fcb127c8f59c36f043c1678110cea492868f1d5"},
+ {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:728a9d2d173a65b62bdc380b7932dd8e74ed4295279a8fe1021204ce210803e7"},
+ {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:509dc827f89c15c66a0c216331260d777dd6c81e9a4e4f830e662b0bb296c313"},
+ {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:849202cd789e5f3cf5dcc7822c34b502181b4824a65ff20ce82da5524e45e8e9"},
+ {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b6f78f98741dcc89607c16b1e9426ee46ce4bf31ac5e6b0d40e81c89f3481ea5"},
+ {file = "regex-2025.11.3-cp310-cp310-win32.whl", hash = "sha256:149eb0bba95231fb4f6d37c8f760ec9fa6fabf65bab555e128dde5f2475193ec"},
+ {file = "regex-2025.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:ee3a83ce492074c35a74cc76cf8235d49e77b757193a5365ff86e3f2f93db9fd"},
+ {file = "regex-2025.11.3-cp310-cp310-win_arm64.whl", hash = "sha256:38af559ad934a7b35147716655d4a2f79fcef2d695ddfe06a06ba40ae631fa7e"},
+ {file = "regex-2025.11.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eadade04221641516fa25139273505a1c19f9bf97589a05bc4cfcd8b4a618031"},
+ {file = "regex-2025.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:feff9e54ec0dd3833d659257f5c3f5322a12eee58ffa360984b716f8b92983f4"},
+ {file = "regex-2025.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3b30bc921d50365775c09a7ed446359e5c0179e9e2512beec4a60cbcef6ddd50"},
+ {file = "regex-2025.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f99be08cfead2020c7ca6e396c13543baea32343b7a9a5780c462e323bd8872f"},
+ {file = "regex-2025.11.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6dd329a1b61c0ee95ba95385fb0c07ea0d3fe1a21e1349fa2bec272636217118"},
+ {file = "regex-2025.11.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c5238d32f3c5269d9e87be0cf096437b7622b6920f5eac4fd202468aaeb34d2"},
+ {file = "regex-2025.11.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10483eefbfb0adb18ee9474498c9a32fcf4e594fbca0543bb94c48bac6183e2e"},
+ {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:78c2d02bb6e1da0720eedc0bad578049cad3f71050ef8cd065ecc87691bed2b0"},
+ {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e6b49cd2aad93a1790ce9cffb18964f6d3a4b0b3dbdbd5de094b65296fce6e58"},
+ {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:885b26aa3ee56433b630502dc3d36ba78d186a00cc535d3806e6bfd9ed3c70ab"},
+ {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ddd76a9f58e6a00f8772e72cff8ebcff78e022be95edf018766707c730593e1e"},
+ {file = "regex-2025.11.3-cp311-cp311-win32.whl", hash = "sha256:3e816cc9aac1cd3cc9a4ec4d860f06d40f994b5c7b4d03b93345f44e08cc68bf"},
+ {file = "regex-2025.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:087511f5c8b7dfbe3a03f5d5ad0c2a33861b1fc387f21f6f60825a44865a385a"},
+ {file = "regex-2025.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:1ff0d190c7f68ae7769cd0313fe45820ba07ffebfddfaa89cc1eb70827ba0ddc"},
+ {file = "regex-2025.11.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8ab71e2e31b16e40868a40a69007bc305e1109bd4658eb6cad007e0bf67c41"},
+ {file = "regex-2025.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:22b29dda7e1f7062a52359fca6e58e548e28c6686f205e780b02ad8ef710de36"},
+ {file = "regex-2025.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a91e4a29938bc1a082cc28fdea44be420bf2bebe2665343029723892eb073e1"},
+ {file = "regex-2025.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b884f4226602ad40c5d55f52bf91a9df30f513864e0054bad40c0e9cf1afb7"},
+ {file = "regex-2025.11.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e0b11b2b2433d1c39c7c7a30e3f3d0aeeea44c2a8d0bae28f6b95f639927a69"},
+ {file = "regex-2025.11.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87eb52a81ef58c7ba4d45c3ca74e12aa4b4e77816f72ca25258a85b3ea96cb48"},
+ {file = "regex-2025.11.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a12ab1f5c29b4e93db518f5e3872116b7e9b1646c9f9f426f777b50d44a09e8c"},
+ {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7521684c8c7c4f6e88e35ec89680ee1aa8358d3f09d27dfbdf62c446f5d4c695"},
+ {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7fe6e5440584e94cc4b3f5f4d98a25e29ca12dccf8873679a635638349831b98"},
+ {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8e026094aa12b43f4fd74576714e987803a315c76edb6b098b9809db5de58f74"},
+ {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:435bbad13e57eb5606a68443af62bed3556de2f46deb9f7d4237bc2f1c9fb3a0"},
+ {file = "regex-2025.11.3-cp312-cp312-win32.whl", hash = "sha256:3839967cf4dc4b985e1570fd8d91078f0c519f30491c60f9ac42a8db039be204"},
+ {file = "regex-2025.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:e721d1b46e25c481dc5ded6f4b3f66c897c58d2e8cfdf77bbced84339108b0b9"},
+ {file = "regex-2025.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:64350685ff08b1d3a6fff33f45a9ca183dc1d58bbfe4981604e70ec9801bbc26"},
+ {file = "regex-2025.11.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c1e448051717a334891f2b9a620fe36776ebf3dd8ec46a0b877c8ae69575feb4"},
+ {file = "regex-2025.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b5aca4d5dfd7fbfbfbdaf44850fcc7709a01146a797536a8f84952e940cca76"},
+ {file = "regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a"},
+ {file = "regex-2025.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d9903ca42bfeec4cebedba8022a7c97ad2aab22e09573ce9976ba01b65e4361"},
+ {file = "regex-2025.11.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:639431bdc89d6429f6721625e8129413980ccd62e9d3f496be618a41d205f160"},
+ {file = "regex-2025.11.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f117efad42068f9715677c8523ed2be1518116d1c49b1dd17987716695181efe"},
+ {file = "regex-2025.11.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aecb6f461316adf9f1f0f6a4a1a3d79e045f9b71ec76055a791affa3b285850"},
+ {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b3a5f320136873cc5561098dfab677eea139521cb9a9e8db98b7e64aef44cbc"},
+ {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9"},
+ {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b"},
+ {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7"},
+ {file = "regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c"},
+ {file = "regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5"},
+ {file = "regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467"},
+ {file = "regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281"},
+ {file = "regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39"},
+ {file = "regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7"},
+ {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f264d4bf02f3176467d90b294d59bf1db9fe53c141ff772f27a8b456b2a9ed"},
+ {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7be0277469bf3bd7a34a9c57c1b6a724532a0d235cd0dc4e7f4316f982c28b19"},
+ {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d31e08426ff4b5b650f68839f5af51a92a5b51abd8554a60c2fbc7c71f25d0b"},
+ {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e43586ce5bd28f9f285a6e729466841368c4a0353f6fd08d4ce4630843d3648a"},
+ {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f9397d561a4c16829d4e6ff75202c1c08b68a3bdbfe29dbfcdb31c9830907c6"},
+ {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce"},
+ {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd"},
+ {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2"},
+ {file = "regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a"},
+ {file = "regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c"},
+ {file = "regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e"},
+ {file = "regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6"},
+ {file = "regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4"},
+ {file = "regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73"},
+ {file = "regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f"},
+ {file = "regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d"},
+ {file = "regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be"},
+ {file = "regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db"},
+ {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62"},
+ {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f"},
+ {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02"},
+ {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed"},
+ {file = "regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4"},
+ {file = "regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad"},
+ {file = "regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f"},
+ {file = "regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc"},
+ {file = "regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49"},
+ {file = "regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536"},
+ {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95"},
+ {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009"},
+ {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9"},
+ {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d"},
+ {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6"},
+ {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154"},
+ {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267"},
+ {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379"},
+ {file = "regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38"},
+ {file = "regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de"},
+ {file = "regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801"},
+ {file = "regex-2025.11.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:81519e25707fc076978c6143b81ea3dc853f176895af05bf7ec51effe818aeec"},
+ {file = "regex-2025.11.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3bf28b1873a8af8bbb58c26cc56ea6e534d80053b41fb511a35795b6de507e6a"},
+ {file = "regex-2025.11.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:856a25c73b697f2ce2a24e7968285579e62577a048526161a2c0f53090bea9f9"},
+ {file = "regex-2025.11.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a3d571bd95fade53c86c0517f859477ff3a93c3fde10c9e669086f038e0f207"},
+ {file = "regex-2025.11.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:732aea6de26051af97b94bc98ed86448821f839d058e5d259c72bf6d73ad0fc0"},
+ {file = "regex-2025.11.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:51c1c1847128238f54930edb8805b660305dca164645a9fd29243f5610beea34"},
+ {file = "regex-2025.11.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22dd622a402aad4558277305350699b2be14bc59f64d64ae1d928ce7d072dced"},
+ {file = "regex-2025.11.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f3b5a391c7597ffa96b41bd5cbd2ed0305f515fcbb367dfa72735679d5502364"},
+ {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:cc4076a5b4f36d849fd709284b4a3b112326652f3b0466f04002a6c15a0c96c1"},
+ {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:a295ca2bba5c1c885826ce3125fa0b9f702a1be547d821c01d65f199e10c01e2"},
+ {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b4774ff32f18e0504bfc4e59a3e71e18d83bc1e171a3c8ed75013958a03b2f14"},
+ {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22e7d1cdfa88ef33a2ae6aa0d707f9255eb286ffbd90045f1088246833223aee"},
+ {file = "regex-2025.11.3-cp39-cp39-win32.whl", hash = "sha256:74d04244852ff73b32eeede4f76f51c5bcf44bc3c207bc3e6cf1c5c45b890708"},
+ {file = "regex-2025.11.3-cp39-cp39-win_amd64.whl", hash = "sha256:7a50cd39f73faa34ec18d6720ee25ef10c4c1839514186fcda658a06c06057a2"},
+ {file = "regex-2025.11.3-cp39-cp39-win_arm64.whl", hash = "sha256:43b4fb020e779ca81c1b5255015fe2b82816c76ec982354534ad9ec09ad7c9e3"},
+ {file = "regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01"},
+]
+
[[package]]
name = "requests"
version = "2.32.4"
@@ -1351,6 +2131,25 @@ urllib3 = ">=1.21.1,<3"
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
+[[package]]
+name = "rich"
+version = "14.2.0"
+description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
+optional = false
+python-versions = ">=3.8.0"
+groups = ["dev"]
+files = [
+ {file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"},
+ {file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"},
+]
+
+[package.dependencies]
+markdown-it-py = ">=2.2.0"
+pygments = ">=2.13.0,<3.0.0"
+
+[package.extras]
+jupyter = ["ipywidgets (>=7.5.1,<9)"]
+
[[package]]
name = "rpds-py"
version = "0.28.0"
@@ -1476,6 +2275,165 @@ files = [
{file = "rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea"},
]
+[[package]]
+name = "ruamel-yaml"
+version = "0.18.17"
+description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "ruamel_yaml-0.18.17-py3-none-any.whl", hash = "sha256:9c8ba9eb3e793efdf924b60d521820869d5bf0cb9c6f1b82d82de8295e290b9d"},
+ {file = "ruamel_yaml-0.18.17.tar.gz", hash = "sha256:9091cd6e2d93a3a4b157ddb8fabf348c3de7f1fb1381346d985b6b247dcd8d3c"},
+]
+
+[package.dependencies]
+"ruamel.yaml.clib" = {version = ">=0.2.15", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.15\""}
+
+[package.extras]
+docs = ["mercurial (>5.7)", "ryd"]
+jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"]
+
+[[package]]
+name = "ruamel-yaml-clib"
+version = "0.2.15"
+description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+markers = "platform_python_implementation == \"CPython\" and python_version < \"3.15\""
+files = [
+ {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:88eea8baf72f0ccf232c22124d122a7f26e8a24110a0273d9bcddcb0f7e1fa03"},
+ {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b6f7d74d094d1f3a4e157278da97752f16ee230080ae331fcc219056ca54f77"},
+ {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4be366220090d7c3424ac2b71c90d1044ea34fca8c0b88f250064fd06087e614"},
+ {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f66f600833af58bea694d5892453f2270695b92200280ee8c625ec5a477eed3"},
+ {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da3d6adadcf55a93c214d23941aef4abfd45652110aed6580e814152f385b862"},
+ {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9fde97ecb7bb9c41261c2ce0da10323e9227555c674989f8d9eb7572fc2098d"},
+ {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:05c70f7f86be6f7bee53794d80050a28ae7e13e4a0087c1839dcdefd68eb36b6"},
+ {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f1d38cbe622039d111b69e9ca945e7e3efebb30ba998867908773183357f3ed"},
+ {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-win32.whl", hash = "sha256:fe239bdfdae2302e93bd6e8264bd9b71290218fff7084a9db250b55caaccf43f"},
+ {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-win_amd64.whl", hash = "sha256:468858e5cbde0198337e6a2a78eda8c3fb148bdf4c6498eaf4bc9ba3f8e780bd"},
+ {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c583229f336682b7212a43d2fa32c30e643d3076178fb9f7a6a14dde85a2d8bd"},
+ {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56ea19c157ed8c74b6be51b5fa1c3aff6e289a041575f0556f66e5fb848bb137"},
+ {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5fea0932358e18293407feb921d4f4457db837b67ec1837f87074667449f9401"},
+ {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71831bd61fbdb7aa0399d5c4da06bea37107ab5c79ff884cc07f2450910262"},
+ {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:617d35dc765715fa86f8c3ccdae1e4229055832c452d4ec20856136acc75053f"},
+ {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b45498cc81a4724a2d42273d6cfc243c0547ad7c6b87b4f774cb7bcc131c98d"},
+ {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:def5663361f6771b18646620fca12968aae730132e104688766cf8a3b1d65922"},
+ {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:014181cdec565c8745b7cbc4de3bf2cc8ced05183d986e6d1200168e5bb59490"},
+ {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-win32.whl", hash = "sha256:d290eda8f6ada19e1771b54e5706b8f9807e6bb08e873900d5ba114ced13e02c"},
+ {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-win_amd64.whl", hash = "sha256:bdc06ad71173b915167702f55d0f3f027fc61abd975bd308a0968c02db4a4c3e"},
+ {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff"},
+ {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2"},
+ {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1"},
+ {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60"},
+ {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9"},
+ {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642"},
+ {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690"},
+ {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a"},
+ {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144"},
+ {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc"},
+ {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb"},
+ {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471"},
+ {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25"},
+ {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a"},
+ {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf"},
+ {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d"},
+ {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf"},
+ {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51"},
+ {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-win32.whl", hash = "sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec"},
+ {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-win_amd64.whl", hash = "sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6"},
+ {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef"},
+ {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf"},
+ {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000"},
+ {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4"},
+ {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c"},
+ {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043"},
+ {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524"},
+ {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e"},
+ {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-win32.whl", hash = "sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa"},
+ {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-win_amd64.whl", hash = "sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467"},
+ {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:923816815974425fbb1f1bf57e85eca6e14d8adc313c66db21c094927ad01815"},
+ {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dcc7f3162d3711fd5d52e2267e44636e3e566d1e5675a5f0b30e98f2c4af7974"},
+ {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d3c9210219cbc0f22706f19b154c9a798ff65a6beeafbf77fc9c057ec806f7d"},
+ {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bb7b728fd9f405aa00b4a0b17ba3f3b810d0ccc5f77f7373162e9b5f0ff75d5"},
+ {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cb75a3c14f1d6c3c2a94631e362802f70e83e20d1f2b2ef3026c05b415c4900"},
+ {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:badd1d7283f3e5894779a6ea8944cc765138b96804496c91812b2829f70e18a7"},
+ {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0ba6604bbc3dfcef844631932d06a1a4dcac3fee904efccf582261948431628a"},
+ {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8220fd4c6f98485e97aea65e1df76d4fed1678ede1fe1d0eed2957230d287c4"},
+ {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-win32.whl", hash = "sha256:04d21dc9c57d9608225da28285900762befbb0165ae48482c15d8d4989d4af14"},
+ {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-win_amd64.whl", hash = "sha256:27dc656e84396e6d687f97c6e65fb284d100483628f02d95464fd731743a4afe"},
+ {file = "ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600"},
+]
+
+[[package]]
+name = "safety"
+version = "3.7.0"
+description = "Scan dependencies for known vulnerabilities and licenses."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "safety-3.7.0-py3-none-any.whl", hash = "sha256:65e71db45eb832e8840e3456333d44c23927423753d5610596a09e909a66d2bf"},
+ {file = "safety-3.7.0.tar.gz", hash = "sha256:daec15a393cafc32b846b7ef93f9c952a1708863e242341ab5bde2e4beabb54e"},
+]
+
+[package.dependencies]
+authlib = ">=1.2.0"
+click = ">=8.0.2"
+dparse = ">=0.6.4"
+filelock = ">=3.16.1,<4.0"
+httpx = "*"
+jinja2 = ">=3.1.0"
+marshmallow = ">=3.15.0"
+nltk = ">=3.9"
+packaging = ">=21.0"
+pydantic = ">=2.6.0"
+requests = "*"
+ruamel-yaml = ">=0.17.21"
+safety-schemas = "0.0.16"
+tenacity = ">=8.1.0"
+tomli = {version = "*", markers = "python_version < \"3.11\""}
+tomlkit = "*"
+typer = ">=0.16.0"
+typing-extensions = ">=4.7.1"
+
+[package.extras]
+github = ["pygithub (>=1.43.3)"]
+gitlab = ["python-gitlab (>=1.3.0)"]
+spdx = ["spdx-tools (>=0.8.2)"]
+
+[[package]]
+name = "safety-schemas"
+version = "0.0.16"
+description = "Schemas for Safety tools"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "safety_schemas-0.0.16-py3-none-any.whl", hash = "sha256:6760515d3fd1e6535b251cd73014bd431d12fe0bfb8b6e8880a9379b5ab7aa44"},
+ {file = "safety_schemas-0.0.16.tar.gz", hash = "sha256:3bb04d11bd4b5cc79f9fa183c658a6a8cf827a9ceec443a5ffa6eed38a50a24e"},
+]
+
+[package.dependencies]
+dparse = ">=0.6.4"
+packaging = ">=21.0"
+pydantic = ">=2.6.0"
+ruamel-yaml = ">=0.17.21"
+typing-extensions = ">=4.7.1"
+
+[[package]]
+name = "shellingham"
+version = "1.5.4"
+description = "Tool to Detect Surrounding Shell"
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"},
+ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
+]
+
[[package]]
name = "slowapi"
version = "0.1.9"
@@ -1506,6 +2464,18 @@ files = [
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
]
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
+optional = false
+python-versions = "*"
+groups = ["dev"]
+files = [
+ {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"},
+ {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
+]
+
[[package]]
name = "sse-starlette"
version = "2.3.6"
@@ -1545,6 +2515,34 @@ anyio = ">=3.6.2,<5"
[package.extras]
full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
+[[package]]
+name = "stevedore"
+version = "5.6.0"
+description = "Manage dynamic plugins for Python applications"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820"},
+ {file = "stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945"},
+]
+
+[[package]]
+name = "tenacity"
+version = "9.1.2"
+description = "Retry code until it succeeds"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"},
+ {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"},
+]
+
+[package.extras]
+doc = ["reno", "sphinx"]
+test = ["pytest", "tornado (>=4.5)", "typeguard"]
+
[[package]]
name = "tomli"
version = "2.2.1"
@@ -1552,7 +2550,7 @@ description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
-markers = "python_version == \"3.10\""
+markers = "python_full_version <= \"3.11.0a6\""
files = [
{file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
{file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"},
@@ -1588,6 +2586,18 @@ files = [
{file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"},
]
+[[package]]
+name = "tomlkit"
+version = "0.13.3"
+description = "Style preserving TOML library"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"},
+ {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"},
+]
+
[[package]]
name = "tqdm"
version = "4.67.1"
@@ -1610,6 +2620,24 @@ notebook = ["ipywidgets (>=6)"]
slack = ["slack-sdk"]
telegram = ["requests"]
+[[package]]
+name = "typer"
+version = "0.21.0"
+description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "typer-0.21.0-py3-none-any.whl", hash = "sha256:c79c01ca6b30af9fd48284058a7056ba0d3bf5cf10d0ff3d0c5b11b68c258ac6"},
+ {file = "typer-0.21.0.tar.gz", hash = "sha256:c87c0d2b6eee3b49c5c64649ec92425492c14488096dfbc8a0c2799b2f6f9c53"},
+]
+
+[package.dependencies]
+click = ">=8.0.0"
+rich = ">=10.11.0"
+shellingham = ">=1.3.0"
+typing-extensions = ">=3.7.4.3"
+
[[package]]
name = "typing-extensions"
version = "4.14.0"
@@ -2025,4 +3053,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = "^3.10"
-content-hash = "22f3aba55fc6dc8ea77aced440d59a91550236ad85d81aeeb9ba6f1b23a1233c"
+content-hash = "995cbb6b6bfbf14612eff7e0690ca47fc7b0c01fd2ef3351dea01d6940be0ed6"
diff --git a/pyproject.toml b/pyproject.toml
index c6cff36..ff00fda 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,6 +25,11 @@ pytest = "^8.0.0"
pytest-asyncio = "^0.23.0"
requests = "^2.32.0"
openai = "^1.0.0"
+pytest-cov = "^7.0.0"
+mypy = "^1.14.0"
+bandit = "^1.8.0"
+safety = "^3.2.0"
+hypothesis = "^6.122.0"
[build-system]
requires = ["poetry-core"]
diff --git a/src/__init__.py b/src/__init__.py
index 1d29cf2..e2b4f2d 100644
--- a/src/__init__.py
+++ b/src/__init__.py
@@ -1,3 +1,3 @@
"""Claude Code OpenAI Wrapper - A FastAPI-based OpenAI-compatible API for Claude Code."""
-__version__ = "1.0.0"
+__version__ = "2.1.0"
diff --git a/src/auth.py b/src/auth.py
index 2a9f43f..7b23e69 100644
--- a/src/auth.py
+++ b/src/auth.py
@@ -32,7 +32,34 @@ def get_api_key(self):
return self.env_api_key
def _detect_auth_method(self) -> str:
- """Detect which Claude Code authentication method is configured."""
+ """Detect which Claude Code authentication method is configured.
+
+ Priority:
+ 1. Explicit CLAUDE_AUTH_METHOD env var (cli, api_key, bedrock, vertex)
+ 2. Legacy env vars (CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX)
+ 3. Auto-detect based on ANTHROPIC_API_KEY presence
+ 4. Default to claude_cli
+ """
+ # Check for explicit auth method first
+ explicit_method = os.getenv("CLAUDE_AUTH_METHOD", "").lower()
+ if explicit_method:
+ method_map = {
+ "cli": "claude_cli",
+ "claude_cli": "claude_cli",
+ "api_key": "anthropic",
+ "anthropic": "anthropic",
+ "bedrock": "bedrock",
+ "vertex": "vertex",
+ }
+ if explicit_method in method_map:
+ logger.info(f"Using explicit auth method: {method_map[explicit_method]}")
+ return method_map[explicit_method]
+ else:
+ logger.warning(
+ f"Unknown CLAUDE_AUTH_METHOD '{explicit_method}', falling back to auto-detect"
+ )
+
+ # Fall back to legacy env vars and auto-detection
if os.getenv("CLAUDE_CODE_USE_BEDROCK") == "1":
return "bedrock"
elif os.getenv("CLAUDE_CODE_USE_VERTEX") == "1":
diff --git a/src/claude_cli.py b/src/claude_cli.py
index da5c648..d87057e 100644
--- a/src/claude_cli.py
+++ b/src/claude_cli.py
@@ -103,6 +103,7 @@ async def run_completion(
disallowed_tools: Optional[List[str]] = None,
session_id: Optional[str] = None,
continue_session: bool = False,
+ permission_mode: Optional[str] = None,
) -> AsyncGenerator[Dict[str, Any], None]:
"""Run Claude Agent using the Python SDK and yield response chunks."""
@@ -136,6 +137,10 @@ async def run_completion(
if disallowed_tools:
options.disallowed_tools = disallowed_tools
+ # Set permission mode (needed for tool execution in API context)
+ if permission_mode:
+ options.permission_mode = permission_mode
+
# Handle session continuity
if continue_session:
options.continue_session = True
@@ -188,7 +193,18 @@ async def run_completion(
}
def parse_claude_message(self, messages: List[Dict[str, Any]]) -> Optional[str]:
- """Extract the assistant message from Claude Agent SDK messages."""
+ """Extract the assistant message from Claude Agent SDK messages.
+
+ Prioritizes ResultMessage.result for multi-turn conversations,
+ falls back to last AssistantMessage content.
+ """
+ # First, check for ResultMessage with 'result' field (multi-turn completion)
+ for message in messages:
+ if message.get("subtype") == "success" and "result" in message:
+ return message["result"]
+
+ # Collect all text from AssistantMessages (take the last one with text)
+ last_text = None
for message in messages:
# Look for AssistantMessage type (new SDK format)
if "content" in message and isinstance(message["content"], list):
@@ -203,7 +219,7 @@ def parse_claude_message(self, messages: List[Dict[str, Any]]) -> Optional[str]:
text_parts.append(block)
if text_parts:
- return "\n".join(text_parts)
+ last_text = "\n".join(text_parts)
# Fallback: look for old format
elif message.get("type") == "assistant" and "message" in message:
@@ -216,11 +232,12 @@ def parse_claude_message(self, messages: List[Dict[str, Any]]) -> Optional[str]:
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text_parts.append(block.get("text", ""))
- return "\n".join(text_parts) if text_parts else None
+ if text_parts:
+ last_text = "\n".join(text_parts)
elif isinstance(content, str):
- return content
+ last_text = content
- return None
+ return last_text
def extract_metadata(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Extract metadata like costs, tokens, and session info from SDK messages."""
diff --git a/src/constants.py b/src/constants.py
index ed4ebfa..5fb452b 100644
--- a/src/constants.py
+++ b/src/constants.py
@@ -2,12 +2,33 @@
Constants and configuration for Claude Code OpenAI Wrapper.
Single source of truth for tool names, models, and other configuration values.
+
+Usage Examples:
+ # Check if a model is supported
+ from src.constants import CLAUDE_MODELS
+ if model_name in CLAUDE_MODELS:
+ # proceed with request
+
+ # Get default allowed tools
+ from src.constants import DEFAULT_ALLOWED_TOOLS
+ options = {"allowed_tools": DEFAULT_ALLOWED_TOOLS}
+
+ # Use rate limits in FastAPI
+ from src.constants import RATE_LIMIT_CHAT
+ @limiter.limit(f"{RATE_LIMIT_CHAT}/minute")
+ async def chat_endpoint(): ...
+
+Note:
+ - Tool configurations are managed by ToolManager (see tool_manager.py)
+ - Model validation uses graceful degradation (warns but allows unknown models)
+ - Rate limits can be overridden via environment variables
"""
import os
# Claude Agent SDK Tool Names
# These are the built-in tools available in the Claude Agent SDK
+# See: https://docs.anthropic.com/en/docs/claude-code/sdk
CLAUDE_TOOLS = [
"Task", # Launch agents for complex tasks
"Bash", # Execute bash commands
@@ -49,7 +70,7 @@
# NOTE: Claude Agent SDK only supports Claude 4+ models, not Claude 3.x
CLAUDE_MODELS = [
# Claude 4.5 Family (Latest - Fall 2025) - RECOMMENDED
- "claude-opus-4-5-20250929", # Latest Opus 4.5 - Most capable
+ "claude-opus-4-5-20250929", # Latest Opus 4.5 - Most capable
"claude-sonnet-4-5-20250929", # Recommended - best coding model
"claude-haiku-4-5-20251001", # Fast & cheap
# Claude 4.1
diff --git a/src/main.py b/src/main.py
index dba2405..4a74aa4 100644
--- a/src/main.py
+++ b/src/main.py
@@ -4,13 +4,14 @@
import logging
import secrets
import string
+import uuid
from typing import Optional, AsyncGenerator, Dict, Any
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request, Depends
from fastapi.security import HTTPAuthorizationCredentials
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import StreamingResponse, JSONResponse
+from fastapi.responses import StreamingResponse, JSONResponse, HTMLResponse
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError
from dotenv import load_dotenv
@@ -32,6 +33,11 @@
MCPServerInfoResponse,
MCPServersListResponse,
MCPConnectionRequest,
+ # Anthropic API compatible models
+ AnthropicMessagesRequest,
+ AnthropicMessagesResponse,
+ AnthropicTextBlock,
+ AnthropicUsage,
)
from src.claude_cli import ClaudeCodeCLI
from src.message_adapter import MessageAdapter
@@ -45,7 +51,7 @@
rate_limit_exceeded_handler,
rate_limit_endpoint,
)
-from src.constants import CLAUDE_MODELS, CLAUDE_TOOLS
+from src.constants import CLAUDE_MODELS, CLAUDE_TOOLS, DEFAULT_ALLOWED_TOOLS
# Load environment variables
load_dotenv()
@@ -215,23 +221,65 @@ async def lifespan(app: FastAPI):
app.state.limiter = limiter
app.add_exception_handler(429, rate_limit_exceeded_handler)
-# Add debug logging middleware
+# Security configuration
+MAX_REQUEST_SIZE = int(os.getenv("MAX_REQUEST_SIZE", str(10 * 1024 * 1024))) # 10MB default
+
+# Add middleware
from starlette.middleware.base import BaseHTTPMiddleware
+class RequestIDMiddleware(BaseHTTPMiddleware):
+ """Add unique request ID to each request for audit trails."""
+
+ async def dispatch(self, request: Request, call_next):
+ request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
+ request.state.request_id = request_id
+
+ response = await call_next(request)
+ response.headers["X-Request-ID"] = request_id
+ return response
+
+
+class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
+ """Limit request body size to prevent DoS attacks."""
+
+ async def dispatch(self, request: Request, call_next):
+ content_length = request.headers.get("content-length")
+ if content_length and int(content_length) > MAX_REQUEST_SIZE:
+ return JSONResponse(
+ status_code=413,
+ content={
+ "error": {
+ "message": f"Request body too large. Maximum size is {MAX_REQUEST_SIZE} bytes.",
+ "type": "request_too_large",
+ "code": 413,
+ }
+ },
+ )
+ return await call_next(request)
+
+
+# Add security middleware (order matters - first added = last executed)
+app.add_middleware(RequestIDMiddleware)
+app.add_middleware(RequestSizeLimitMiddleware)
+
+
class DebugLoggingMiddleware(BaseHTTPMiddleware):
"""ASGI-compliant middleware for logging request/response details when debug mode is enabled."""
async def dispatch(self, request: Request, call_next):
+ # Get request ID for correlation
+ request_id = getattr(request.state, "request_id", "unknown")
+
if not (DEBUG_MODE or VERBOSE):
return await call_next(request)
# Log request details
start_time = asyncio.get_event_loop().time()
- # Log basic request info
- logger.debug(f"๐ Incoming request: {request.method} {request.url}")
- logger.debug(f"๐ Headers: {dict(request.headers)}")
+ # Log basic request info with request ID for correlation
+ logger.debug(f"๐ [{request_id}] Incoming request: {request.method} {request.url}")
+ logger.debug(f"๐ [{request_id}] Headers: {dict(request.headers)}")
# For POST requests, try to log body (but don't break if we can't)
body_logged = False
@@ -385,7 +433,11 @@ async def generate_streaming_response(
claude_options["max_turns"] = 1 # Single turn for Q&A
logger.info("Tools disabled (default behavior for OpenAI compatibility)")
else:
- logger.info("Tools enabled by user request")
+ # Enable tools - use default safe subset (Read, Glob, Grep, Bash, Write, Edit)
+ claude_options["allowed_tools"] = DEFAULT_ALLOWED_TOOLS
+ # Set permission mode to bypass prompts (required for API/headless usage)
+ claude_options["permission_mode"] = "bypassPermissions"
+ logger.info(f"Tools enabled by user request: {DEFAULT_ALLOWED_TOOLS}")
# Run Claude Code
chunks_buffer = []
@@ -399,6 +451,7 @@ async def generate_streaming_response(
max_turns=claude_options.get("max_turns", 10),
allowed_tools=claude_options.get("allowed_tools"),
disallowed_tools=claude_options.get("disallowed_tools"),
+ permission_mode=claude_options.get("permission_mode"),
stream=True,
):
chunks_buffer.append(chunk)
@@ -642,7 +695,11 @@ async def chat_completions(
claude_options["max_turns"] = 1 # Single turn for Q&A
logger.info("Tools disabled (default behavior for OpenAI compatibility)")
else:
- logger.info("Tools enabled by user request")
+ # Enable tools - use default safe subset (Read, Glob, Grep, Bash, Write, Edit)
+ claude_options["allowed_tools"] = DEFAULT_ALLOWED_TOOLS
+ # Set permission mode to bypass prompts (required for API/headless usage)
+ claude_options["permission_mode"] = "bypassPermissions"
+ logger.info(f"Tools enabled by user request: {DEFAULT_ALLOWED_TOOLS}")
# Collect all chunks
chunks = []
@@ -653,6 +710,7 @@ async def chat_completions(
max_turns=claude_options.get("max_turns", 10),
allowed_tools=claude_options.get("allowed_tools"),
disallowed_tools=claude_options.get("disallowed_tools"),
+ permission_mode=claude_options.get("permission_mode"),
stream=False,
):
chunks.append(chunk)
@@ -702,6 +760,102 @@ async def chat_completions(
raise HTTPException(status_code=500, detail=str(e))
+@app.post("/v1/messages")
+@rate_limit_endpoint("chat")
+async def anthropic_messages(
+ request_body: AnthropicMessagesRequest,
+ request: Request,
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
+):
+ """Anthropic Messages API compatible endpoint.
+
+ This endpoint provides compatibility with the native Anthropic SDK,
+ allowing tools like VC to use this wrapper via the VC_API_BASE setting.
+ """
+ # Check FastAPI API key if configured
+ await verify_api_key(request, credentials)
+
+ # Validate Claude Code authentication
+ auth_valid, auth_info = validate_claude_code_auth()
+
+ if not auth_valid:
+ error_detail = {
+ "message": "Claude Code authentication failed",
+ "errors": auth_info.get("errors", []),
+ "method": auth_info.get("method", "none"),
+ "help": "Check /v1/auth/status for detailed authentication information",
+ }
+ raise HTTPException(status_code=503, detail=error_detail)
+
+ try:
+ logger.info(f"Anthropic Messages API request: model={request_body.model}")
+
+ # Convert Anthropic messages to internal format
+ messages = request_body.to_openai_messages()
+
+ # Build prompt from messages
+ prompt_parts = []
+ for msg in messages:
+ if msg.role == "user":
+ prompt_parts.append(msg.content)
+ elif msg.role == "assistant":
+ prompt_parts.append(f"Assistant: {msg.content}")
+
+ prompt = "\n\n".join(prompt_parts)
+ system_prompt = request_body.system
+
+ # Filter content
+ prompt = MessageAdapter.filter_content(prompt)
+ if system_prompt:
+ system_prompt = MessageAdapter.filter_content(system_prompt)
+
+ # Run Claude Code - tools enabled by default for Anthropic SDK clients
+ # (they're typically using this for agentic workflows)
+ chunks = []
+ async for chunk in claude_cli.run_completion(
+ prompt=prompt,
+ system_prompt=system_prompt,
+ model=request_body.model,
+ max_turns=10,
+ allowed_tools=DEFAULT_ALLOWED_TOOLS,
+ permission_mode="bypassPermissions",
+ stream=False,
+ ):
+ chunks.append(chunk)
+
+ # Extract assistant message
+ raw_assistant_content = claude_cli.parse_claude_message(chunks)
+
+ if not raw_assistant_content:
+ raise HTTPException(status_code=500, detail="No response from Claude Code")
+
+ # Filter out tool usage and thinking blocks
+ assistant_content = MessageAdapter.filter_content(raw_assistant_content)
+
+ # Estimate tokens
+ prompt_tokens = MessageAdapter.estimate_tokens(prompt)
+ completion_tokens = MessageAdapter.estimate_tokens(assistant_content)
+
+ # Create Anthropic-format response
+ response = AnthropicMessagesResponse(
+ model=request_body.model,
+ content=[AnthropicTextBlock(text=assistant_content)],
+ stop_reason="end_turn",
+ usage=AnthropicUsage(
+ input_tokens=prompt_tokens,
+ output_tokens=completion_tokens,
+ ),
+ )
+
+ return response
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Anthropic Messages API error: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
@app.get("/v1/models")
async def list_models(
request: Request, credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
@@ -757,6 +911,626 @@ async def health_check(request: Request):
return {"status": "healthy", "service": "claude-code-openai-wrapper"}
+@app.get("/version")
+@rate_limit_endpoint("health")
+async def version_info(request: Request):
+ """Version information endpoint."""
+ from src import __version__
+
+ return {
+ "version": __version__,
+ "service": "claude-code-openai-wrapper",
+ "api_version": "v1",
+ }
+
+
+@app.get("/", response_class=HTMLResponse)
+async def root():
+ """Landing page with API documentation."""
+ from src import __version__
+
+ auth_info = get_claude_code_auth_info()
+ auth_method = auth_info.get("method", "unknown")
+ auth_valid = auth_info.get("status", {}).get("valid", False)
+ status_color = "#22c55e" if auth_valid else "#ef4444"
+ status_text = "Connected" if auth_valid else "Not Connected"
+
+ html_content = f"""
+
+
+
+
+
+
+ Claude Code OpenAI Wrapper
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {status_text}
+
+
Auth: {auth_method}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ POST
+ /v1/chat/completions
+ OpenAI-compatible chat
+
+
+ POST
+ /v1/messages
+ Anthropic-compatible
+
+
+
+
+
+ GET
+ /v1/models
+ List models
+
+
+
+
+
+
+ GET
+ /v1/auth/status
+ Auth status
+
+
+
+
+
+
+ GET
+ /v1/sessions
+ Active sessions
+
+
+
+
+
+
+ GET
+ /health
+ Health check
+
+
+
+
+
+
+ GET
+ /version
+ API version
+
+
+
+
+
+
+
+
+ Set CLAUDE_AUTH_METHOD to choose authentication:
+
+
+
cli
+
Claude CLI auth
+
+
+
api_key
+
ANTHROPIC_API_KEY
+
+
+
bedrock
+
AWS Bedrock
+
+
+
vertex
+
Google Vertex AI
+
+
+
+
+
+
+
+
+
+ """
+ return HTMLResponse(content=html_content)
+
+
@app.post("/v1/debug/request")
@rate_limit_endpoint("debug")
async def debug_request_validation(request: Request):
@@ -1165,7 +1939,7 @@ def find_available_port(start_port: int = 8000, max_attempts: int = 10) -> int:
)
-def run_server(port: int = None):
+def run_server(port: int = None, host: str = None):
"""Run the server - used as Poetry script entry point."""
import uvicorn
@@ -1176,11 +1950,15 @@ def run_server(port: int = None):
# Priority: CLI arg > ENV var > default
if port is None:
port = int(os.getenv("PORT", "8000"))
+ if host is None:
+ # Default to 0.0.0.0 for container/development use (configurable via CLAUDE_WRAPPER_HOST env)
+ host = os.getenv("CLAUDE_WRAPPER_HOST", "0.0.0.0") # nosec B104
preferred_port = port
try:
# Try the preferred port first
- uvicorn.run(app, host="0.0.0.0", port=preferred_port)
+ # Binding to 0.0.0.0 is intentional for container/development use
+ uvicorn.run(app, host=host, port=preferred_port) # nosec B104
except OSError as e:
if "Address already in use" in str(e) or e.errno == 48:
logger.warning(f"Port {preferred_port} is already in use. Finding alternative port...")
@@ -1189,7 +1967,8 @@ def run_server(port: int = None):
logger.info(f"Starting server on alternative port {available_port}")
print(f"\n๐ Server starting on http://localhost:{available_port}")
print(f"๐ Update your client base_url to: http://localhost:{available_port}/v1")
- uvicorn.run(app, host="0.0.0.0", port=available_port)
+ # Binding to 0.0.0.0 is intentional for container/development use
+ uvicorn.run(app, host=host, port=available_port) # nosec B104
except RuntimeError as port_error:
logger.error(f"Could not find available port: {port_error}")
print(f"\nโ Error: {port_error}")
diff --git a/src/models.py b/src/models.py
index 3738481..82e85f4 100644
--- a/src/models.py
+++ b/src/models.py
@@ -6,10 +6,12 @@
logger = logging.getLogger(__name__)
+
# Import DEFAULT_MODEL to avoid circular imports
def get_default_model():
"""Get default model from constants to avoid circular imports."""
from src.constants import DEFAULT_MODEL
+
return DEFAULT_MODEL
@@ -407,3 +409,71 @@ def validate_tool_name(cls, v: str) -> str:
if len(v) > 200:
raise ValueError("Tool name too long (max 200 characters)")
return v.strip()
+
+
+# ============================================================================
+# Anthropic API Compatible Models (for /v1/messages endpoint)
+# ============================================================================
+
+
+class AnthropicTextBlock(BaseModel):
+ """Anthropic text content block."""
+
+ type: Literal["text"] = "text"
+ text: str
+
+
+class AnthropicMessage(BaseModel):
+ """Anthropic message format."""
+
+ role: Literal["user", "assistant"]
+ content: Union[str, List[AnthropicTextBlock]]
+
+
+class AnthropicMessagesRequest(BaseModel):
+ """Anthropic Messages API request format."""
+
+ model: str
+ messages: List[AnthropicMessage]
+ max_tokens: int = Field(default=4096, description="Maximum tokens to generate")
+ system: Optional[str] = Field(default=None, description="System prompt")
+ temperature: Optional[float] = Field(default=1.0, ge=0, le=1)
+ top_p: Optional[float] = Field(default=None, ge=0, le=1)
+ top_k: Optional[int] = Field(default=None, ge=0)
+ stop_sequences: Optional[List[str]] = None
+ stream: Optional[bool] = False
+ metadata: Optional[Dict[str, Any]] = None
+
+ def to_openai_messages(self) -> List[Message]:
+ """Convert Anthropic messages to OpenAI format."""
+ result = []
+ for msg in self.messages:
+ content = msg.content
+ if isinstance(content, list):
+ # Extract text from content blocks
+ text_parts = [
+ block.text for block in content if isinstance(block, AnthropicTextBlock)
+ ]
+ content = "\n".join(text_parts)
+ result.append(Message(role=msg.role, content=content))
+ return result
+
+
+class AnthropicUsage(BaseModel):
+ """Anthropic usage information."""
+
+ input_tokens: int
+ output_tokens: int
+
+
+class AnthropicMessagesResponse(BaseModel):
+ """Anthropic Messages API response format."""
+
+ id: str = Field(default_factory=lambda: f"msg_{uuid.uuid4().hex[:24]}")
+ type: Literal["message"] = "message"
+ role: Literal["assistant"] = "assistant"
+ content: List[AnthropicTextBlock]
+ model: str
+ stop_reason: Optional[Literal["end_turn", "max_tokens", "stop_sequence"]] = "end_turn"
+ stop_sequence: Optional[str] = None
+ usage: AnthropicUsage
diff --git a/src/parameter_validator.py b/src/parameter_validator.py
index 06bbb43..e45452f 100644
--- a/src/parameter_validator.py
+++ b/src/parameter_validator.py
@@ -17,7 +17,7 @@ class ParameterValidator:
SUPPORTED_MODELS = set(CLAUDE_MODELS)
# Valid permission modes for Claude Code SDK
- VALID_PERMISSION_MODES = {"default", "acceptEdits", "bypassPermissions"}
+ VALID_PERMISSION_MODES = {"default", "acceptEdits", "bypassPermissions", "plan"}
@classmethod
def validate_model(cls, model: str) -> bool:
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..d5ab386
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,23 @@
+"""
+Pytest configuration and fixtures for claude-code-openai-wrapper tests.
+"""
+
+import pytest
+import requests
+
+
+# Check if server is running for integration tests
+def is_server_running(base_url: str = "http://localhost:8000") -> bool:
+ """Check if the test server is running."""
+ try:
+ response = requests.get(f"{base_url}/health", timeout=2)
+ return response.status_code == 200
+ except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
+ return False
+
+
+# Marker for tests that require a running server
+requires_server = pytest.mark.skipif(
+ not is_server_running(),
+ reason="Server not running at localhost:8000. Start with: poetry run python main.py",
+)
diff --git a/tests/test_anthropic_messages.py b/tests/test_anthropic_messages.py
new file mode 100644
index 0000000..1f8d303
--- /dev/null
+++ b/tests/test_anthropic_messages.py
@@ -0,0 +1,215 @@
+#!/usr/bin/env python3
+"""
+Tests for the Anthropic Messages API compatible endpoint (/v1/messages).
+
+This endpoint provides compatibility with the native Anthropic SDK,
+enabling tools like VC to use this wrapper via custom base URL configuration.
+"""
+
+import pytest
+import requests
+
+from tests.conftest import requires_server
+
+BASE_URL = "http://localhost:8000"
+
+
+class TestAnthropicMessagesModels:
+ """Test Anthropic API model classes."""
+
+ def test_anthropic_text_block(self):
+ """Test AnthropicTextBlock model."""
+ from src.models import AnthropicTextBlock
+
+ block = AnthropicTextBlock(text="Hello world")
+ assert block.type == "text"
+ assert block.text == "Hello world"
+
+ def test_anthropic_message(self):
+ """Test AnthropicMessage model."""
+ from src.models import AnthropicMessage
+
+ # String content
+ msg = AnthropicMessage(role="user", content="Hello")
+ assert msg.role == "user"
+ assert msg.content == "Hello"
+
+ # List content
+ from src.models import AnthropicTextBlock
+
+ msg2 = AnthropicMessage(role="assistant", content=[AnthropicTextBlock(text="Hi there")])
+ assert msg2.role == "assistant"
+ assert len(msg2.content) == 1
+
+ def test_anthropic_messages_request(self):
+ """Test AnthropicMessagesRequest model."""
+ from src.models import AnthropicMessagesRequest, AnthropicMessage
+
+ request = AnthropicMessagesRequest(
+ model="claude-sonnet-4-5-20250929",
+ messages=[AnthropicMessage(role="user", content="Hello")],
+ max_tokens=100,
+ system="You are helpful",
+ )
+
+ assert request.model == "claude-sonnet-4-5-20250929"
+ assert len(request.messages) == 1
+ assert request.max_tokens == 100
+ assert request.system == "You are helpful"
+
+ def test_anthropic_messages_request_to_openai(self):
+ """Test conversion from Anthropic to OpenAI message format."""
+ from src.models import AnthropicMessagesRequest, AnthropicMessage
+
+ request = AnthropicMessagesRequest(
+ model="claude-sonnet-4-5-20250929",
+ messages=[
+ AnthropicMessage(role="user", content="Hello"),
+ AnthropicMessage(role="assistant", content="Hi there"),
+ AnthropicMessage(role="user", content="How are you?"),
+ ],
+ )
+
+ openai_messages = request.to_openai_messages()
+ assert len(openai_messages) == 3
+ assert openai_messages[0].role == "user"
+ assert openai_messages[0].content == "Hello"
+ assert openai_messages[1].role == "assistant"
+ assert openai_messages[2].content == "How are you?"
+
+ def test_anthropic_messages_response(self):
+ """Test AnthropicMessagesResponse model."""
+ from src.models import (
+ AnthropicMessagesResponse,
+ AnthropicTextBlock,
+ AnthropicUsage,
+ )
+
+ response = AnthropicMessagesResponse(
+ model="claude-sonnet-4-5-20250929",
+ content=[AnthropicTextBlock(text="Hello!")],
+ usage=AnthropicUsage(input_tokens=10, output_tokens=5),
+ )
+
+ assert response.type == "message"
+ assert response.role == "assistant"
+ assert response.model == "claude-sonnet-4-5-20250929"
+ assert len(response.content) == 1
+ assert response.content[0].text == "Hello!"
+ assert response.stop_reason == "end_turn"
+ assert response.usage.input_tokens == 10
+ assert response.usage.output_tokens == 5
+
+
+class TestAnthropicMessagesEndpoint:
+ """Integration tests for /v1/messages endpoint."""
+
+ @requires_server
+ def test_basic_message(self):
+ """Test basic message request."""
+ response = requests.post(
+ f"{BASE_URL}/v1/messages",
+ json={
+ "model": "claude-sonnet-4-5-20250929",
+ "max_tokens": 50,
+ "messages": [{"role": "user", "content": "Say 'test' and nothing else"}],
+ },
+ )
+
+ assert response.status_code == 200
+ result = response.json()
+
+ # Verify Anthropic response format
+ assert result["type"] == "message"
+ assert result["role"] == "assistant"
+ assert "content" in result
+ assert len(result["content"]) > 0
+ assert result["content"][0]["type"] == "text"
+ assert "usage" in result
+ assert "input_tokens" in result["usage"]
+ assert "output_tokens" in result["usage"]
+
+ @requires_server
+ def test_message_with_system_prompt(self):
+ """Test message with system prompt."""
+ response = requests.post(
+ f"{BASE_URL}/v1/messages",
+ json={
+ "model": "claude-sonnet-4-5-20250929",
+ "max_tokens": 50,
+ "system": "You always respond with exactly one word.",
+ "messages": [{"role": "user", "content": "Say hello"}],
+ },
+ )
+
+ assert response.status_code == 200
+ result = response.json()
+ assert result["type"] == "message"
+ assert len(result["content"]) > 0
+
+ @requires_server
+ def test_multi_turn_conversation(self):
+ """Test multi-turn conversation."""
+ response = requests.post(
+ f"{BASE_URL}/v1/messages",
+ json={
+ "model": "claude-sonnet-4-5-20250929",
+ "max_tokens": 100,
+ "messages": [
+ {"role": "user", "content": "My name is Alice."},
+ {"role": "assistant", "content": "Hello Alice!"},
+ {"role": "user", "content": "What's my name?"},
+ ],
+ },
+ )
+
+ assert response.status_code == 200
+ result = response.json()
+ assert result["type"] == "message"
+ # The response should reference Alice
+ response_text = result["content"][0]["text"].lower()
+ assert "alice" in response_text
+
+ @requires_server
+ def test_invalid_request_missing_messages(self):
+ """Test error handling for missing messages."""
+ response = requests.post(
+ f"{BASE_URL}/v1/messages",
+ json={
+ "model": "claude-sonnet-4-5-20250929",
+ "max_tokens": 50,
+ # Missing 'messages' field
+ },
+ )
+
+ assert response.status_code == 422 # Validation error
+
+ @requires_server
+ def test_response_format_matches_anthropic_sdk(self):
+ """Test that response format matches what Anthropic SDK expects."""
+ response = requests.post(
+ f"{BASE_URL}/v1/messages",
+ json={
+ "model": "claude-sonnet-4-5-20250929",
+ "max_tokens": 50,
+ "messages": [{"role": "user", "content": "Hi"}],
+ },
+ )
+
+ assert response.status_code == 200
+ result = response.json()
+
+ # Required fields for Anthropic SDK compatibility
+ assert "id" in result
+ assert result["id"].startswith("msg_")
+ assert result["type"] == "message"
+ assert result["role"] == "assistant"
+ assert isinstance(result["content"], list)
+ assert result["stop_reason"] in ["end_turn", "max_tokens", "stop_sequence"]
+ assert "usage" in result
+ assert "input_tokens" in result["usage"]
+ assert "output_tokens" in result["usage"]
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/tests/test_auth_unit.py b/tests/test_auth_unit.py
new file mode 100644
index 0000000..ba9ec92
--- /dev/null
+++ b/tests/test_auth_unit.py
@@ -0,0 +1,502 @@
+#!/usr/bin/env python3
+"""
+Unit tests for src/auth.py
+
+Tests the ClaudeCodeAuthManager and authentication functions.
+These are pure unit tests that don't require a running server.
+"""
+
+import pytest
+import os
+from unittest.mock import MagicMock, patch, AsyncMock
+from fastapi import HTTPException
+
+# We need to patch environment before importing auth module
+import importlib
+
+
+class TestClaudeCodeAuthManagerDetectMethod:
+ """Test _detect_auth_method()"""
+
+ def test_explicit_cli_method(self):
+ """CLAUDE_AUTH_METHOD=cli uses claude_cli."""
+ with patch.dict(os.environ, {"CLAUDE_AUTH_METHOD": "cli"}, clear=False):
+ import src.auth
+
+ importlib.reload(src.auth)
+ assert src.auth.auth_manager.auth_method == "claude_cli"
+
+ def test_explicit_claude_cli_method(self):
+ """CLAUDE_AUTH_METHOD=claude_cli uses claude_cli."""
+ with patch.dict(os.environ, {"CLAUDE_AUTH_METHOD": "claude_cli"}, clear=False):
+ import src.auth
+
+ importlib.reload(src.auth)
+ assert src.auth.auth_manager.auth_method == "claude_cli"
+
+ def test_explicit_api_key_method(self):
+ """CLAUDE_AUTH_METHOD=api_key uses anthropic."""
+ with patch.dict(
+ os.environ,
+ {"CLAUDE_AUTH_METHOD": "api_key", "ANTHROPIC_API_KEY": "test-key-12345"},
+ clear=False,
+ ):
+ import src.auth
+
+ importlib.reload(src.auth)
+ assert src.auth.auth_manager.auth_method == "anthropic"
+
+ def test_explicit_anthropic_method(self):
+ """CLAUDE_AUTH_METHOD=anthropic uses anthropic."""
+ with patch.dict(
+ os.environ,
+ {"CLAUDE_AUTH_METHOD": "anthropic", "ANTHROPIC_API_KEY": "test-key-12345"},
+ clear=False,
+ ):
+ import src.auth
+
+ importlib.reload(src.auth)
+ assert src.auth.auth_manager.auth_method == "anthropic"
+
+ def test_explicit_bedrock_method(self):
+ """CLAUDE_AUTH_METHOD=bedrock uses bedrock."""
+ with patch.dict(os.environ, {"CLAUDE_AUTH_METHOD": "bedrock"}, clear=False):
+ import src.auth
+
+ importlib.reload(src.auth)
+ assert src.auth.auth_manager.auth_method == "bedrock"
+
+ def test_explicit_vertex_method(self):
+ """CLAUDE_AUTH_METHOD=vertex uses vertex."""
+ with patch.dict(os.environ, {"CLAUDE_AUTH_METHOD": "vertex"}, clear=False):
+ import src.auth
+
+ importlib.reload(src.auth)
+ assert src.auth.auth_manager.auth_method == "vertex"
+
+ def test_unknown_method_falls_back(self):
+ """Unknown CLAUDE_AUTH_METHOD falls back to auto-detect."""
+ with patch.dict(os.environ, {"CLAUDE_AUTH_METHOD": "unknown_method"}, clear=False):
+ import src.auth
+
+ importlib.reload(src.auth)
+ # Should fall back to claude_cli (default)
+ assert src.auth.auth_manager.auth_method in [
+ "claude_cli",
+ "anthropic",
+ "bedrock",
+ "vertex",
+ ]
+
+ def test_legacy_bedrock_env_var(self):
+ """CLAUDE_CODE_USE_BEDROCK=1 uses bedrock."""
+ env = {"CLAUDE_CODE_USE_BEDROCK": "1"}
+ # Remove CLAUDE_AUTH_METHOD if present
+ env_copy = {k: v for k, v in os.environ.items() if k != "CLAUDE_AUTH_METHOD"}
+ env_copy.update(env)
+ with patch.dict(os.environ, env_copy, clear=True):
+ import src.auth
+
+ importlib.reload(src.auth)
+ assert src.auth.auth_manager.auth_method == "bedrock"
+
+ def test_legacy_vertex_env_var(self):
+ """CLAUDE_CODE_USE_VERTEX=1 uses vertex."""
+ env = {"CLAUDE_CODE_USE_VERTEX": "1"}
+ env_copy = {k: v for k, v in os.environ.items() if k != "CLAUDE_AUTH_METHOD"}
+ env_copy.update(env)
+ with patch.dict(os.environ, env_copy, clear=True):
+ import src.auth
+
+ importlib.reload(src.auth)
+ assert src.auth.auth_manager.auth_method == "vertex"
+
+ def test_auto_detect_anthropic_key(self):
+ """ANTHROPIC_API_KEY auto-detects to anthropic."""
+ env = {"ANTHROPIC_API_KEY": "test-key-12345678901234567890"}
+ env_copy = {
+ k: v
+ for k, v in os.environ.items()
+ if k not in ["CLAUDE_AUTH_METHOD", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX"]
+ }
+ env_copy.update(env)
+ with patch.dict(os.environ, env_copy, clear=True):
+ import src.auth
+
+ importlib.reload(src.auth)
+ assert src.auth.auth_manager.auth_method == "anthropic"
+
+ def test_default_to_claude_cli(self):
+ """No env vars defaults to claude_cli."""
+ env_copy = {
+ k: v
+ for k, v in os.environ.items()
+ if k
+ not in [
+ "CLAUDE_AUTH_METHOD",
+ "CLAUDE_CODE_USE_BEDROCK",
+ "CLAUDE_CODE_USE_VERTEX",
+ "ANTHROPIC_API_KEY",
+ ]
+ }
+ with patch.dict(os.environ, env_copy, clear=True):
+ import src.auth
+
+ importlib.reload(src.auth)
+ assert src.auth.auth_manager.auth_method == "claude_cli"
+
+
+class TestClaudeCodeAuthManagerValidation:
+ """Test authentication validation methods."""
+
+ def test_validate_anthropic_valid(self):
+ """Valid ANTHROPIC_API_KEY passes validation."""
+ with patch.dict(
+ os.environ,
+ {
+ "CLAUDE_AUTH_METHOD": "anthropic",
+ "ANTHROPIC_API_KEY": "sk-ant-api03-validkey1234567890",
+ },
+ ):
+ import src.auth
+
+ importlib.reload(src.auth)
+ status = src.auth.auth_manager.auth_status
+ assert status["valid"] is True
+ assert status["method"] == "anthropic"
+
+ def test_validate_anthropic_missing_key(self):
+ """Missing ANTHROPIC_API_KEY fails validation."""
+ env_copy = {k: v for k, v in os.environ.items() if k != "ANTHROPIC_API_KEY"}
+ env_copy["CLAUDE_AUTH_METHOD"] = "anthropic"
+ with patch.dict(os.environ, env_copy, clear=True):
+ import src.auth
+
+ importlib.reload(src.auth)
+ status = src.auth.auth_manager.auth_status
+ assert status["valid"] is False
+ assert any("ANTHROPIC_API_KEY" in err for err in status["errors"])
+
+ def test_validate_anthropic_short_key(self):
+ """Too short ANTHROPIC_API_KEY fails validation."""
+ with patch.dict(
+ os.environ,
+ {"CLAUDE_AUTH_METHOD": "anthropic", "ANTHROPIC_API_KEY": "short"},
+ ):
+ import src.auth
+
+ importlib.reload(src.auth)
+ status = src.auth.auth_manager.auth_status
+ assert status["valid"] is False
+ assert any("too short" in err for err in status["errors"])
+
+ def test_validate_bedrock_valid(self):
+ """Valid Bedrock config passes validation."""
+ with patch.dict(
+ os.environ,
+ {
+ "CLAUDE_AUTH_METHOD": "bedrock",
+ "CLAUDE_CODE_USE_BEDROCK": "1",
+ "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE",
+ "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ "AWS_REGION": "us-east-1",
+ },
+ ):
+ import src.auth
+
+ importlib.reload(src.auth)
+ status = src.auth.auth_manager.auth_status
+ assert status["valid"] is True
+ assert status["method"] == "bedrock"
+
+ def test_validate_bedrock_missing_credentials(self):
+ """Missing AWS credentials fails Bedrock validation."""
+ env_copy = {
+ k: v
+ for k, v in os.environ.items()
+ if k not in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"]
+ }
+ env_copy["CLAUDE_AUTH_METHOD"] = "bedrock"
+ env_copy["CLAUDE_CODE_USE_BEDROCK"] = "1"
+ with patch.dict(os.environ, env_copy, clear=True):
+ import src.auth
+
+ importlib.reload(src.auth)
+ status = src.auth.auth_manager.auth_status
+ assert status["valid"] is False
+ assert len(status["errors"]) > 0
+
+ def test_validate_vertex_valid(self):
+ """Valid Vertex config passes validation."""
+ with patch.dict(
+ os.environ,
+ {
+ "CLAUDE_AUTH_METHOD": "vertex",
+ "CLAUDE_CODE_USE_VERTEX": "1",
+ "ANTHROPIC_VERTEX_PROJECT_ID": "my-project-123",
+ "CLOUD_ML_REGION": "us-central1",
+ },
+ ):
+ import src.auth
+
+ importlib.reload(src.auth)
+ status = src.auth.auth_manager.auth_status
+ assert status["valid"] is True
+ assert status["method"] == "vertex"
+
+ def test_validate_vertex_missing_config(self):
+ """Missing Vertex config fails validation."""
+ env_copy = {
+ k: v
+ for k, v in os.environ.items()
+ if k not in ["ANTHROPIC_VERTEX_PROJECT_ID", "CLOUD_ML_REGION"]
+ }
+ env_copy["CLAUDE_AUTH_METHOD"] = "vertex"
+ env_copy["CLAUDE_CODE_USE_VERTEX"] = "1"
+ with patch.dict(os.environ, env_copy, clear=True):
+ import src.auth
+
+ importlib.reload(src.auth)
+ status = src.auth.auth_manager.auth_status
+ assert status["valid"] is False
+
+ def test_validate_claude_cli_always_valid(self):
+ """Claude CLI auth is always considered valid initially."""
+ env_copy = {k: v for k, v in os.environ.items() if k != "CLAUDE_AUTH_METHOD"}
+ env_copy["CLAUDE_AUTH_METHOD"] = "cli"
+ with patch.dict(os.environ, env_copy, clear=True):
+ import src.auth
+
+ importlib.reload(src.auth)
+ status = src.auth.auth_manager.auth_status
+ assert status["valid"] is True
+ assert status["method"] == "claude_cli"
+
+
+class TestClaudeCodeAuthManagerEnvVars:
+ """Test get_claude_code_env_vars()"""
+
+ def test_anthropic_env_vars(self):
+ """Anthropic method returns ANTHROPIC_API_KEY."""
+ with patch.dict(
+ os.environ,
+ {
+ "CLAUDE_AUTH_METHOD": "anthropic",
+ "ANTHROPIC_API_KEY": "test-key-12345",
+ },
+ ):
+ import src.auth
+
+ importlib.reload(src.auth)
+ env_vars = src.auth.auth_manager.get_claude_code_env_vars()
+ assert "ANTHROPIC_API_KEY" in env_vars
+ assert env_vars["ANTHROPIC_API_KEY"] == "test-key-12345"
+
+ def test_bedrock_env_vars(self):
+ """Bedrock method returns AWS credentials."""
+ with patch.dict(
+ os.environ,
+ {
+ "CLAUDE_AUTH_METHOD": "bedrock",
+ "CLAUDE_CODE_USE_BEDROCK": "1",
+ "AWS_ACCESS_KEY_ID": "AKIATEST",
+ "AWS_SECRET_ACCESS_KEY": "secretkey",
+ "AWS_REGION": "us-east-1",
+ },
+ ):
+ import src.auth
+
+ importlib.reload(src.auth)
+ env_vars = src.auth.auth_manager.get_claude_code_env_vars()
+ assert env_vars.get("CLAUDE_CODE_USE_BEDROCK") == "1"
+ assert "AWS_ACCESS_KEY_ID" in env_vars
+ assert "AWS_SECRET_ACCESS_KEY" in env_vars
+ assert "AWS_REGION" in env_vars
+
+ def test_vertex_env_vars(self):
+ """Vertex method returns Google Cloud credentials."""
+ with patch.dict(
+ os.environ,
+ {
+ "CLAUDE_AUTH_METHOD": "vertex",
+ "CLAUDE_CODE_USE_VERTEX": "1",
+ "ANTHROPIC_VERTEX_PROJECT_ID": "my-project",
+ "CLOUD_ML_REGION": "us-central1",
+ },
+ ):
+ import src.auth
+
+ importlib.reload(src.auth)
+ env_vars = src.auth.auth_manager.get_claude_code_env_vars()
+ assert env_vars.get("CLAUDE_CODE_USE_VERTEX") == "1"
+ assert "ANTHROPIC_VERTEX_PROJECT_ID" in env_vars
+ assert "CLOUD_ML_REGION" in env_vars
+
+ def test_cli_env_vars_empty(self):
+ """CLI method returns no environment variables."""
+ env_copy = {k: v for k, v in os.environ.items() if k != "CLAUDE_AUTH_METHOD"}
+ env_copy["CLAUDE_AUTH_METHOD"] = "cli"
+ with patch.dict(os.environ, env_copy, clear=True):
+ import src.auth
+
+ importlib.reload(src.auth)
+ env_vars = src.auth.auth_manager.get_claude_code_env_vars()
+ assert env_vars == {}
+
+
+class TestVerifyApiKey:
+ """Test verify_api_key() function."""
+
+ @pytest.mark.asyncio
+ async def test_no_api_key_configured_allows_all(self):
+ """When no API_KEY is set, all requests are allowed."""
+ env_copy = {k: v for k, v in os.environ.items() if k != "API_KEY"}
+ with patch.dict(os.environ, env_copy, clear=True):
+ import src.auth
+
+ importlib.reload(src.auth)
+
+ # Mock auth_manager to have no API key
+ with patch.object(src.auth.auth_manager, "get_api_key", return_value=None):
+ mock_request = MagicMock()
+ result = await src.auth.verify_api_key(mock_request)
+ assert result is True
+
+ @pytest.mark.asyncio
+ async def test_valid_api_key_passes(self):
+ """Valid API key in Authorization header passes."""
+ with patch.dict(os.environ, {"API_KEY": "test-secret-key"}):
+ import src.auth
+
+ importlib.reload(src.auth)
+
+ from fastapi.security import HTTPAuthorizationCredentials
+
+ mock_request = MagicMock()
+ credentials = HTTPAuthorizationCredentials(
+ scheme="Bearer", credentials="test-secret-key"
+ )
+
+ with patch.object(src.auth.auth_manager, "get_api_key", return_value="test-secret-key"):
+ result = await src.auth.verify_api_key(mock_request, credentials)
+ assert result is True
+
+ @pytest.mark.asyncio
+ async def test_invalid_api_key_raises_401(self):
+ """Invalid API key raises 401 HTTPException."""
+ with patch.dict(os.environ, {"API_KEY": "correct-key"}):
+ import src.auth
+
+ importlib.reload(src.auth)
+
+ from fastapi.security import HTTPAuthorizationCredentials
+
+ mock_request = MagicMock()
+ credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="wrong-key")
+
+ with patch.object(src.auth.auth_manager, "get_api_key", return_value="correct-key"):
+ with pytest.raises(HTTPException) as exc_info:
+ await src.auth.verify_api_key(mock_request, credentials)
+ assert exc_info.value.status_code == 401
+ assert "Invalid API key" in exc_info.value.detail
+
+ @pytest.mark.asyncio
+ async def test_missing_credentials_raises_401(self):
+ """Missing credentials raise 401 HTTPException."""
+ with patch.dict(os.environ, {"API_KEY": "test-key"}):
+ import src.auth
+
+ importlib.reload(src.auth)
+
+ mock_request = MagicMock()
+ # Mock security to return None (no credentials)
+ with patch.object(src.auth, "security", AsyncMock(return_value=None)):
+ with patch.object(src.auth.auth_manager, "get_api_key", return_value="test-key"):
+ with pytest.raises(HTTPException) as exc_info:
+ await src.auth.verify_api_key(mock_request, None)
+ assert exc_info.value.status_code == 401
+ assert "Missing API key" in exc_info.value.detail
+
+
+class TestValidateClaudeCodeAuth:
+ """Test validate_claude_code_auth() function."""
+
+ def test_valid_auth_returns_true(self):
+ """Valid auth returns (True, status)."""
+ with patch.dict(os.environ, {"CLAUDE_AUTH_METHOD": "cli"}):
+ import src.auth
+
+ importlib.reload(src.auth)
+
+ is_valid, status = src.auth.validate_claude_code_auth()
+ assert is_valid is True
+ assert status["valid"] is True
+
+ def test_invalid_auth_returns_false(self):
+ """Invalid auth returns (False, status)."""
+ env_copy = {k: v for k, v in os.environ.items() if k != "ANTHROPIC_API_KEY"}
+ env_copy["CLAUDE_AUTH_METHOD"] = "anthropic"
+ with patch.dict(os.environ, env_copy, clear=True):
+ import src.auth
+
+ importlib.reload(src.auth)
+
+ is_valid, status = src.auth.validate_claude_code_auth()
+ assert is_valid is False
+ assert status["valid"] is False
+
+
+class TestGetClaudeCodeAuthInfo:
+ """Test get_claude_code_auth_info() function."""
+
+ def test_returns_auth_info(self):
+ """Returns comprehensive auth information."""
+ with patch.dict(os.environ, {"CLAUDE_AUTH_METHOD": "cli"}):
+ import src.auth
+
+ importlib.reload(src.auth)
+
+ info = src.auth.get_claude_code_auth_info()
+ assert "method" in info
+ assert "status" in info
+ assert "environment_variables" in info
+
+
+class TestGetApiKey:
+ """Test ClaudeCodeAuthManager.get_api_key()"""
+
+ def test_returns_env_api_key(self):
+ """Returns API_KEY from environment."""
+ with patch.dict(os.environ, {"API_KEY": "env-api-key"}):
+ import src.auth
+
+ importlib.reload(src.auth)
+ assert src.auth.auth_manager.get_api_key() == "env-api-key"
+
+ def test_returns_runtime_key_when_available(self):
+ """Returns runtime key when set in main module."""
+ with patch.dict(os.environ, {"API_KEY": "env-key"}):
+ import src.auth
+
+ importlib.reload(src.auth)
+
+ # Mock the runtime API key
+ mock_main = MagicMock()
+ mock_main.runtime_api_key = "runtime-key"
+
+ with patch.dict("sys.modules", {"src.main": mock_main}):
+ # Need to reload to pick up the mock
+ result = src.auth.auth_manager.get_api_key()
+ # May return env key if import fails, but shouldn't error
+ assert result in ["env-key", "runtime-key"]
+
+
+# Reset module state after tests
+@pytest.fixture(autouse=True)
+def reset_auth_module():
+ """Reset auth module after each test."""
+ yield
+ # Restore default state
+ import src.auth
+
+ importlib.reload(src.auth)
diff --git a/tests/test_basic.py b/tests/test_basic.py
index 9808483..084ccc7 100755
--- a/tests/test_basic.py
+++ b/tests/test_basic.py
@@ -6,22 +6,26 @@
import sys
import os
+import pytest
import requests
+
+from tests.conftest import requires_server
from openai import OpenAI
+
def get_api_key():
"""Get the appropriate API key for testing."""
# Check if user provided API key via environment
if os.getenv("TEST_API_KEY"):
return os.getenv("TEST_API_KEY")
-
+
# Check server auth status
try:
response = requests.get("http://localhost:8000/v1/auth/status")
if response.status_code == 200:
auth_data = response.json()
server_info = auth_data.get("server_info", {})
-
+
if not server_info.get("api_key_required", False):
# No auth required, use a dummy key
return "no-auth-required"
@@ -34,9 +38,11 @@ def get_api_key():
except Exception as e:
print(f"โ ๏ธ Could not check server auth status: {e}")
print(" Assuming no authentication required")
-
+
return "fallback-dummy-key"
+
+@requires_server
def test_health_check():
"""Test the health endpoint."""
print("Testing health check...")
@@ -52,6 +58,8 @@ def test_health_check():
print(f"โ Cannot connect to server: {e}")
return False
+
+@requires_server
def test_models_endpoint():
"""Test the models endpoint."""
print("\nTesting models endpoint...")
@@ -68,28 +76,25 @@ def test_models_endpoint():
print(f"โ Models endpoint error: {e}")
return False
+
+@requires_server
def test_openai_sdk():
"""Test with OpenAI SDK."""
print("\nTesting OpenAI SDK integration...")
-
+
api_key = get_api_key()
if api_key is None:
print("โ Cannot run test - API key required but not provided")
return False
-
+
try:
- client = OpenAI(
- base_url="http://localhost:8000/v1",
- api_key=api_key
- )
-
+ client = OpenAI(base_url="http://localhost:8000/v1", api_key=api_key)
+
# Simple test - use a model supported by Claude Agent SDK
response = client.chat.completions.create(
model="claude-sonnet-4-5-20250929", # Use newer model supported by SDK
- messages=[
- {"role": "user", "content": "Say 'Hello, World!' and nothing else."}
- ],
- max_tokens=50
+ messages=[{"role": "user", "content": "Say 'Hello, World!' and nothing else."}],
+ max_tokens=50,
)
content = response.choices[0].message.content
@@ -103,41 +108,38 @@ def test_openai_sdk():
print(f"โ OpenAI SDK test passed")
print(f" Response: {content}")
return True
-
+
except Exception as e:
print(f"โ OpenAI SDK test failed: {e}")
return False
+
+@requires_server
def test_streaming():
"""Test streaming functionality."""
print("\nTesting streaming...")
-
+
api_key = get_api_key()
if api_key is None:
print("โ Cannot run test - API key required but not provided")
return False
-
+
try:
- client = OpenAI(
- base_url="http://localhost:8000/v1",
- api_key=api_key
- )
-
+ client = OpenAI(base_url="http://localhost:8000/v1", api_key=api_key)
+
stream = client.chat.completions.create(
model="claude-sonnet-4-5-20250929", # Use newer model supported by SDK
- messages=[
- {"role": "user", "content": "Count from 1 to 3."}
- ],
- stream=True
+ messages=[{"role": "user", "content": "Count from 1 to 3."}],
+ stream=True,
)
-
+
chunks_received = 0
content = ""
for chunk in stream:
chunks_received += 1
if chunk.choices[0].delta.content:
content += chunk.choices[0].delta.content
-
+
if chunks_received > 0:
print(f"โ Streaming test passed ({chunks_received} chunks)")
print(f" Response: {content[:50]}...")
@@ -145,18 +147,62 @@ def test_streaming():
else:
print("โ No streaming chunks received")
return False
-
+
except Exception as e:
print(f"โ Streaming test failed: {e}")
return False
+
+@requires_server
+def test_version_endpoint():
+ """Test the version endpoint."""
+ print("\nTesting version endpoint...")
+ try:
+ response = requests.get("http://localhost:8000/version")
+ if response.status_code == 200:
+ data = response.json()
+ assert "version" in data, "Response missing 'version' field"
+ assert "service" in data, "Response missing 'service' field"
+ assert data["service"] == "claude-code-openai-wrapper"
+ print(f"โ Version endpoint works. Version: {data['version']}")
+ return True
+ else:
+ print(f"โ Version endpoint failed: {response.status_code}")
+ return False
+ except Exception as e:
+ print(f"โ Version endpoint error: {e}")
+ return False
+
+
+@requires_server
+def test_landing_page():
+ """Test the landing page returns HTML."""
+ print("\nTesting landing page...")
+ try:
+ response = requests.get("http://localhost:8000/")
+ if response.status_code == 200:
+ content_type = response.headers.get("content-type", "")
+ assert "text/html" in content_type, f"Expected HTML, got {content_type}"
+ assert "Claude Code OpenAI Wrapper" in response.text, "Missing page title"
+ assert "Quick Start" in response.text, "Missing Quick Start section"
+ assert "API Endpoints" in response.text, "Missing API Endpoints section"
+ print("โ Landing page works")
+ return True
+ else:
+ print(f"โ Landing page failed: {response.status_code}")
+ return False
+ except Exception as e:
+ print(f"โ Landing page error: {e}")
+ return False
+
+
def main():
"""Run all tests."""
print("Claude Code OpenAI Wrapper - Basic Tests")
- print("="*50)
+ print("=" * 50)
print("Make sure the server is running: python main.py")
- print("="*50)
-
+ print("=" * 50)
+
# Show API key status
api_key = get_api_key()
if api_key:
@@ -166,23 +212,18 @@ def main():
print("๐ Server authentication: Required (using provided key)")
else:
print("โ Server authentication: Required but no key available")
- print("="*50)
-
- tests = [
- test_health_check,
- test_models_endpoint,
- test_openai_sdk,
- test_streaming
- ]
-
+ print("=" * 50)
+
+ tests = [test_health_check, test_models_endpoint, test_openai_sdk, test_streaming]
+
passed = 0
for test in tests:
if test():
passed += 1
-
- print("\n" + "="*50)
+
+ print("\n" + "=" * 50)
print(f"Tests completed: {passed}/{len(tests)} passed")
-
+
if passed == len(tests):
print("โ All tests passed! The wrapper is working correctly.")
return 0
@@ -190,5 +231,6 @@ def main():
print("โ Some tests failed. Check the server logs for details.")
return 1
+
if __name__ == "__main__":
- sys.exit(main())
\ No newline at end of file
+ sys.exit(main())
diff --git a/tests/test_claude_cli_unit.py b/tests/test_claude_cli_unit.py
new file mode 100644
index 0000000..c67c7fe
--- /dev/null
+++ b/tests/test_claude_cli_unit.py
@@ -0,0 +1,725 @@
+#!/usr/bin/env python3
+"""
+Unit tests for src/claude_cli.py
+
+Tests the ClaudeCodeCLI class methods.
+These are pure unit tests that don't require a running server or Claude SDK.
+"""
+
+import pytest
+import os
+import tempfile
+import sys
+from unittest.mock import MagicMock, patch, AsyncMock
+from pathlib import Path
+
+
+class TestClaudeCodeCLIParseMessage:
+ """Test ClaudeCodeCLI.parse_claude_message()"""
+
+ @pytest.fixture
+ def cli_class(self):
+ """Get the ClaudeCodeCLI class without instantiating."""
+ from src.claude_cli import ClaudeCodeCLI
+
+ return ClaudeCodeCLI
+
+ def test_parse_result_message(self, cli_class):
+ """Parses result message with 'result' field."""
+ # Use classmethod-like approach - create minimal mock instance
+ cli = MagicMock()
+ cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class)
+
+ messages = [{"subtype": "success", "result": "The final answer is 42."}]
+ result = cli.parse_claude_message(messages)
+ assert result == "The final answer is 42."
+
+ def test_parse_assistant_message_with_content_list(self, cli_class):
+ """Parses assistant message with content list."""
+ cli = MagicMock()
+ cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class)
+
+ messages = [
+ {
+ "content": [
+ {"type": "text", "text": "Hello "},
+ {"type": "text", "text": "World!"},
+ ]
+ }
+ ]
+ result = cli.parse_claude_message(messages)
+ assert result == "Hello \nWorld!"
+
+ def test_parse_assistant_message_with_textblock_objects(self, cli_class):
+ """Parses assistant message with TextBlock objects."""
+ cli = MagicMock()
+ cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class)
+
+ # Mock TextBlock object
+ text_block = MagicMock()
+ text_block.text = "Response text"
+
+ messages = [{"content": [text_block]}]
+ result = cli.parse_claude_message(messages)
+ assert result == "Response text"
+
+ def test_parse_assistant_message_with_string_content(self, cli_class):
+ """Parses assistant message with string content blocks."""
+ cli = MagicMock()
+ cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class)
+
+ messages = [{"content": ["Part 1", "Part 2"]}]
+ result = cli.parse_claude_message(messages)
+ assert result == "Part 1\nPart 2"
+
+ def test_parse_old_format_assistant_message(self, cli_class):
+ """Parses old format assistant message."""
+ cli = MagicMock()
+ cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class)
+
+ messages = [
+ {
+ "type": "assistant",
+ "message": {"content": [{"type": "text", "text": "Old format response"}]},
+ }
+ ]
+ result = cli.parse_claude_message(messages)
+ assert result == "Old format response"
+
+ def test_parse_old_format_string_content(self, cli_class):
+ """Parses old format with string content."""
+ cli = MagicMock()
+ cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class)
+
+ messages = [
+ {
+ "type": "assistant",
+ "message": {"content": "Simple string content"},
+ }
+ ]
+ result = cli.parse_claude_message(messages)
+ assert result == "Simple string content"
+
+ def test_parse_empty_messages_returns_none(self, cli_class):
+ """Empty messages list returns None."""
+ cli = MagicMock()
+ cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class)
+
+ result = cli.parse_claude_message([])
+ assert result is None
+
+ def test_parse_no_matching_messages_returns_none(self, cli_class):
+ """No matching messages returns None."""
+ cli = MagicMock()
+ cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class)
+
+ messages = [{"type": "system", "content": "System message"}]
+ result = cli.parse_claude_message(messages)
+ assert result is None
+
+ def test_parse_uses_last_text(self, cli_class):
+ """When multiple messages, uses the last one with text."""
+ cli = MagicMock()
+ cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class)
+
+ messages = [
+ {"content": [{"type": "text", "text": "First response"}]},
+ {"content": [{"type": "text", "text": "Second response"}]},
+ ]
+ result = cli.parse_claude_message(messages)
+ assert result == "Second response"
+
+ def test_result_takes_priority(self, cli_class):
+ """ResultMessage.result takes priority over AssistantMessage."""
+ cli = MagicMock()
+ cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class)
+
+ messages = [
+ {"content": [{"type": "text", "text": "Some response"}]},
+ {"subtype": "success", "result": "Final result"},
+ ]
+ result = cli.parse_claude_message(messages)
+ assert result == "Final result"
+
+
+class TestClaudeCodeCLIExtractMetadata:
+ """Test ClaudeCodeCLI.extract_metadata()"""
+
+ @pytest.fixture
+ def cli_class(self):
+ """Get the ClaudeCodeCLI class."""
+ from src.claude_cli import ClaudeCodeCLI
+
+ return ClaudeCodeCLI
+
+ def test_extract_from_result_message(self, cli_class):
+ """Extracts metadata from new SDK ResultMessage."""
+ cli = MagicMock()
+ cli.extract_metadata = cli_class.extract_metadata.__get__(cli, cli_class)
+
+ messages = [
+ {
+ "subtype": "success",
+ "total_cost_usd": 0.05,
+ "duration_ms": 1500,
+ "num_turns": 3,
+ "session_id": "sess-123",
+ }
+ ]
+ metadata = cli.extract_metadata(messages)
+
+ assert metadata["total_cost_usd"] == 0.05
+ assert metadata["duration_ms"] == 1500
+ assert metadata["num_turns"] == 3
+ assert metadata["session_id"] == "sess-123"
+
+ def test_extract_from_system_init_message(self, cli_class):
+ """Extracts metadata from SystemMessage init."""
+ cli = MagicMock()
+ cli.extract_metadata = cli_class.extract_metadata.__get__(cli, cli_class)
+
+ messages = [
+ {
+ "subtype": "init",
+ "data": {"session_id": "init-sess-456", "model": "claude-3-opus"},
+ }
+ ]
+ metadata = cli.extract_metadata(messages)
+
+ assert metadata["session_id"] == "init-sess-456"
+ assert metadata["model"] == "claude-3-opus"
+
+ def test_extract_from_old_result_message(self, cli_class):
+ """Extracts metadata from old format result message."""
+ cli = MagicMock()
+ cli.extract_metadata = cli_class.extract_metadata.__get__(cli, cli_class)
+
+ messages = [
+ {
+ "type": "result",
+ "total_cost_usd": 0.03,
+ "duration_ms": 1000,
+ "num_turns": 2,
+ "session_id": "old-sess",
+ }
+ ]
+ metadata = cli.extract_metadata(messages)
+
+ assert metadata["total_cost_usd"] == 0.03
+ assert metadata["duration_ms"] == 1000
+ assert metadata["session_id"] == "old-sess"
+
+ def test_extract_from_old_system_init(self, cli_class):
+ """Extracts metadata from old format system init."""
+ cli = MagicMock()
+ cli.extract_metadata = cli_class.extract_metadata.__get__(cli, cli_class)
+
+ messages = [
+ {
+ "type": "system",
+ "subtype": "init",
+ "session_id": "old-init-sess",
+ "model": "claude-3-haiku",
+ }
+ ]
+ metadata = cli.extract_metadata(messages)
+
+ assert metadata["session_id"] == "old-init-sess"
+ assert metadata["model"] == "claude-3-haiku"
+
+ def test_extract_empty_messages_returns_defaults(self, cli_class):
+ """Empty messages returns default metadata."""
+ cli = MagicMock()
+ cli.extract_metadata = cli_class.extract_metadata.__get__(cli, cli_class)
+
+ metadata = cli.extract_metadata([])
+
+ assert metadata["session_id"] is None
+ assert metadata["total_cost_usd"] == 0.0
+ assert metadata["duration_ms"] == 0
+ assert metadata["num_turns"] == 0
+ assert metadata["model"] is None
+
+
+class TestClaudeCodeCLIEstimateTokenUsage:
+ """Test ClaudeCodeCLI.estimate_token_usage()"""
+
+ @pytest.fixture
+ def cli_class(self):
+ """Get the ClaudeCodeCLI class."""
+ from src.claude_cli import ClaudeCodeCLI
+
+ return ClaudeCodeCLI
+
+ def test_estimate_basic(self, cli_class):
+ """Basic token estimation."""
+ cli = MagicMock()
+ cli.estimate_token_usage = cli_class.estimate_token_usage.__get__(cli, cli_class)
+
+ # 12 chars / 4 = 3 tokens, 16 chars / 4 = 4 tokens
+ result = cli.estimate_token_usage("Hello World!", "Response here!")
+ assert result["prompt_tokens"] == 3
+ assert result["completion_tokens"] == 3
+ assert result["total_tokens"] == 6
+
+ def test_estimate_minimum_one_token(self, cli_class):
+ """Minimum is 1 token."""
+ cli = MagicMock()
+ cli.estimate_token_usage = cli_class.estimate_token_usage.__get__(cli, cli_class)
+
+ result = cli.estimate_token_usage("Hi", "X")
+ assert result["prompt_tokens"] >= 1
+ assert result["completion_tokens"] >= 1
+
+ def test_estimate_long_text(self, cli_class):
+ """Longer text estimation."""
+ cli = MagicMock()
+ cli.estimate_token_usage = cli_class.estimate_token_usage.__get__(cli, cli_class)
+
+ prompt = "a" * 400 # 100 tokens
+ completion = "b" * 200 # 50 tokens
+ result = cli.estimate_token_usage(prompt, completion)
+
+ assert result["prompt_tokens"] == 100
+ assert result["completion_tokens"] == 50
+ assert result["total_tokens"] == 150
+
+ def test_estimate_empty_strings(self, cli_class):
+ """Empty strings return minimum 1 token each."""
+ cli = MagicMock()
+ cli.estimate_token_usage = cli_class.estimate_token_usage.__get__(cli, cli_class)
+
+ result = cli.estimate_token_usage("", "")
+ assert result["prompt_tokens"] == 1
+ assert result["completion_tokens"] == 1
+
+
+class TestClaudeCodeCLICleanupTempDir:
+ """Test ClaudeCodeCLI._cleanup_temp_dir()"""
+
+ def test_cleanup_removes_existing_dir(self):
+ """Cleanup removes existing temp directory."""
+ from src.claude_cli import ClaudeCodeCLI
+
+ # Create a mock instance
+ cli = MagicMock(spec=ClaudeCodeCLI)
+
+ # Create an actual temp directory
+ temp_dir = tempfile.mkdtemp(prefix="test_cleanup_")
+ cli.temp_dir = temp_dir
+
+ # Bind the method
+ cli._cleanup_temp_dir = ClaudeCodeCLI._cleanup_temp_dir.__get__(cli, ClaudeCodeCLI)
+
+ assert os.path.exists(temp_dir)
+
+ cli._cleanup_temp_dir()
+
+ assert not os.path.exists(temp_dir)
+
+ def test_cleanup_handles_missing_dir(self):
+ """Cleanup handles already-deleted directory gracefully."""
+ from src.claude_cli import ClaudeCodeCLI
+
+ cli = MagicMock(spec=ClaudeCodeCLI)
+ cli.temp_dir = "/nonexistent/test/dir/12345"
+
+ cli._cleanup_temp_dir = ClaudeCodeCLI._cleanup_temp_dir.__get__(cli, ClaudeCodeCLI)
+
+ # Should not raise
+ cli._cleanup_temp_dir()
+
+ def test_cleanup_no_temp_dir_set(self):
+ """Cleanup does nothing when temp_dir is None."""
+ from src.claude_cli import ClaudeCodeCLI
+
+ cli = MagicMock(spec=ClaudeCodeCLI)
+ cli.temp_dir = None
+
+ cli._cleanup_temp_dir = ClaudeCodeCLI._cleanup_temp_dir.__get__(cli, ClaudeCodeCLI)
+
+ # Should not raise
+ cli._cleanup_temp_dir()
+
+
+class TestClaudeCodeCLIInit:
+ """Test ClaudeCodeCLI.__init__() initialization logic."""
+
+ def test_timeout_conversion(self):
+ """Timeout is converted from milliseconds to seconds."""
+ # Test the conversion logic directly
+ timeout_ms = 120000
+ timeout_seconds = timeout_ms / 1000
+ assert timeout_seconds == 120.0
+
+ def test_path_handling_with_valid_dir(self):
+ """Valid directory path is handled correctly."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ path = Path(temp_dir)
+ assert path.exists()
+
+ def test_path_handling_with_invalid_dir(self):
+ """Invalid directory path is detected."""
+ path = Path("/nonexistent/path/12345")
+ assert not path.exists()
+
+ def test_init_with_cwd(self):
+ """ClaudeCodeCLI initializes with provided cwd."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ with patch("src.auth.validate_claude_code_auth") as mock_validate:
+ with patch("src.auth.auth_manager") as mock_auth:
+ mock_validate.return_value = (True, {"method": "anthropic"})
+ mock_auth.get_claude_code_env_vars.return_value = {}
+
+ from src.claude_cli import ClaudeCodeCLI
+
+ cli = ClaudeCodeCLI(cwd=temp_dir)
+
+ assert cli.cwd == Path(temp_dir)
+ assert cli.temp_dir is None
+ assert cli.timeout == 600.0 # 600000ms / 1000
+
+ def test_init_with_invalid_cwd_raises(self):
+ """ClaudeCodeCLI raises ValueError for non-existent cwd."""
+ with patch("src.auth.validate_claude_code_auth") as mock_validate:
+ with patch("src.auth.auth_manager") as mock_auth:
+ mock_validate.return_value = (True, {"method": "anthropic"})
+ mock_auth.get_claude_code_env_vars.return_value = {}
+
+ from src.claude_cli import ClaudeCodeCLI
+
+ with pytest.raises(ValueError, match="Working directory does not exist"):
+ ClaudeCodeCLI(cwd="/nonexistent/path/12345")
+
+ def test_init_without_cwd_creates_temp(self):
+ """ClaudeCodeCLI creates temp directory when no cwd provided."""
+ with patch("src.auth.validate_claude_code_auth") as mock_validate:
+ with patch("src.auth.auth_manager") as mock_auth:
+ with patch("atexit.register"): # Don't actually register cleanup
+ mock_validate.return_value = (True, {"method": "anthropic"})
+ mock_auth.get_claude_code_env_vars.return_value = {}
+
+ from src.claude_cli import ClaudeCodeCLI
+
+ cli = ClaudeCodeCLI()
+
+ assert cli.temp_dir is not None
+ assert cli.cwd == Path(cli.temp_dir)
+ assert "claude_code_workspace_" in cli.temp_dir
+
+ # Cleanup
+ if cli.temp_dir and os.path.exists(cli.temp_dir):
+ import shutil
+
+ shutil.rmtree(cli.temp_dir)
+
+ def test_init_with_custom_timeout(self):
+ """ClaudeCodeCLI uses custom timeout."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ with patch("src.auth.validate_claude_code_auth") as mock_validate:
+ with patch("src.auth.auth_manager") as mock_auth:
+ mock_validate.return_value = (True, {"method": "anthropic"})
+ mock_auth.get_claude_code_env_vars.return_value = {}
+
+ from src.claude_cli import ClaudeCodeCLI
+
+ cli = ClaudeCodeCLI(timeout=120000, cwd=temp_dir)
+
+ assert cli.timeout == 120.0
+
+ def test_init_auth_validation_failure(self):
+ """ClaudeCodeCLI handles auth validation failure gracefully."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ with patch("src.auth.validate_claude_code_auth") as mock_validate:
+ with patch("src.auth.auth_manager") as mock_auth:
+ # Auth fails
+ mock_validate.return_value = (False, {"errors": ["Missing API key"]})
+ mock_auth.get_claude_code_env_vars.return_value = {}
+
+ from src.claude_cli import ClaudeCodeCLI
+
+ # Should not raise, just log warning
+ cli = ClaudeCodeCLI(cwd=temp_dir)
+ assert cli.cwd == Path(temp_dir)
+
+
+class TestClaudeCodeCLIVerifyCLI:
+ """Test ClaudeCodeCLI.verify_cli()"""
+
+ @pytest.fixture
+ def cli_instance(self):
+ """Create a CLI instance with mocked auth."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ with patch("src.auth.validate_claude_code_auth") as mock_validate:
+ with patch("src.auth.auth_manager") as mock_auth:
+ mock_validate.return_value = (True, {"method": "anthropic"})
+ mock_auth.get_claude_code_env_vars.return_value = {}
+
+ from src.claude_cli import ClaudeCodeCLI
+
+ cli = ClaudeCodeCLI(cwd=temp_dir)
+ yield cli
+
+ @pytest.mark.asyncio
+ async def test_verify_cli_success(self, cli_instance):
+ """verify_cli returns True on successful SDK response."""
+ mock_message = {"type": "assistant", "content": [{"type": "text", "text": "Hello"}]}
+
+ async def mock_query(*args, **kwargs):
+ yield mock_message
+
+ with patch("src.claude_cli.query", mock_query):
+ result = await cli_instance.verify_cli()
+ assert result is True
+
+ @pytest.mark.asyncio
+ async def test_verify_cli_no_messages(self, cli_instance):
+ """verify_cli returns False when no messages returned."""
+
+ async def mock_query(*args, **kwargs):
+ return
+ yield # Make it a generator but yield nothing
+
+ with patch("src.claude_cli.query", mock_query):
+ result = await cli_instance.verify_cli()
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_verify_cli_exception(self, cli_instance):
+ """verify_cli returns False on exception."""
+
+ async def mock_query(*args, **kwargs):
+ raise RuntimeError("SDK error")
+ yield # Make it a generator
+
+ with patch("src.claude_cli.query", mock_query):
+ result = await cli_instance.verify_cli()
+ assert result is False
+
+
+class TestClaudeCodeCLIRunCompletion:
+ """Test ClaudeCodeCLI.run_completion()"""
+
+ @pytest.fixture
+ def cli_instance(self):
+ """Create a CLI instance with mocked auth."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ with patch("src.auth.validate_claude_code_auth") as mock_validate:
+ with patch("src.auth.auth_manager") as mock_auth:
+ mock_validate.return_value = (True, {"method": "anthropic"})
+ mock_auth.get_claude_code_env_vars.return_value = {
+ "ANTHROPIC_API_KEY": "test-key"
+ }
+
+ from src.claude_cli import ClaudeCodeCLI
+
+ cli = ClaudeCodeCLI(cwd=temp_dir)
+ yield cli
+
+ @pytest.mark.asyncio
+ async def test_run_completion_basic(self, cli_instance):
+ """run_completion yields messages from SDK."""
+ mock_message = {"type": "assistant", "content": [{"type": "text", "text": "Hello"}]}
+
+ async def mock_query(*args, **kwargs):
+ yield mock_message
+
+ with patch("src.claude_cli.query", mock_query):
+ messages = []
+ async for msg in cli_instance.run_completion("Hello"):
+ messages.append(msg)
+
+ assert len(messages) == 1
+ assert messages[0] == mock_message
+
+ @pytest.mark.asyncio
+ async def test_run_completion_with_system_prompt(self, cli_instance):
+ """run_completion sets system_prompt option."""
+ mock_message = {"type": "assistant", "content": "Response"}
+ captured_options = []
+
+ async def mock_query(prompt, options):
+ captured_options.append(options)
+ yield mock_message
+
+ with patch("src.claude_cli.query", mock_query):
+ async for _ in cli_instance.run_completion("Hello", system_prompt="You are helpful"):
+ pass
+
+ assert len(captured_options) == 1
+ opts = captured_options[0]
+ assert opts.system_prompt == {"type": "text", "text": "You are helpful"}
+
+ @pytest.mark.asyncio
+ async def test_run_completion_with_model(self, cli_instance):
+ """run_completion sets model option."""
+ mock_message = {"type": "assistant"}
+ captured_options = []
+
+ async def mock_query(prompt, options):
+ captured_options.append(options)
+ yield mock_message
+
+ with patch("src.claude_cli.query", mock_query):
+ async for _ in cli_instance.run_completion("Hello", model="claude-3-opus"):
+ pass
+
+ assert captured_options[0].model == "claude-3-opus"
+
+ @pytest.mark.asyncio
+ async def test_run_completion_with_tool_restrictions(self, cli_instance):
+ """run_completion sets allowed/disallowed tools."""
+ mock_message = {"type": "assistant"}
+ captured_options = []
+
+ async def mock_query(prompt, options):
+ captured_options.append(options)
+ yield mock_message
+
+ with patch("src.claude_cli.query", mock_query):
+ async for _ in cli_instance.run_completion(
+ "Hello",
+ allowed_tools=["Bash", "Read"],
+ disallowed_tools=["Task"],
+ ):
+ pass
+
+ assert captured_options[0].allowed_tools == ["Bash", "Read"]
+ assert captured_options[0].disallowed_tools == ["Task"]
+
+ @pytest.mark.asyncio
+ async def test_run_completion_with_permission_mode(self, cli_instance):
+ """run_completion sets permission_mode."""
+ mock_message = {"type": "assistant"}
+ captured_options = []
+
+ async def mock_query(prompt, options):
+ captured_options.append(options)
+ yield mock_message
+
+ with patch("src.claude_cli.query", mock_query):
+ async for _ in cli_instance.run_completion("Hello", permission_mode="acceptEdits"):
+ pass
+
+ assert captured_options[0].permission_mode == "acceptEdits"
+
+ @pytest.mark.asyncio
+ async def test_run_completion_continue_session(self, cli_instance):
+ """run_completion sets continue_session option."""
+ mock_message = {"type": "assistant"}
+ captured_options = []
+
+ async def mock_query(prompt, options):
+ captured_options.append(options)
+ yield mock_message
+
+ with patch("src.claude_cli.query", mock_query):
+ async for _ in cli_instance.run_completion("Hello", continue_session=True):
+ pass
+
+ assert captured_options[0].continue_session is True
+
+ @pytest.mark.asyncio
+ async def test_run_completion_resume_session(self, cli_instance):
+ """run_completion sets resume option for session_id."""
+ mock_message = {"type": "assistant"}
+ captured_options = []
+
+ async def mock_query(prompt, options):
+ captured_options.append(options)
+ yield mock_message
+
+ with patch("src.claude_cli.query", mock_query):
+ async for _ in cli_instance.run_completion("Hello", session_id="sess-123"):
+ pass
+
+ assert captured_options[0].resume == "sess-123"
+
+ @pytest.mark.asyncio
+ async def test_run_completion_converts_objects_to_dicts(self, cli_instance):
+ """run_completion converts message objects to dicts."""
+ # Create a mock object with attributes
+ mock_obj = MagicMock()
+ mock_obj.type = "assistant"
+ mock_obj.content = "Hello"
+
+ async def mock_query(*args, **kwargs):
+ yield mock_obj
+
+ with patch("src.claude_cli.query", mock_query):
+ messages = []
+ async for msg in cli_instance.run_completion("Hello"):
+ messages.append(msg)
+
+ assert len(messages) == 1
+ # Should be converted to dict
+ assert isinstance(messages[0], dict)
+ assert "type" in messages[0]
+
+ @pytest.mark.asyncio
+ async def test_run_completion_exception_yields_error(self, cli_instance):
+ """run_completion yields error message on exception."""
+
+ async def mock_query(*args, **kwargs):
+ raise RuntimeError("SDK failed")
+ yield # Make it a generator
+
+ with patch("src.claude_cli.query", mock_query):
+ messages = []
+ async for msg in cli_instance.run_completion("Hello"):
+ messages.append(msg)
+
+ assert len(messages) == 1
+ assert messages[0]["type"] == "result"
+ assert messages[0]["subtype"] == "error_during_execution"
+ assert messages[0]["is_error"] is True
+ assert "SDK failed" in messages[0]["error_message"]
+
+ @pytest.mark.asyncio
+ async def test_run_completion_restores_env_vars(self, cli_instance):
+ """run_completion restores environment variables after execution."""
+ # Set an env var that will be modified
+ original_key = os.environ.get("ANTHROPIC_API_KEY")
+
+ mock_message = {"type": "assistant"}
+
+ async def mock_query(*args, **kwargs):
+ yield mock_message
+
+ with patch("src.claude_cli.query", mock_query):
+ async for _ in cli_instance.run_completion("Hello"):
+ pass
+
+ # Env should be restored
+ if original_key is None:
+ assert (
+ "ANTHROPIC_API_KEY" not in os.environ
+ or os.environ.get("ANTHROPIC_API_KEY") == original_key
+ )
+ else:
+ assert os.environ.get("ANTHROPIC_API_KEY") == original_key
+
+
+class TestClaudeCodeCLICleanupException:
+ """Test ClaudeCodeCLI._cleanup_temp_dir() exception handling."""
+
+ def test_cleanup_exception_is_caught(self):
+ """Cleanup catches exceptions during rmtree."""
+ from src.claude_cli import ClaudeCodeCLI
+
+ cli = MagicMock(spec=ClaudeCodeCLI)
+ temp_dir = tempfile.mkdtemp(prefix="test_cleanup_exc_")
+ cli.temp_dir = temp_dir
+
+ # Bind the real method
+ cli._cleanup_temp_dir = ClaudeCodeCLI._cleanup_temp_dir.__get__(cli, ClaudeCodeCLI)
+
+ with patch("shutil.rmtree", side_effect=PermissionError("Cannot delete")):
+ # Should not raise
+ cli._cleanup_temp_dir()
+
+ # Clean up manually
+ import shutil
+
+ if os.path.exists(temp_dir):
+ shutil.rmtree(temp_dir)
diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py
index 6799285..3592818 100644
--- a/tests/test_endpoints.py
+++ b/tests/test_endpoints.py
@@ -4,11 +4,16 @@
Run this while the server is running on localhost:8000
"""
+import pytest
import requests
+
+from tests.conftest import requires_server
import json
BASE_URL = "http://localhost:8000"
+
+@requires_server
def test_health():
print("Testing /health endpoint...")
try:
@@ -20,6 +25,8 @@ def test_health():
print(f" Error: {e}")
return False
+
+@requires_server
def test_auth_status():
print("\nTesting /v1/auth/status endpoint...")
try:
@@ -31,6 +38,8 @@ def test_auth_status():
print(f" Error: {e}")
return False
+
+@requires_server
def test_models():
print("\nTesting /v1/models endpoint...")
try:
@@ -38,74 +47,81 @@ def test_models():
print(f" Status: {response.status_code}")
models = response.json()
print(f" Found {len(models.get('data', []))} models")
- for model in models.get('data', [])[:3]: # Show first 3
+ for model in models.get("data", [])[:3]: # Show first 3
print(f" - {model.get('id')}")
return response.status_code == 200
except Exception as e:
print(f" Error: {e}")
return False
+
+@requires_server
def test_chat_completion():
print("\nTesting /v1/chat/completions endpoint...")
try:
payload = {
"model": "claude-3-5-haiku-20241022", # Use fastest model
"messages": [
- {"role": "user", "content": "Say 'Hello, SDK integration working!' and nothing else."}
+ {
+ "role": "user",
+ "content": "Say 'Hello, SDK integration working!' and nothing else.",
+ }
],
- "max_tokens": 50
+ "max_tokens": 50,
}
-
+
response = requests.post(
f"{BASE_URL}/v1/chat/completions",
json=payload,
- headers={"Content-Type": "application/json"}
+ headers={"Content-Type": "application/json"},
)
-
+
print(f" Status: {response.status_code}")
-
+
if response.status_code == 200:
result = response.json()
- content = result.get('choices', [{}])[0].get('message', {}).get('content', '')
+ content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
print(f" Response: {content}")
print(f" Usage: {result.get('usage', {})}")
return True
else:
print(f" Error: {response.text}")
return False
-
+
except Exception as e:
print(f" Error: {e}")
return False
+
def main():
print("Claude Code OpenAI Wrapper - Endpoint Tests")
print("=" * 50)
-
+
tests = [
("Health Check", test_health),
- ("Auth Status", test_auth_status),
+ ("Auth Status", test_auth_status),
("Models List", test_models),
- ("Chat Completion", test_chat_completion)
+ ("Chat Completion", test_chat_completion),
]
-
+
passed = 0
total = len(tests)
-
+
for name, test_func in tests:
if test_func():
print(f"โ {name} passed")
passed += 1
else:
print(f"โ {name} failed")
-
+
print("=" * 50)
print(f"Results: {passed}/{total} tests passed")
-
+
if passed == total:
print("๐ All tests passed! SDK integration is working correctly.")
else:
print("โ Some tests failed. Check server logs for details.")
+
if __name__ == "__main__":
- main()
\ No newline at end of file
+ main()
diff --git a/tests/test_mcp_client_unit.py b/tests/test_mcp_client_unit.py
new file mode 100644
index 0000000..b983fdc
--- /dev/null
+++ b/tests/test_mcp_client_unit.py
@@ -0,0 +1,887 @@
+#!/usr/bin/env python3
+"""
+Unit tests for src/mcp_client.py
+
+Tests the MCPClient, MCPServerConfig, and MCPServerConnection classes.
+These are pure unit tests that don't require actual MCP servers.
+"""
+
+import pytest
+from datetime import datetime
+from unittest.mock import MagicMock, patch, AsyncMock
+import threading
+
+from src.mcp_client import (
+ MCPServerConfig,
+ MCPServerConnection,
+ MCPClient,
+ mcp_client,
+ MCP_AVAILABLE,
+)
+
+
+class TestMCPServerConfig:
+ """Test the MCPServerConfig dataclass."""
+
+ def test_creation_with_required_fields(self):
+ """MCPServerConfig can be created with just required fields."""
+ config = MCPServerConfig(name="test-server", command="test-cmd")
+ assert config.name == "test-server"
+ assert config.command == "test-cmd"
+ assert config.args == []
+ assert config.env is None
+ assert config.description == ""
+ assert config.enabled is True
+
+ def test_creation_with_all_fields(self):
+ """MCPServerConfig can be created with all fields."""
+ config = MCPServerConfig(
+ name="full-server",
+ command="/usr/bin/server",
+ args=["--port", "8080"],
+ env={"DEBUG": "1"},
+ description="A full test server",
+ enabled=False,
+ )
+ assert config.name == "full-server"
+ assert config.command == "/usr/bin/server"
+ assert config.args == ["--port", "8080"]
+ assert config.env == {"DEBUG": "1"}
+ assert config.description == "A full test server"
+ assert config.enabled is False
+
+ def test_args_default_is_empty_list(self):
+ """Default args is an empty list (not shared between instances)."""
+ config1 = MCPServerConfig(name="s1", command="cmd")
+ config2 = MCPServerConfig(name="s2", command="cmd")
+
+ config1.args.append("--flag")
+ assert "--flag" not in config2.args
+
+
+class TestMCPServerConnection:
+ """Test the MCPServerConnection dataclass."""
+
+ @pytest.fixture
+ def mock_config(self):
+ """Create a mock server config."""
+ return MCPServerConfig(name="test", command="test-cmd")
+
+ def test_creation_with_required_fields(self, mock_config):
+ """MCPServerConnection can be created with required fields."""
+ mock_session = MagicMock()
+ mock_read = MagicMock()
+ mock_write = MagicMock()
+
+ connection = MCPServerConnection(
+ config=mock_config,
+ session=mock_session,
+ read_stream=mock_read,
+ write_stream=mock_write,
+ )
+ assert connection.config is mock_config
+ assert connection.session is mock_session
+ assert isinstance(connection.connected_at, datetime)
+ assert connection.available_tools == []
+ assert connection.available_resources == []
+ assert connection.available_prompts == []
+
+ def test_creation_with_capabilities(self, mock_config):
+ """MCPServerConnection can be created with capabilities."""
+ connection = MCPServerConnection(
+ config=mock_config,
+ session=MagicMock(),
+ read_stream=MagicMock(),
+ write_stream=MagicMock(),
+ available_tools=[{"name": "tool1"}],
+ available_resources=[{"uri": "file://test"}],
+ available_prompts=[{"name": "prompt1"}],
+ )
+ assert len(connection.available_tools) == 1
+ assert len(connection.available_resources) == 1
+ assert len(connection.available_prompts) == 1
+
+
+class TestMCPClient:
+ """Test the MCPClient class."""
+
+ @pytest.fixture
+ def client(self):
+ """Create a fresh MCPClient for each test."""
+ return MCPClient()
+
+ def test_initialization(self, client):
+ """MCPClient initializes with empty servers and connections."""
+ assert client.servers == {}
+ assert client.connections == {}
+
+ def test_is_available(self, client):
+ """is_available returns MCP_AVAILABLE constant."""
+ assert client.is_available() == MCP_AVAILABLE
+
+ def test_register_server(self, client):
+ """register_server adds server configuration."""
+ config = MCPServerConfig(name="test-server", command="test-cmd")
+ client.register_server(config)
+
+ assert "test-server" in client.servers
+ assert client.servers["test-server"] is config
+
+ def test_register_server_overwrites_existing(self, client):
+ """register_server overwrites existing configuration."""
+ config1 = MCPServerConfig(name="test-server", command="cmd1")
+ config2 = MCPServerConfig(name="test-server", command="cmd2")
+
+ client.register_server(config1)
+ client.register_server(config2)
+
+ assert client.servers["test-server"].command == "cmd2"
+
+ def test_unregister_server_existing(self, client):
+ """unregister_server removes existing server."""
+ config = MCPServerConfig(name="test-server", command="cmd")
+ client.register_server(config)
+ assert "test-server" in client.servers
+
+ result = client.unregister_server("test-server")
+ assert result is True
+ assert "test-server" not in client.servers
+
+ def test_unregister_server_nonexistent(self, client):
+ """unregister_server returns False for nonexistent server."""
+ result = client.unregister_server("nonexistent")
+ assert result is False
+
+ def test_list_servers(self, client):
+ """list_servers returns all registered servers."""
+ config1 = MCPServerConfig(name="server1", command="cmd1")
+ config2 = MCPServerConfig(name="server2", command="cmd2")
+
+ client.register_server(config1)
+ client.register_server(config2)
+
+ servers = client.list_servers()
+ assert len(servers) == 2
+ names = [s.name for s in servers]
+ assert "server1" in names
+ assert "server2" in names
+
+ def test_list_servers_empty(self, client):
+ """list_servers returns empty list when no servers registered."""
+ assert client.list_servers() == []
+
+ def test_get_server_existing(self, client):
+ """get_server returns existing server config."""
+ config = MCPServerConfig(name="test-server", command="cmd")
+ client.register_server(config)
+
+ result = client.get_server("test-server")
+ assert result is config
+
+ def test_get_server_nonexistent(self, client):
+ """get_server returns None for nonexistent server."""
+ result = client.get_server("nonexistent")
+ assert result is None
+
+ def test_list_connected_servers_empty(self, client):
+ """list_connected_servers returns empty list when no connections."""
+ assert client.list_connected_servers() == []
+
+ def test_list_connected_servers_with_connections(self, client):
+ """list_connected_servers returns names of connected servers."""
+ config = MCPServerConfig(name="test", command="cmd")
+ connection = MCPServerConnection(
+ config=config,
+ session=MagicMock(),
+ read_stream=MagicMock(),
+ write_stream=MagicMock(),
+ )
+ client.connections["test"] = connection
+
+ result = client.list_connected_servers()
+ assert result == ["test"]
+
+ def test_get_connection_existing(self, client):
+ """get_connection returns existing connection."""
+ config = MCPServerConfig(name="test", command="cmd")
+ connection = MCPServerConnection(
+ config=config,
+ session=MagicMock(),
+ read_stream=MagicMock(),
+ write_stream=MagicMock(),
+ )
+ client.connections["test"] = connection
+
+ result = client.get_connection("test")
+ assert result is connection
+
+ def test_get_connection_nonexistent(self, client):
+ """get_connection returns None for nonexistent connection."""
+ result = client.get_connection("nonexistent")
+ assert result is None
+
+ def test_get_all_tools_empty(self, client):
+ """get_all_tools returns empty dict when no connections."""
+ assert client.get_all_tools() == {}
+
+ def test_get_all_tools_with_connections(self, client):
+ """get_all_tools returns tools from all connections."""
+ config1 = MCPServerConfig(name="server1", command="cmd")
+ config2 = MCPServerConfig(name="server2", command="cmd")
+
+ conn1 = MCPServerConnection(
+ config=config1,
+ session=MagicMock(),
+ read_stream=MagicMock(),
+ write_stream=MagicMock(),
+ available_tools=[{"name": "tool1"}],
+ )
+ conn2 = MCPServerConnection(
+ config=config2,
+ session=MagicMock(),
+ read_stream=MagicMock(),
+ write_stream=MagicMock(),
+ available_tools=[{"name": "tool2"}, {"name": "tool3"}],
+ )
+
+ client.connections["server1"] = conn1
+ client.connections["server2"] = conn2
+
+ result = client.get_all_tools()
+ assert "server1" in result
+ assert "server2" in result
+ assert len(result["server1"]) == 1
+ assert len(result["server2"]) == 2
+
+ def test_get_stats_empty(self, client):
+ """get_stats returns correct stats when empty."""
+ stats = client.get_stats()
+
+ assert stats["mcp_available"] == MCP_AVAILABLE
+ assert stats["registered_servers"] == 0
+ assert stats["connected_servers"] == 0
+ assert stats["total_tools"] == 0
+ assert stats["total_resources"] == 0
+ assert stats["total_prompts"] == 0
+ assert stats["servers"] == []
+
+ def test_get_stats_with_servers(self, client):
+ """get_stats includes registered server info."""
+ config = MCPServerConfig(
+ name="test-server",
+ command="cmd",
+ description="Test server",
+ enabled=True,
+ )
+ client.register_server(config)
+
+ stats = client.get_stats()
+ assert stats["registered_servers"] == 1
+ assert len(stats["servers"]) == 1
+ assert stats["servers"][0]["name"] == "test-server"
+ assert stats["servers"][0]["enabled"] is True
+ assert stats["servers"][0]["connected"] is False
+
+ def test_get_stats_with_connections(self, client):
+ """get_stats counts tools, resources, prompts correctly."""
+ config = MCPServerConfig(name="test", command="cmd")
+ client.register_server(config)
+
+ connection = MCPServerConnection(
+ config=config,
+ session=MagicMock(),
+ read_stream=MagicMock(),
+ write_stream=MagicMock(),
+ available_tools=[{"name": "t1"}, {"name": "t2"}],
+ available_resources=[{"uri": "r1"}],
+ available_prompts=[{"name": "p1"}, {"name": "p2"}, {"name": "p3"}],
+ )
+ client.connections["test"] = connection
+
+ stats = client.get_stats()
+ assert stats["connected_servers"] == 1
+ assert stats["total_tools"] == 2
+ assert stats["total_resources"] == 1
+ assert stats["total_prompts"] == 3
+ assert stats["servers"][0]["connected"] is True
+
+
+class TestMCPClientAsync:
+ """Test async methods of MCPClient."""
+
+ @pytest.fixture
+ def client(self):
+ """Create a fresh MCPClient for each test."""
+ return MCPClient()
+
+ @pytest.mark.asyncio
+ async def test_connect_server_not_registered(self, client):
+ """connect_server returns False for unregistered server."""
+ result = await client.connect_server("nonexistent")
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_connect_server_disabled(self, client):
+ """connect_server returns False for disabled server."""
+ config = MCPServerConfig(name="disabled", command="cmd", enabled=False)
+ client.register_server(config)
+
+ result = await client.connect_server("disabled")
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_connect_server_already_connected(self, client):
+ """connect_server returns True when already connected."""
+ config = MCPServerConfig(name="test", command="cmd")
+ client.register_server(config)
+
+ # Add fake connection
+ connection = MCPServerConnection(
+ config=config,
+ session=MagicMock(),
+ read_stream=MagicMock(),
+ write_stream=MagicMock(),
+ )
+ client.connections["test"] = connection
+
+ result = await client.connect_server("test")
+ assert result is True
+
+ @pytest.mark.asyncio
+ async def test_disconnect_server_not_connected(self, client):
+ """disconnect_server returns False when not connected."""
+ result = await client.disconnect_server("nonexistent")
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_disconnect_server_success(self, client):
+ """disconnect_server removes connection and returns True."""
+ config = MCPServerConfig(name="test", command="cmd")
+ connection = MCPServerConnection(
+ config=config,
+ session=MagicMock(),
+ read_stream=MagicMock(),
+ write_stream=MagicMock(),
+ )
+ client.connections["test"] = connection
+
+ result = await client.disconnect_server("test")
+ assert result is True
+ assert "test" not in client.connections
+
+ @pytest.mark.asyncio
+ async def test_call_tool_not_connected(self, client):
+ """call_tool raises ValueError when not connected."""
+ with pytest.raises(ValueError, match="Not connected to MCP server"):
+ await client.call_tool("nonexistent", "tool", {})
+
+ @pytest.mark.asyncio
+ async def test_call_tool_success(self, client):
+ """call_tool delegates to session.call_tool."""
+ mock_session = AsyncMock()
+ mock_session.call_tool = AsyncMock(return_value={"result": "success"})
+
+ config = MCPServerConfig(name="test", command="cmd")
+ connection = MCPServerConnection(
+ config=config,
+ session=mock_session,
+ read_stream=MagicMock(),
+ write_stream=MagicMock(),
+ )
+ client.connections["test"] = connection
+
+ result = await client.call_tool("test", "my-tool", {"arg": "value"})
+ assert result == {"result": "success"}
+ mock_session.call_tool.assert_called_once_with("my-tool", {"arg": "value"})
+
+ @pytest.mark.asyncio
+ async def test_call_tool_error(self, client):
+ """call_tool propagates errors from session."""
+ mock_session = AsyncMock()
+ mock_session.call_tool = AsyncMock(side_effect=RuntimeError("Tool failed"))
+
+ config = MCPServerConfig(name="test", command="cmd")
+ connection = MCPServerConnection(
+ config=config,
+ session=mock_session,
+ read_stream=MagicMock(),
+ write_stream=MagicMock(),
+ )
+ client.connections["test"] = connection
+
+ with pytest.raises(RuntimeError, match="Tool failed"):
+ await client.call_tool("test", "my-tool", {})
+
+ @pytest.mark.asyncio
+ async def test_read_resource_not_connected(self, client):
+ """read_resource raises ValueError when not connected."""
+ with pytest.raises(ValueError, match="Not connected to MCP server"):
+ await client.read_resource("nonexistent", "file://test")
+
+ @pytest.mark.asyncio
+ async def test_read_resource_success(self, client):
+ """read_resource delegates to session.read_resource."""
+ mock_session = AsyncMock()
+ mock_session.read_resource = AsyncMock(return_value="resource content")
+
+ config = MCPServerConfig(name="test", command="cmd")
+ connection = MCPServerConnection(
+ config=config,
+ session=mock_session,
+ read_stream=MagicMock(),
+ write_stream=MagicMock(),
+ )
+ client.connections["test"] = connection
+
+ result = await client.read_resource("test", "file://path")
+ assert result == "resource content"
+ mock_session.read_resource.assert_called_once_with("file://path")
+
+ @pytest.mark.asyncio
+ async def test_read_resource_error(self, client):
+ """read_resource propagates errors from session."""
+ mock_session = AsyncMock()
+ mock_session.read_resource = AsyncMock(side_effect=FileNotFoundError("Not found"))
+
+ config = MCPServerConfig(name="test", command="cmd")
+ connection = MCPServerConnection(
+ config=config,
+ session=mock_session,
+ read_stream=MagicMock(),
+ write_stream=MagicMock(),
+ )
+ client.connections["test"] = connection
+
+ with pytest.raises(FileNotFoundError):
+ await client.read_resource("test", "file://missing")
+
+ @pytest.mark.asyncio
+ async def test_get_prompt_not_connected(self, client):
+ """get_prompt raises ValueError when not connected."""
+ with pytest.raises(ValueError, match="Not connected to MCP server"):
+ await client.get_prompt("nonexistent", "prompt-name")
+
+ @pytest.mark.asyncio
+ async def test_get_prompt_success(self, client):
+ """get_prompt delegates to session.get_prompt."""
+ mock_session = AsyncMock()
+ mock_session.get_prompt = AsyncMock(return_value={"messages": []})
+
+ config = MCPServerConfig(name="test", command="cmd")
+ connection = MCPServerConnection(
+ config=config,
+ session=mock_session,
+ read_stream=MagicMock(),
+ write_stream=MagicMock(),
+ )
+ client.connections["test"] = connection
+
+ result = await client.get_prompt("test", "my-prompt", {"arg": "val"})
+ assert result == {"messages": []}
+ mock_session.get_prompt.assert_called_once_with("my-prompt", {"arg": "val"})
+
+ @pytest.mark.asyncio
+ async def test_get_prompt_no_arguments(self, client):
+ """get_prompt uses empty dict when no arguments provided."""
+ mock_session = AsyncMock()
+ mock_session.get_prompt = AsyncMock(return_value={"messages": []})
+
+ config = MCPServerConfig(name="test", command="cmd")
+ connection = MCPServerConnection(
+ config=config,
+ session=mock_session,
+ read_stream=MagicMock(),
+ write_stream=MagicMock(),
+ )
+ client.connections["test"] = connection
+
+ await client.get_prompt("test", "my-prompt")
+ mock_session.get_prompt.assert_called_once_with("my-prompt", {})
+
+ @pytest.mark.asyncio
+ async def test_get_prompt_error(self, client):
+ """get_prompt propagates errors from session."""
+ mock_session = AsyncMock()
+ mock_session.get_prompt = AsyncMock(side_effect=KeyError("Prompt not found"))
+
+ config = MCPServerConfig(name="test", command="cmd")
+ connection = MCPServerConnection(
+ config=config,
+ session=mock_session,
+ read_stream=MagicMock(),
+ write_stream=MagicMock(),
+ )
+ client.connections["test"] = connection
+
+ with pytest.raises(KeyError):
+ await client.get_prompt("test", "missing-prompt")
+
+
+class TestMCPClientConnectServerMCPAvailable:
+ """Test connect_server when MCP SDK is available (mocked)."""
+
+ @pytest.fixture
+ def client(self):
+ """Create a fresh MCPClient for each test."""
+ return MCPClient()
+
+ @pytest.mark.asyncio
+ @patch("src.mcp_client.MCP_AVAILABLE", False)
+ async def test_connect_server_mcp_not_available(self):
+ """connect_server returns False when MCP SDK not available."""
+ # Create client with mocked MCP_AVAILABLE
+ with patch("src.mcp_client.MCP_AVAILABLE", False):
+ client = MCPClient()
+ config = MCPServerConfig(name="test", command="cmd")
+ client.register_server(config)
+
+ result = await client.connect_server("test")
+ assert result is False
+
+
+class TestMCPClientConnectServerWithMocking:
+ """Test connect_server with full MCP SDK mocking."""
+
+ @pytest.fixture
+ def client(self):
+ """Create a fresh MCPClient for each test."""
+ return MCPClient()
+
+ @pytest.mark.asyncio
+ async def test_connect_server_success(self, client):
+ """connect_server successfully connects and lists capabilities."""
+ if not MCP_AVAILABLE:
+ pytest.skip("MCP SDK not available")
+
+ config = MCPServerConfig(name="test", command="test-cmd")
+ client.register_server(config)
+
+ # Create mock tools, resources, prompts
+ mock_tool = MagicMock()
+ mock_tool.name = "mock-tool"
+ mock_tool.description = "A mock tool"
+ mock_tool.inputSchema = {"type": "object"}
+
+ mock_resource = MagicMock()
+ mock_resource.uri = "file://test"
+ mock_resource.name = "test-resource"
+ mock_resource.description = "A test resource"
+ mock_resource.mimeType = "text/plain"
+
+ mock_prompt = MagicMock()
+ mock_prompt.name = "mock-prompt"
+ mock_prompt.description = "A mock prompt"
+ mock_prompt.arguments = []
+
+ mock_tools_response = MagicMock()
+ mock_tools_response.tools = [mock_tool]
+
+ mock_resources_response = MagicMock()
+ mock_resources_response.resources = [mock_resource]
+
+ mock_prompts_response = MagicMock()
+ mock_prompts_response.prompts = [mock_prompt]
+
+ mock_session = AsyncMock()
+ mock_session.initialize = AsyncMock()
+ mock_session.list_tools = AsyncMock(return_value=mock_tools_response)
+ mock_session.list_resources = AsyncMock(return_value=mock_resources_response)
+ mock_session.list_prompts = AsyncMock(return_value=mock_prompts_response)
+
+ mock_read = MagicMock()
+ mock_write = MagicMock()
+
+ with patch("src.mcp_client.StdioServerParameters") as mock_params:
+ with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio:
+ with patch("src.mcp_client.ClientSession") as mock_client_session:
+ mock_stdio.return_value = (mock_read, mock_write)
+ mock_client_session.return_value = mock_session
+
+ result = await client.connect_server("test")
+
+ assert result is True
+ assert "test" in client.connections
+ conn = client.connections["test"]
+ assert len(conn.available_tools) == 1
+ assert len(conn.available_resources) == 1
+ assert len(conn.available_prompts) == 1
+
+ @pytest.mark.asyncio
+ async def test_connect_server_list_tools_fails(self, client):
+ """connect_server handles tool listing failure gracefully."""
+ if not MCP_AVAILABLE:
+ pytest.skip("MCP SDK not available")
+
+ config = MCPServerConfig(name="test", command="test-cmd")
+ client.register_server(config)
+
+ mock_session = AsyncMock()
+ mock_session.initialize = AsyncMock()
+ mock_session.list_tools = AsyncMock(side_effect=RuntimeError("Tools error"))
+ mock_session.list_resources = AsyncMock(return_value=MagicMock(resources=[]))
+ mock_session.list_prompts = AsyncMock(return_value=MagicMock(prompts=[]))
+
+ with patch("src.mcp_client.StdioServerParameters"):
+ with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio:
+ with patch("src.mcp_client.ClientSession") as mock_client_session:
+ mock_stdio.return_value = (MagicMock(), MagicMock())
+ mock_client_session.return_value = mock_session
+
+ result = await client.connect_server("test")
+
+ assert result is True
+ conn = client.connections["test"]
+ assert conn.available_tools == []
+
+ @pytest.mark.asyncio
+ async def test_connect_server_list_resources_fails(self, client):
+ """connect_server handles resource listing failure gracefully."""
+ if not MCP_AVAILABLE:
+ pytest.skip("MCP SDK not available")
+
+ config = MCPServerConfig(name="test", command="test-cmd")
+ client.register_server(config)
+
+ mock_session = AsyncMock()
+ mock_session.initialize = AsyncMock()
+ mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[]))
+ mock_session.list_resources = AsyncMock(side_effect=RuntimeError("Resources error"))
+ mock_session.list_prompts = AsyncMock(return_value=MagicMock(prompts=[]))
+
+ with patch("src.mcp_client.StdioServerParameters"):
+ with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio:
+ with patch("src.mcp_client.ClientSession") as mock_client_session:
+ mock_stdio.return_value = (MagicMock(), MagicMock())
+ mock_client_session.return_value = mock_session
+
+ result = await client.connect_server("test")
+
+ assert result is True
+ conn = client.connections["test"]
+ assert conn.available_resources == []
+
+ @pytest.mark.asyncio
+ async def test_connect_server_list_prompts_fails(self, client):
+ """connect_server handles prompt listing failure gracefully."""
+ if not MCP_AVAILABLE:
+ pytest.skip("MCP SDK not available")
+
+ config = MCPServerConfig(name="test", command="test-cmd")
+ client.register_server(config)
+
+ mock_session = AsyncMock()
+ mock_session.initialize = AsyncMock()
+ mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[]))
+ mock_session.list_resources = AsyncMock(return_value=MagicMock(resources=[]))
+ mock_session.list_prompts = AsyncMock(side_effect=RuntimeError("Prompts error"))
+
+ with patch("src.mcp_client.StdioServerParameters"):
+ with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio:
+ with patch("src.mcp_client.ClientSession") as mock_client_session:
+ mock_stdio.return_value = (MagicMock(), MagicMock())
+ mock_client_session.return_value = mock_session
+
+ result = await client.connect_server("test")
+
+ assert result is True
+ conn = client.connections["test"]
+ assert conn.available_prompts == []
+
+ @pytest.mark.asyncio
+ async def test_connect_server_connection_error(self, client):
+ """connect_server returns False on ConnectionError."""
+ if not MCP_AVAILABLE:
+ pytest.skip("MCP SDK not available")
+
+ config = MCPServerConfig(name="test", command="test-cmd")
+ client.register_server(config)
+
+ with patch("src.mcp_client.StdioServerParameters"):
+ with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio:
+ mock_stdio.side_effect = ConnectionError("Connection refused")
+
+ result = await client.connect_server("test")
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_connect_server_value_error(self, client):
+ """connect_server returns False on ValueError."""
+ if not MCP_AVAILABLE:
+ pytest.skip("MCP SDK not available")
+
+ config = MCPServerConfig(name="test", command="test-cmd")
+ client.register_server(config)
+
+ with patch("src.mcp_client.StdioServerParameters"):
+ with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio:
+ mock_stdio.side_effect = ValueError("Invalid config")
+
+ result = await client.connect_server("test")
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_connect_server_timeout_error(self, client):
+ """connect_server returns False on TimeoutError."""
+ if not MCP_AVAILABLE:
+ pytest.skip("MCP SDK not available")
+
+ config = MCPServerConfig(name="test", command="test-cmd")
+ client.register_server(config)
+
+ with patch("src.mcp_client.StdioServerParameters"):
+ with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio:
+ mock_stdio.side_effect = TimeoutError("Connection timeout")
+
+ result = await client.connect_server("test")
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_connect_server_file_not_found_error(self, client):
+ """connect_server returns False on FileNotFoundError."""
+ if not MCP_AVAILABLE:
+ pytest.skip("MCP SDK not available")
+
+ config = MCPServerConfig(name="test", command="nonexistent-cmd")
+ client.register_server(config)
+
+ with patch("src.mcp_client.StdioServerParameters"):
+ with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio:
+ mock_stdio.side_effect = FileNotFoundError("Command not found")
+
+ result = await client.connect_server("test")
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_connect_server_permission_error(self, client):
+ """connect_server returns False on PermissionError."""
+ if not MCP_AVAILABLE:
+ pytest.skip("MCP SDK not available")
+
+ config = MCPServerConfig(name="test", command="test-cmd")
+ client.register_server(config)
+
+ with patch("src.mcp_client.StdioServerParameters"):
+ with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio:
+ mock_stdio.side_effect = PermissionError("Permission denied")
+
+ result = await client.connect_server("test")
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_connect_server_unexpected_error(self, client):
+ """connect_server returns False on unexpected Exception."""
+ if not MCP_AVAILABLE:
+ pytest.skip("MCP SDK not available")
+
+ config = MCPServerConfig(name="test", command="test-cmd")
+ client.register_server(config)
+
+ with patch("src.mcp_client.StdioServerParameters"):
+ with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio:
+ mock_stdio.side_effect = RuntimeError("Unexpected error")
+
+ result = await client.connect_server("test")
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_disconnect_server_exception(self, client):
+ """disconnect_server handles exception during cleanup."""
+ config = MCPServerConfig(name="test", command="cmd")
+
+ # Create a connection with a mock session that raises on cleanup
+ mock_session = MagicMock()
+
+ connection = MCPServerConnection(
+ config=config,
+ session=mock_session,
+ read_stream=MagicMock(),
+ write_stream=MagicMock(),
+ )
+ client.connections["test"] = connection
+
+ # The disconnect should still return True (cleanup exception is logged)
+ result = await client.disconnect_server("test")
+ assert result is True
+ assert "test" not in client.connections
+
+
+class TestMCPClientThreadSafety:
+ """Test thread safety of MCPClient operations."""
+
+ @pytest.fixture
+ def client(self):
+ """Create a fresh MCPClient for each test."""
+ return MCPClient()
+
+ def test_concurrent_server_registration(self, client):
+ """Multiple threads can register servers concurrently."""
+ results = []
+ errors = []
+
+ def register_server(name):
+ try:
+ config = MCPServerConfig(name=name, command="cmd")
+ client.register_server(config)
+ results.append(name)
+ except Exception as e:
+ errors.append(str(e))
+
+ threads = []
+ for i in range(20):
+ t = threading.Thread(target=register_server, args=(f"server-{i}",))
+ threads.append(t)
+ t.start()
+
+ for t in threads:
+ t.join()
+
+ assert len(errors) == 0
+ assert len(results) == 20
+ assert len(client.servers) == 20
+
+ def test_concurrent_get_stats(self, client):
+ """Multiple threads can call get_stats concurrently."""
+ # Register some servers first
+ for i in range(10):
+ config = MCPServerConfig(name=f"server-{i}", command="cmd")
+ client.register_server(config)
+
+ results = []
+ errors = []
+
+ def get_stats():
+ try:
+ stats = client.get_stats()
+ results.append(stats)
+ except Exception as e:
+ errors.append(str(e))
+
+ threads = []
+ for _ in range(20):
+ t = threading.Thread(target=get_stats)
+ threads.append(t)
+ t.start()
+
+ for t in threads:
+ t.join()
+
+ assert len(errors) == 0
+ assert len(results) == 20
+ # All stats should show 10 registered servers
+ for stats in results:
+ assert stats["registered_servers"] == 10
+
+
+class TestGlobalMCPClientInstance:
+ """Test the global mcp_client instance."""
+
+ def test_global_instance_exists(self):
+ """Global mcp_client instance is available."""
+ assert mcp_client is not None
+ assert isinstance(mcp_client, MCPClient)
+
+ def test_global_instance_is_available_method(self):
+ """Global instance has is_available method."""
+ # Should not raise
+ result = mcp_client.is_available()
+ assert isinstance(result, bool)
diff --git a/tests/test_message_adapter_unit.py b/tests/test_message_adapter_unit.py
new file mode 100644
index 0000000..90f3c52
--- /dev/null
+++ b/tests/test_message_adapter_unit.py
@@ -0,0 +1,310 @@
+#!/usr/bin/env python3
+"""
+Unit tests for src/message_adapter.py
+
+Tests the MessageAdapter class for message format conversion.
+These are pure unit tests that don't require a running server.
+"""
+
+import pytest
+from src.message_adapter import MessageAdapter
+from src.models import Message
+
+
+class TestMessagesToPrompt:
+ """Test MessageAdapter.messages_to_prompt()"""
+
+ def test_single_user_message(self):
+ """Single user message converts correctly."""
+ messages = [Message(role="user", content="Hello")]
+ prompt, system = MessageAdapter.messages_to_prompt(messages)
+
+ assert "Human: Hello" in prompt
+ assert system is None
+
+ def test_user_and_assistant_conversation(self):
+ """User and assistant messages form conversation."""
+ messages = [
+ Message(role="user", content="Hello"),
+ Message(role="assistant", content="Hi there!"),
+ Message(role="user", content="How are you?"),
+ ]
+ prompt, system = MessageAdapter.messages_to_prompt(messages)
+
+ assert "Human: Hello" in prompt
+ assert "Assistant: Hi there!" in prompt
+ assert "Human: How are you?" in prompt
+
+ def test_system_message_extracted(self):
+ """System message is extracted as system_prompt."""
+ messages = [
+ Message(role="system", content="You are a helpful assistant."),
+ Message(role="user", content="Hello"),
+ ]
+ prompt, system = MessageAdapter.messages_to_prompt(messages)
+
+ assert system == "You are a helpful assistant."
+ assert "Human: Hello" in prompt
+
+ def test_multiple_system_messages_uses_last(self):
+ """Multiple system messages use the last one."""
+ messages = [
+ Message(role="system", content="First system message"),
+ Message(role="user", content="Hello"),
+ Message(role="system", content="Second system message"),
+ ]
+ prompt, system = MessageAdapter.messages_to_prompt(messages)
+
+ assert system == "Second system message"
+
+ def test_last_message_not_user_adds_continue(self):
+ """If last message isn't from user, adds 'Please continue'."""
+ messages = [
+ Message(role="user", content="Hello"),
+ Message(role="assistant", content="Hi there!"),
+ ]
+ prompt, system = MessageAdapter.messages_to_prompt(messages)
+
+ assert "Please continue" in prompt
+
+ def test_last_message_is_user_no_continue(self):
+ """If last message is from user, no 'Please continue' added."""
+ messages = [
+ Message(role="user", content="Hello"),
+ Message(role="assistant", content="Hi!"),
+ Message(role="user", content="What's up?"),
+ ]
+ prompt, system = MessageAdapter.messages_to_prompt(messages)
+
+ assert "Please continue" not in prompt
+
+ def test_empty_messages_list(self):
+ """Empty messages list returns empty prompt."""
+ messages = []
+ prompt, system = MessageAdapter.messages_to_prompt(messages)
+
+ assert prompt == ""
+ assert system is None
+
+
+class TestFilterContent:
+ """Test MessageAdapter.filter_content()"""
+
+ def test_empty_content_returns_empty(self):
+ """Empty content returns empty."""
+ assert MessageAdapter.filter_content("") == ""
+ assert MessageAdapter.filter_content(None) is None
+
+ def test_plain_text_unchanged(self):
+ """Plain text content is unchanged."""
+ content = "Hello, how can I help you today?"
+ result = MessageAdapter.filter_content(content)
+ assert result == content
+
+ def test_removes_thinking_blocks(self):
+ """Thinking blocks are removed."""
+ content = "Let me think about this...Here is my answer."
+ result = MessageAdapter.filter_content(content)
+
+ assert "" not in result
+ assert "Let me think" not in result
+ assert "Here is my answer" in result
+
+ def test_removes_multiline_thinking_blocks(self):
+ """Multiline thinking blocks are removed."""
+ content = """
+ Line 1 of thinking
+ Line 2 of thinking
+
+ The actual response."""
+ result = MessageAdapter.filter_content(content)
+
+ assert "" not in result
+ assert "The actual response" in result
+
+ def test_extracts_attempt_completion_content(self):
+ """Content from attempt_completion blocks is extracted."""
+ content = """Some preamble
+
+ This is the actual response to return.
+
+ Some other stuff"""
+ result = MessageAdapter.filter_content(content)
+
+ assert "This is the actual response to return" in result
+
+ def test_extracts_result_from_attempt_completion(self):
+ """Content from result tags inside attempt_completion is extracted."""
+ content = """
+ The extracted result.
+ """
+ result = MessageAdapter.filter_content(content)
+
+ assert result == "The extracted result."
+
+ def test_removes_read_file_blocks(self):
+ """read_file blocks are removed."""
+ content = "Response path/to/file.txt more text"
+ result = MessageAdapter.filter_content(content)
+
+ assert "" not in result
+ assert "path/to/file" not in result
+
+ def test_removes_write_file_blocks(self):
+ """write_file blocks are removed."""
+ content = "Response content more text"
+ result = MessageAdapter.filter_content(content)
+
+ assert "" not in result
+
+ def test_removes_bash_blocks(self):
+ """bash blocks are removed."""
+ content = "Here's the output: ls -la done"
+ result = MessageAdapter.filter_content(content)
+
+ assert "" not in result
+ assert "ls -la" not in result
+
+ def test_removes_search_files_blocks(self):
+ """search_files blocks are removed."""
+ content = "patternResult"
+ result = MessageAdapter.filter_content(content)
+
+ assert "" not in result
+
+ def test_removes_str_replace_editor_blocks(self):
+ """str_replace_editor blocks are removed."""
+ content = "editDone"
+ result = MessageAdapter.filter_content(content)
+
+ assert "" not in result
+
+ def test_removes_args_blocks(self):
+ """args blocks are removed."""
+ content = "Command --flag value executed"
+ result = MessageAdapter.filter_content(content)
+
+ assert "" not in result
+
+ def test_removes_ask_followup_question_blocks(self):
+ """ask_followup_question blocks are removed."""
+ content = "What do you mean?Ok"
+ result = MessageAdapter.filter_content(content)
+
+ assert "" not in result
+
+ def test_removes_question_blocks(self):
+ """question blocks are removed."""
+ content = "Do you want to proceed?Answer"
+ result = MessageAdapter.filter_content(content)
+
+ assert "" not in result
+
+ def test_removes_follow_up_blocks(self):
+ """follow_up blocks are removed."""
+ content = "Please clarifyResponse"
+ result = MessageAdapter.filter_content(content)
+
+ assert "" not in result
+
+ def test_removes_suggest_blocks(self):
+ """suggest blocks are removed."""
+ content = "try thisSuggestion"
+ result = MessageAdapter.filter_content(content)
+
+ assert "" not in result
+
+ def test_replaces_image_references(self):
+ """Image references are replaced with placeholder."""
+ content = "Here's the image: [Image: screenshot.png] as you can see"
+ result = MessageAdapter.filter_content(content)
+
+ assert "[Image: Content not supported by Claude Code]" in result
+ assert "screenshot.png" not in result
+
+ def test_replaces_base64_image_data(self):
+ """Base64 image data is replaced."""
+ content = "Image: data:image/png;base64,iVBORw0KGgoAAAANSUhE end"
+ result = MessageAdapter.filter_content(content)
+
+ assert "base64" not in result
+ assert "iVBORw0" not in result
+
+ def test_collapses_multiple_newlines(self):
+ """Multiple consecutive newlines are collapsed."""
+ content = "Line 1\n\n\n\n\nLine 2"
+ result = MessageAdapter.filter_content(content)
+
+ # Should have at most double newlines
+ assert "\n\n\n" not in result
+
+ def test_empty_after_filtering_returns_fallback(self):
+ """If content is empty after filtering, returns fallback message."""
+ content = "Only thinking content"
+ result = MessageAdapter.filter_content(content)
+
+ assert "How can I help you today?" in result
+
+ def test_whitespace_only_after_filtering_returns_fallback(self):
+ """If content is only whitespace after filtering, returns fallback."""
+ content = "content \n \n "
+ result = MessageAdapter.filter_content(content)
+
+ assert "How can I help you today?" in result
+
+
+class TestFormatClaudeResponse:
+ """Test MessageAdapter.format_claude_response()"""
+
+ def test_basic_formatting(self):
+ """Basic response formatting."""
+ result = MessageAdapter.format_claude_response(content="Hello!", model="claude-3-opus")
+
+ assert result["role"] == "assistant"
+ assert result["content"] == "Hello!"
+ assert result["model"] == "claude-3-opus"
+ assert result["finish_reason"] == "stop"
+
+ def test_custom_finish_reason(self):
+ """Can specify custom finish_reason."""
+ result = MessageAdapter.format_claude_response(
+ content="Hello!", model="claude-3", finish_reason="length"
+ )
+
+ assert result["finish_reason"] == "length"
+
+ def test_preserves_content_exactly(self):
+ """Content is preserved exactly as provided."""
+ content = 'Multi\nline\ncontent with special chars: <>&"'
+ result = MessageAdapter.format_claude_response(content=content, model="claude")
+
+ assert result["content"] == content
+
+
+class TestEstimateTokens:
+ """Test MessageAdapter.estimate_tokens()"""
+
+ def test_short_text(self):
+ """Short text token estimation."""
+ # 12 chars / 4 = 3 tokens
+ result = MessageAdapter.estimate_tokens("Hello World!")
+ assert result == 3
+
+ def test_empty_text(self):
+ """Empty text returns 0 tokens."""
+ result = MessageAdapter.estimate_tokens("")
+ assert result == 0
+
+ def test_long_text(self):
+ """Longer text estimation."""
+ # 100 chars / 4 = 25 tokens
+ text = "a" * 100
+ result = MessageAdapter.estimate_tokens(text)
+ assert result == 25
+
+ def test_realistic_text(self):
+ """Realistic text estimation."""
+ text = "This is a realistic sentence that might appear in a conversation."
+ result = MessageAdapter.estimate_tokens(text)
+ # 67 chars / 4 = 16 tokens
+ assert result == 16
diff --git a/tests/test_models_unit.py b/tests/test_models_unit.py
new file mode 100644
index 0000000..5e6387d
--- /dev/null
+++ b/tests/test_models_unit.py
@@ -0,0 +1,572 @@
+#!/usr/bin/env python3
+"""
+Unit tests for src/models.py
+
+Tests all Pydantic models including validators and methods.
+These are pure unit tests that don't require a running server.
+"""
+
+import pytest
+from datetime import datetime
+from unittest.mock import patch
+
+from src.models import (
+ ContentPart,
+ Message,
+ StreamOptions,
+ ChatCompletionRequest,
+ Choice,
+ Usage,
+ ChatCompletionResponse,
+ StreamChoice,
+ ChatCompletionStreamResponse,
+ ErrorDetail,
+ ErrorResponse,
+ SessionInfo,
+ SessionListResponse,
+ ToolMetadataResponse,
+ ToolListResponse,
+ ToolConfigurationResponse,
+ ToolConfigurationRequest,
+ ToolValidationResponse,
+ MCPServerConfigRequest,
+ MCPServerInfoResponse,
+ MCPServersListResponse,
+ MCPConnectionRequest,
+ MCPToolCallRequest,
+ AnthropicTextBlock,
+ AnthropicMessage,
+ AnthropicMessagesRequest,
+ AnthropicUsage,
+ AnthropicMessagesResponse,
+)
+
+
+class TestContentPart:
+ """Test ContentPart model."""
+
+ def test_create_text_content_part(self):
+ """Can create a text content part."""
+ part = ContentPart(type="text", text="Hello world")
+ assert part.type == "text"
+ assert part.text == "Hello world"
+
+
+class TestMessage:
+ """Test Message model."""
+
+ def test_create_user_message(self):
+ """Can create a user message."""
+ msg = Message(role="user", content="Hello")
+ assert msg.role == "user"
+ assert msg.content == "Hello"
+
+ def test_create_assistant_message(self):
+ """Can create an assistant message."""
+ msg = Message(role="assistant", content="Hi there")
+ assert msg.role == "assistant"
+ assert msg.content == "Hi there"
+
+ def test_create_system_message(self):
+ """Can create a system message."""
+ msg = Message(role="system", content="You are helpful")
+ assert msg.role == "system"
+ assert msg.content == "You are helpful"
+
+ def test_message_with_name(self):
+ """Can create a message with a name."""
+ msg = Message(role="user", content="Hello", name="alice")
+ assert msg.name == "alice"
+
+ def test_message_normalizes_array_content(self):
+ """Array content is normalized to string."""
+ content_parts = [
+ ContentPart(type="text", text="Part 1"),
+ ContentPart(type="text", text="Part 2"),
+ ]
+ msg = Message(role="user", content=content_parts)
+ assert msg.content == "Part 1\nPart 2"
+
+ def test_message_normalizes_dict_content(self):
+ """Dict content parts are normalized to string."""
+ content = [
+ {"type": "text", "text": "Hello"},
+ {"type": "text", "text": "World"},
+ ]
+ msg = Message(role="user", content=content)
+ assert msg.content == "Hello\nWorld"
+
+ def test_empty_array_content_becomes_empty_string(self):
+ """Empty array content becomes empty string."""
+ msg = Message(role="user", content=[])
+ assert msg.content == ""
+
+
+class TestStreamOptions:
+ """Test StreamOptions model."""
+
+ def test_default_include_usage_is_false(self):
+ """Default include_usage is False."""
+ options = StreamOptions()
+ assert options.include_usage is False
+
+ def test_can_set_include_usage(self):
+ """Can set include_usage to True."""
+ options = StreamOptions(include_usage=True)
+ assert options.include_usage is True
+
+
+class TestChatCompletionRequest:
+ """Test ChatCompletionRequest model."""
+
+ def test_minimal_request(self):
+ """Can create request with just messages."""
+ request = ChatCompletionRequest(messages=[Message(role="user", content="Hi")])
+ assert len(request.messages) == 1
+
+ def test_default_model(self):
+ """Default model is set from constants."""
+ request = ChatCompletionRequest(messages=[Message(role="user", content="Hi")])
+ assert request.model is not None
+
+ def test_temperature_range_validation(self):
+ """Temperature must be between 0 and 2."""
+ # Valid range
+ request = ChatCompletionRequest(
+ messages=[Message(role="user", content="Hi")], temperature=1.5
+ )
+ assert request.temperature == 1.5
+
+ # Invalid - too high
+ with pytest.raises(ValueError):
+ ChatCompletionRequest(messages=[Message(role="user", content="Hi")], temperature=3.0)
+
+ # Invalid - too low
+ with pytest.raises(ValueError):
+ ChatCompletionRequest(messages=[Message(role="user", content="Hi")], temperature=-1.0)
+
+ def test_top_p_range_validation(self):
+ """top_p must be between 0 and 1."""
+ request = ChatCompletionRequest(messages=[Message(role="user", content="Hi")], top_p=0.5)
+ assert request.top_p == 0.5
+
+ with pytest.raises(ValueError):
+ ChatCompletionRequest(messages=[Message(role="user", content="Hi")], top_p=1.5)
+
+ def test_n_must_be_1(self):
+ """n > 1 raises validation error."""
+ with pytest.raises(ValueError) as exc_info:
+ ChatCompletionRequest(messages=[Message(role="user", content="Hi")], n=3)
+ assert "multiple choices" in str(exc_info.value).lower()
+
+ def test_presence_penalty_range(self):
+ """presence_penalty must be between -2 and 2."""
+ request = ChatCompletionRequest(
+ messages=[Message(role="user", content="Hi")], presence_penalty=1.0
+ )
+ assert request.presence_penalty == 1.0
+
+ with pytest.raises(ValueError):
+ ChatCompletionRequest(
+ messages=[Message(role="user", content="Hi")], presence_penalty=3.0
+ )
+
+ def test_frequency_penalty_range(self):
+ """frequency_penalty must be between -2 and 2."""
+ request = ChatCompletionRequest(
+ messages=[Message(role="user", content="Hi")], frequency_penalty=-1.0
+ )
+ assert request.frequency_penalty == -1.0
+
+ with pytest.raises(ValueError):
+ ChatCompletionRequest(
+ messages=[Message(role="user", content="Hi")], frequency_penalty=5.0
+ )
+
+ def test_stream_options(self):
+ """Can set stream_options."""
+ request = ChatCompletionRequest(
+ messages=[Message(role="user", content="Hi")],
+ stream_options=StreamOptions(include_usage=True),
+ )
+ assert request.stream_options.include_usage is True
+
+ def test_log_parameter_info(self):
+ """log_parameter_info logs warnings for unsupported params."""
+ request = ChatCompletionRequest(
+ messages=[Message(role="user", content="Hi")],
+ temperature=0.5,
+ presence_penalty=0.5,
+ stop=["END"],
+ )
+ with patch("src.models.logger") as mock_logger:
+ request.log_parameter_info()
+ # Should have info for temperature, warnings for penalty and stop
+ assert mock_logger.info.called
+ assert mock_logger.warning.called
+
+ def test_get_sampling_instructions_low_temperature(self):
+ """Low temperature produces focused instructions."""
+ request = ChatCompletionRequest(
+ messages=[Message(role="user", content="Hi")], temperature=0.2
+ )
+ instructions = request.get_sampling_instructions()
+ assert instructions is not None
+ assert "deterministic" in instructions.lower() or "focused" in instructions.lower()
+
+ def test_get_sampling_instructions_high_temperature(self):
+ """High temperature produces creative instructions."""
+ request = ChatCompletionRequest(
+ messages=[Message(role="user", content="Hi")], temperature=1.8
+ )
+ instructions = request.get_sampling_instructions()
+ assert instructions is not None
+ assert "creative" in instructions.lower()
+
+ def test_get_sampling_instructions_low_top_p(self):
+ """Low top_p produces focused instructions."""
+ request = ChatCompletionRequest(messages=[Message(role="user", content="Hi")], top_p=0.3)
+ instructions = request.get_sampling_instructions()
+ assert instructions is not None
+ assert "probable" in instructions.lower() or "mainstream" in instructions.lower()
+
+ def test_get_sampling_instructions_default_returns_none(self):
+ """Default values return no instructions."""
+ request = ChatCompletionRequest(messages=[Message(role="user", content="Hi")])
+ instructions = request.get_sampling_instructions()
+ assert instructions is None
+
+ def test_to_claude_options_basic(self):
+ """to_claude_options() returns model."""
+ request = ChatCompletionRequest(
+ model="claude-sonnet-4-5-20250929",
+ messages=[Message(role="user", content="Hi")],
+ )
+ options = request.to_claude_options()
+ assert options["model"] == "claude-sonnet-4-5-20250929"
+
+ def test_to_claude_options_with_max_tokens(self):
+ """to_claude_options() maps max_tokens to max_thinking_tokens."""
+ request = ChatCompletionRequest(
+ messages=[Message(role="user", content="Hi")], max_tokens=500
+ )
+ options = request.to_claude_options()
+ assert options.get("max_thinking_tokens") == 500
+
+ def test_to_claude_options_prefers_max_completion_tokens(self):
+ """max_completion_tokens takes precedence over max_tokens."""
+ request = ChatCompletionRequest(
+ messages=[Message(role="user", content="Hi")],
+ max_tokens=500,
+ max_completion_tokens=1000,
+ )
+ options = request.to_claude_options()
+ assert options.get("max_thinking_tokens") == 1000
+
+
+class TestChatCompletionResponse:
+ """Test ChatCompletionResponse model."""
+
+ def test_response_has_auto_generated_id(self):
+ """Response has auto-generated ID starting with chatcmpl-."""
+ response = ChatCompletionResponse(
+ model="claude-3",
+ choices=[
+ Choice(
+ index=0,
+ message=Message(role="assistant", content="Hello"),
+ finish_reason="stop",
+ )
+ ],
+ )
+ assert response.id.startswith("chatcmpl-")
+
+ def test_response_object_type(self):
+ """Response object is chat.completion."""
+ response = ChatCompletionResponse(
+ model="claude-3",
+ choices=[
+ Choice(
+ index=0,
+ message=Message(role="assistant", content="Hello"),
+ finish_reason="stop",
+ )
+ ],
+ )
+ assert response.object == "chat.completion"
+
+ def test_response_created_timestamp(self):
+ """Response has created timestamp."""
+ before = int(datetime.now().timestamp())
+ response = ChatCompletionResponse(
+ model="claude-3",
+ choices=[
+ Choice(
+ index=0,
+ message=Message(role="assistant", content="Hello"),
+ finish_reason="stop",
+ )
+ ],
+ )
+ after = int(datetime.now().timestamp())
+ assert before <= response.created <= after
+
+
+class TestChatCompletionStreamResponse:
+ """Test ChatCompletionStreamResponse model."""
+
+ def test_stream_response_object_type(self):
+ """Stream response object is chat.completion.chunk."""
+ response = ChatCompletionStreamResponse(
+ model="claude-3",
+ choices=[StreamChoice(index=0, delta={"content": "Hello"})],
+ )
+ assert response.object == "chat.completion.chunk"
+
+ def test_stream_response_with_usage(self):
+ """Stream response can include usage."""
+ response = ChatCompletionStreamResponse(
+ model="claude-3",
+ choices=[StreamChoice(index=0, delta={}, finish_reason="stop")],
+ usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15),
+ )
+ assert response.usage.total_tokens == 15
+
+
+class TestErrorModels:
+ """Test error response models."""
+
+ def test_error_detail(self):
+ """Can create ErrorDetail."""
+ error = ErrorDetail(message="Something went wrong", type="invalid_request")
+ assert error.message == "Something went wrong"
+ assert error.type == "invalid_request"
+
+ def test_error_response(self):
+ """Can create ErrorResponse."""
+ response = ErrorResponse(
+ error=ErrorDetail(message="Bad request", type="invalid_request", code="400")
+ )
+ assert response.error.code == "400"
+
+
+class TestSessionModels:
+ """Test session-related models."""
+
+ def test_session_info(self):
+ """Can create SessionInfo."""
+ now = datetime.utcnow()
+ info = SessionInfo(
+ session_id="test-123",
+ created_at=now,
+ last_accessed=now,
+ message_count=5,
+ expires_at=now,
+ )
+ assert info.session_id == "test-123"
+ assert info.message_count == 5
+
+ def test_session_list_response(self):
+ """Can create SessionListResponse."""
+ now = datetime.utcnow()
+ response = SessionListResponse(
+ sessions=[
+ SessionInfo(
+ session_id="s1",
+ created_at=now,
+ last_accessed=now,
+ message_count=1,
+ expires_at=now,
+ )
+ ],
+ total=1,
+ )
+ assert response.total == 1
+
+
+class TestToolModels:
+ """Test tool-related models."""
+
+ def test_tool_metadata_response(self):
+ """Can create ToolMetadataResponse."""
+ tool = ToolMetadataResponse(
+ name="Read",
+ description="Read a file",
+ category="filesystem",
+ parameters={"path": "string"},
+ examples=["Read file.txt"],
+ is_safe=True,
+ requires_network=False,
+ )
+ assert tool.name == "Read"
+
+ def test_tool_list_response(self):
+ """Can create ToolListResponse."""
+ response = ToolListResponse(tools=[], total=0)
+ assert response.total == 0
+
+ def test_tool_configuration_response(self):
+ """Can create ToolConfigurationResponse."""
+ now = datetime.utcnow()
+ response = ToolConfigurationResponse(
+ allowed_tools=["Read"],
+ effective_tools=["Read", "Write"],
+ created_at=now,
+ updated_at=now,
+ )
+ assert "Read" in response.allowed_tools
+
+ def test_tool_configuration_request(self):
+ """Can create ToolConfigurationRequest."""
+ request = ToolConfigurationRequest(allowed_tools=["Read", "Write"], session_id="test")
+ assert len(request.allowed_tools) == 2
+
+ def test_tool_validation_response(self):
+ """Can create ToolValidationResponse."""
+ response = ToolValidationResponse(
+ valid={"Read": True, "Invalid": False}, invalid_tools=["Invalid"]
+ )
+ assert "Invalid" in response.invalid_tools
+
+
+class TestMCPModels:
+ """Test MCP-related models."""
+
+ def test_mcp_server_config_request(self):
+ """Can create MCPServerConfigRequest."""
+ config = MCPServerConfigRequest(
+ name="test-server",
+ command="node",
+ args=["server.js"],
+ description="Test MCP server",
+ )
+ assert config.name == "test-server"
+
+ def test_mcp_server_name_validation(self):
+ """Server name is validated."""
+ # Valid name
+ config = MCPServerConfigRequest(name="my-server.v1", command="node")
+ assert config.name == "my-server.v1"
+
+ # Empty name
+ with pytest.raises(ValueError):
+ MCPServerConfigRequest(name="", command="node")
+
+ # Invalid characters
+ with pytest.raises(ValueError):
+ MCPServerConfigRequest(name="server name with spaces", command="node")
+
+ def test_mcp_server_command_validation(self):
+ """Server command is validated."""
+ with pytest.raises(ValueError):
+ MCPServerConfigRequest(name="server", command="")
+
+ def test_mcp_server_info_response(self):
+ """Can create MCPServerInfoResponse."""
+ info = MCPServerInfoResponse(
+ name="test",
+ command="node",
+ args=[],
+ description="Test",
+ enabled=True,
+ connected=False,
+ tools_count=5,
+ )
+ assert info.tools_count == 5
+
+ def test_mcp_servers_list_response(self):
+ """Can create MCPServersListResponse."""
+ response = MCPServersListResponse(servers=[], total=0)
+ assert response.total == 0
+
+ def test_mcp_connection_request(self):
+ """Can create MCPConnectionRequest."""
+ request = MCPConnectionRequest(server_name="my-server")
+ assert request.server_name == "my-server"
+
+ def test_mcp_connection_request_validation(self):
+ """Server name in connection request is validated."""
+ with pytest.raises(ValueError):
+ MCPConnectionRequest(server_name="")
+
+ def test_mcp_tool_call_request(self):
+ """Can create MCPToolCallRequest."""
+ request = MCPToolCallRequest(
+ server_name="server", tool_name="read_file", arguments={"path": "/tmp/test"}
+ )
+ assert request.tool_name == "read_file"
+
+ def test_mcp_tool_call_request_validation(self):
+ """Tool call request validates names."""
+ with pytest.raises(ValueError):
+ MCPToolCallRequest(server_name="", tool_name="tool")
+
+ with pytest.raises(ValueError):
+ MCPToolCallRequest(server_name="server", tool_name="")
+
+
+class TestAnthropicModels:
+ """Test Anthropic API compatible models."""
+
+ def test_anthropic_text_block(self):
+ """Can create AnthropicTextBlock."""
+ block = AnthropicTextBlock(text="Hello world")
+ assert block.type == "text"
+ assert block.text == "Hello world"
+
+ def test_anthropic_message(self):
+ """Can create AnthropicMessage."""
+ msg = AnthropicMessage(role="user", content="Hello")
+ assert msg.role == "user"
+
+ def test_anthropic_message_with_blocks(self):
+ """Can create AnthropicMessage with content blocks."""
+ msg = AnthropicMessage(role="assistant", content=[AnthropicTextBlock(text="Hi there")])
+ assert len(msg.content) == 1
+
+ def test_anthropic_messages_request(self):
+ """Can create AnthropicMessagesRequest."""
+ request = AnthropicMessagesRequest(
+ model="claude-3-opus",
+ messages=[AnthropicMessage(role="user", content="Hello")],
+ max_tokens=1000,
+ )
+ assert request.model == "claude-3-opus"
+ assert request.max_tokens == 1000
+
+ def test_anthropic_messages_request_to_openai(self):
+ """to_openai_messages() converts correctly."""
+ request = AnthropicMessagesRequest(
+ model="claude-3",
+ messages=[
+ AnthropicMessage(role="user", content="Hello"),
+ AnthropicMessage(
+ role="assistant",
+ content=[
+ AnthropicTextBlock(text="Part 1"),
+ AnthropicTextBlock(text="Part 2"),
+ ],
+ ),
+ ],
+ )
+ openai_msgs = request.to_openai_messages()
+ assert len(openai_msgs) == 2
+ assert openai_msgs[0].content == "Hello"
+ assert openai_msgs[1].content == "Part 1\nPart 2"
+
+ def test_anthropic_usage(self):
+ """Can create AnthropicUsage."""
+ usage = AnthropicUsage(input_tokens=100, output_tokens=50)
+ assert usage.input_tokens == 100
+
+ def test_anthropic_messages_response(self):
+ """Can create AnthropicMessagesResponse."""
+ response = AnthropicMessagesResponse(
+ model="claude-3",
+ content=[AnthropicTextBlock(text="Response text")],
+ usage=AnthropicUsage(input_tokens=10, output_tokens=5),
+ )
+ assert response.type == "message"
+ assert response.role == "assistant"
+ assert response.stop_reason == "end_turn"
+ assert response.id.startswith("msg_")
diff --git a/tests/test_non_streaming.py b/tests/test_non_streaming.py
index 61170c0..ec94673 100644
--- a/tests/test_non_streaming.py
+++ b/tests/test_non_streaming.py
@@ -5,60 +5,58 @@
import os
import json
+import pytest
import requests
+from tests.conftest import requires_server
+
# Set debug mode
-os.environ['DEBUG_MODE'] = 'true'
+os.environ["DEBUG_MODE"] = "true"
+
+@requires_server
def test_non_streaming():
"""Test that non-streaming responses work correctly."""
print("๐งช Testing non-streaming response...")
-
+
# Simple request with streaming disabled
request_data = {
"model": "claude-3-7-sonnet-20250219",
- "messages": [
- {
- "role": "user",
- "content": "What is 2+2?"
- }
- ],
+ "messages": [{"role": "user", "content": "What is 2+2?"}],
"stream": False,
- "temperature": 0.0
+ "temperature": 0.0,
}
-
+
try:
# Send non-streaming request
response = requests.post(
- "http://localhost:8000/v1/chat/completions",
- json=request_data,
- timeout=30
+ "http://localhost:8000/v1/chat/completions", json=request_data, timeout=30
)
-
+
print(f"โ
Response status: {response.status_code}")
-
+
if response.status_code != 200:
print(f"โ Request failed: {response.text}")
return False
-
+
# Parse response
data = response.json()
-
+
# Check response structure
- if 'choices' in data and len(data['choices']) > 0:
- message = data['choices'][0]['message']
- content = message['content']
-
+ if "choices" in data and len(data["choices"]) > 0:
+ message = data["choices"][0]["message"]
+ content = message["content"]
+
print(f"๐ Response content: {content}")
-
+
# Check if we got actual content instead of fallback message
fallback_messages = [
"I'm unable to provide a response at the moment",
- "I understand you're testing the system"
+ "I understand you're testing the system",
]
-
+
is_fallback = any(msg in content for msg in fallback_messages)
-
+
if not is_fallback and len(content) > 0:
print("\n๐ Non-streaming response is working!")
print("โ
Real content extracted successfully")
@@ -70,18 +68,19 @@ def test_non_streaming():
else:
print("โ Unexpected response structure")
return False
-
+
except Exception as e:
print(f"โ Test failed with exception: {e}")
return False
+
def main():
"""Test non-streaming responses."""
print("๐ Testing Non-Streaming Responses")
print("=" * 50)
-
+
success = test_non_streaming()
-
+
print("\n" + "=" * 50)
if success:
print("๐ Non-streaming test PASSED!")
@@ -89,9 +88,10 @@ def main():
else:
print("โ Non-streaming test FAILED")
print("โ ๏ธ Issue may still persist")
-
+
return success
+
if __name__ == "__main__":
success = main()
- exit(0 if success else 1)
\ No newline at end of file
+ exit(0 if success else 1)
diff --git a/tests/test_parameter_mapping.py b/tests/test_parameter_mapping.py
index e27a7f6..d6bcaa2 100644
--- a/tests/test_parameter_mapping.py
+++ b/tests/test_parameter_mapping.py
@@ -1,33 +1,41 @@
#!/usr/bin/env python3
"""
Test script demonstrating OpenAI to Claude Code SDK parameter mapping.
+
+These are integration tests that require a running server.
+Run with: poetry run pytest tests/test_parameter_mapping.py -v
"""
import asyncio
import json
+import pytest
import requests
from typing import Dict, Any
+from tests.conftest import requires_server
+
# Test server URL
BASE_URL = "http://localhost:8000"
+
+@requires_server
def test_basic_completion():
"""Test basic chat completion with OpenAI parameters."""
print("=== Testing Basic Completion ===")
-
+
payload = {
"model": "claude-3-5-sonnet-20241022",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
- {"role": "user", "content": "Say hello in a creative way."}
+ {"role": "user", "content": "Say hello in a creative way."},
],
"temperature": 0.7, # Will be ignored with warning
- "max_tokens": 100, # Will be ignored with warning
- "stream": False
+ "max_tokens": 100, # Will be ignored with warning
+ "stream": False,
}
-
+
response = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload)
-
+
if response.status_code == 200:
print("โ
Request successful")
result = response.json()
@@ -36,31 +44,27 @@ def test_basic_completion():
print(f"โ Request failed: {response.status_code}")
print(response.text)
+
+@requires_server
def test_with_claude_headers():
"""Test completion with Claude-specific headers."""
print("\n=== Testing with Claude-Specific Headers ===")
-
+
payload = {
- "model": "claude-3-5-sonnet-20241022",
- "messages": [
- {"role": "user", "content": "List the files in the current directory"}
- ],
- "stream": False
+ "model": "claude-3-5-sonnet-20241022",
+ "messages": [{"role": "user", "content": "List the files in the current directory"}],
+ "stream": False,
}
-
+
headers = {
"Content-Type": "application/json",
"X-Claude-Max-Turns": "5",
"X-Claude-Allowed-Tools": "ls,pwd,cat",
- "X-Claude-Permission-Mode": "acceptEdits"
+ "X-Claude-Permission-Mode": "acceptEdits",
}
-
- response = requests.post(
- f"{BASE_URL}/v1/chat/completions",
- json=payload,
- headers=headers
- )
-
+
+ response = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload, headers=headers)
+
if response.status_code == 200:
print("โ
Request with Claude headers successful")
result = response.json()
@@ -69,10 +73,12 @@ def test_with_claude_headers():
print(f"โ Request failed: {response.status_code}")
print(response.text)
+
+@requires_server
def test_compatibility_check():
"""Test the compatibility endpoint."""
print("\n=== Testing Compatibility Check ===")
-
+
payload = {
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hello"}],
@@ -84,11 +90,11 @@ def test_compatibility_check():
"logit_bias": {"hello": 2.0},
"stop": ["END"],
"n": 1,
- "user": "test_user"
+ "user": "test_user",
}
-
+
response = requests.post(f"{BASE_URL}/v1/compatibility", json=payload)
-
+
if response.status_code == 200:
print("โ
Compatibility check successful")
result = response.json()
@@ -97,54 +103,51 @@ def test_compatibility_check():
print(f"โ Compatibility check failed: {response.status_code}")
print(response.text)
+
+@requires_server
def test_parameter_validation():
"""Test parameter validation (should fail)."""
print("\n=== Testing Parameter Validation ===")
-
+
# Test with n > 1 (should fail)
payload = {
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hello"}],
- "n": 3 # Should fail validation
+ "n": 3, # Should fail validation
}
-
+
response = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload)
-
+
if response.status_code == 422:
print("โ
Validation correctly rejected n > 1")
print(response.json())
else:
print(f"โ Expected validation error, got: {response.status_code}")
+
def test_streaming_with_parameters():
"""Test streaming response with unsupported parameters."""
print("\n=== Testing Streaming with Unsupported Parameters ===")
-
+
payload = {
"model": "claude-3-5-sonnet-20241022",
- "messages": [
- {"role": "user", "content": "Write a short poem about programming"}
- ],
+ "messages": [{"role": "user", "content": "Write a short poem about programming"}],
"temperature": 0.9, # Will be warned about
- "max_tokens": 200, # Will be warned about
- "stream": True
+ "max_tokens": 200, # Will be warned about
+ "stream": True,
}
-
+
try:
- response = requests.post(
- f"{BASE_URL}/v1/chat/completions",
- json=payload,
- stream=True
- )
-
+ response = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload, stream=True)
+
if response.status_code == 200:
print("โ
Streaming request successful")
print("First few chunks:")
count = 0
for line in response.iter_lines():
if line and count < 5:
- line_str = line.decode('utf-8')
- if line_str.startswith('data: ') and not line_str.endswith('[DONE]'):
+ line_str = line.decode("utf-8")
+ if line_str.startswith("data: ") and not line_str.endswith("[DONE]"):
print(f" {line_str}")
count += 1
else:
@@ -152,11 +155,12 @@ def test_streaming_with_parameters():
except Exception as e:
print(f"โ Streaming test error: {e}")
+
def main():
"""Run all tests."""
print("OpenAI to Claude Code SDK Parameter Mapping Tests")
print("=" * 50)
-
+
try:
# Check if server is running
response = requests.get(f"{BASE_URL}/health")
@@ -164,23 +168,26 @@ def main():
print("โ Server is not running. Start it with: poetry run python main.py")
return
print("โ
Server is running")
-
+
# Run tests
test_basic_completion()
test_with_claude_headers()
test_compatibility_check()
test_parameter_validation()
test_streaming_with_parameters()
-
+
print("\n" + "=" * 50)
print("๐ All tests completed!")
print("\nTo see parameter warnings in detail, run the server with:")
- print("PYTHONPATH=. poetry run python -c \"import logging; logging.basicConfig(level=logging.DEBUG); exec(open('main.py').read())\"")
-
+ print(
+ "PYTHONPATH=. poetry run python -c \"import logging; logging.basicConfig(level=logging.DEBUG); exec(open('main.py').read())\""
+ )
+
except requests.exceptions.ConnectionError:
print("โ Cannot connect to server. Make sure it's running on port 8000")
except Exception as e:
print(f"โ Test error: {e}")
+
if __name__ == "__main__":
- main()
\ No newline at end of file
+ main()
diff --git a/tests/test_parameter_validator_unit.py b/tests/test_parameter_validator_unit.py
new file mode 100644
index 0000000..3c31945
--- /dev/null
+++ b/tests/test_parameter_validator_unit.py
@@ -0,0 +1,364 @@
+#!/usr/bin/env python3
+"""
+Unit tests for src/parameter_validator.py
+
+Tests the ParameterValidator and CompatibilityReporter classes.
+These are pure unit tests that don't require a running server.
+"""
+
+import pytest
+from unittest.mock import MagicMock, patch
+
+from src.parameter_validator import ParameterValidator, CompatibilityReporter
+from src.models import Message, ChatCompletionRequest
+
+
+class TestParameterValidatorValidateModel:
+ """Test ParameterValidator.validate_model()"""
+
+ def test_valid_model_returns_true(self):
+ """Known supported model returns True."""
+ result = ParameterValidator.validate_model("claude-sonnet-4-5-20250929")
+ assert result is True
+
+ def test_unknown_model_returns_true_with_warning(self):
+ """Unknown model returns True (graceful degradation) with warning logged."""
+ with patch("src.parameter_validator.logger") as mock_logger:
+ result = ParameterValidator.validate_model("unknown-model-xyz")
+ assert result is True
+ mock_logger.warning.assert_called_once()
+ assert "unknown-model-xyz" in str(mock_logger.warning.call_args)
+
+ def test_all_known_models_valid(self):
+ """All models in SUPPORTED_MODELS are valid."""
+ for model in ParameterValidator.SUPPORTED_MODELS:
+ assert ParameterValidator.validate_model(model) is True
+
+
+class TestParameterValidatorValidatePermissionMode:
+ """Test ParameterValidator.validate_permission_mode()"""
+
+ def test_valid_permission_mode_default(self):
+ """'default' permission mode is valid."""
+ assert ParameterValidator.validate_permission_mode("default") is True
+
+ def test_valid_permission_mode_accept_edits(self):
+ """'acceptEdits' permission mode is valid."""
+ assert ParameterValidator.validate_permission_mode("acceptEdits") is True
+
+ def test_valid_permission_mode_bypass(self):
+ """'bypassPermissions' permission mode is valid."""
+ assert ParameterValidator.validate_permission_mode("bypassPermissions") is True
+
+ def test_invalid_permission_mode_returns_false(self):
+ """Invalid permission mode returns False with error logged."""
+ with patch("src.parameter_validator.logger") as mock_logger:
+ result = ParameterValidator.validate_permission_mode("invalidMode")
+ assert result is False
+ mock_logger.error.assert_called_once()
+ assert "invalidMode" in str(mock_logger.error.call_args)
+
+
+class TestParameterValidatorValidateTools:
+ """Test ParameterValidator.validate_tools()"""
+
+ def test_valid_tools_list(self):
+ """List of valid tool names returns True."""
+ tools = ["Read", "Write", "Bash"]
+ assert ParameterValidator.validate_tools(tools) is True
+
+ def test_empty_string_tool_returns_false(self):
+ """Tool list with empty string returns False."""
+ with patch("src.parameter_validator.logger") as mock_logger:
+ result = ParameterValidator.validate_tools(["Read", "", "Bash"])
+ assert result is False
+ mock_logger.error.assert_called_once()
+
+ def test_whitespace_only_tool_returns_false(self):
+ """Tool list with whitespace-only string returns False."""
+ with patch("src.parameter_validator.logger") as mock_logger:
+ result = ParameterValidator.validate_tools(["Read", " ", "Bash"])
+ assert result is False
+ mock_logger.error.assert_called_once()
+
+ def test_non_string_tool_returns_false(self):
+ """Tool list with non-string element returns False."""
+ with patch("src.parameter_validator.logger") as mock_logger:
+ result = ParameterValidator.validate_tools(["Read", 123, "Bash"])
+ assert result is False
+ mock_logger.error.assert_called_once()
+
+ def test_empty_list_returns_true(self):
+ """Empty tool list returns True (no invalid tools)."""
+ assert ParameterValidator.validate_tools([]) is True
+
+
+class TestParameterValidatorCreateEnhancedOptions:
+ """Test ParameterValidator.create_enhanced_options()"""
+
+ @pytest.fixture
+ def basic_request(self):
+ """Create a basic chat completion request for testing."""
+ return ChatCompletionRequest(
+ model="claude-sonnet-4-5-20250929",
+ messages=[Message(role="user", content="Hello")],
+ )
+
+ def test_basic_options_from_request(self, basic_request):
+ """Basic request options are extracted correctly."""
+ options = ParameterValidator.create_enhanced_options(basic_request)
+ assert "model" in options
+ assert options["model"] == "claude-sonnet-4-5-20250929"
+
+ def test_max_turns_added_when_provided(self, basic_request):
+ """max_turns is added to options when provided."""
+ options = ParameterValidator.create_enhanced_options(basic_request, max_turns=5)
+ assert options.get("max_turns") == 5
+
+ def test_max_turns_warning_for_out_of_range(self, basic_request):
+ """Warning logged when max_turns is out of recommended range."""
+ with patch("src.parameter_validator.logger") as mock_logger:
+ # Test value below range
+ ParameterValidator.create_enhanced_options(basic_request, max_turns=0)
+ mock_logger.warning.assert_called()
+
+ mock_logger.reset_mock()
+
+ # Test value above range
+ ParameterValidator.create_enhanced_options(basic_request, max_turns=150)
+ mock_logger.warning.assert_called()
+
+ def test_allowed_tools_added_when_valid(self, basic_request):
+ """allowed_tools is added when valid tools provided."""
+ tools = ["Read", "Write"]
+ options = ParameterValidator.create_enhanced_options(basic_request, allowed_tools=tools)
+ assert options.get("allowed_tools") == tools
+
+ def test_disallowed_tools_added_when_valid(self, basic_request):
+ """disallowed_tools is added when valid tools provided."""
+ tools = ["Bash", "Edit"]
+ options = ParameterValidator.create_enhanced_options(basic_request, disallowed_tools=tools)
+ assert options.get("disallowed_tools") == tools
+
+ def test_permission_mode_added_when_valid(self, basic_request):
+ """permission_mode is added when valid mode provided."""
+ options = ParameterValidator.create_enhanced_options(
+ basic_request, permission_mode="acceptEdits"
+ )
+ assert options.get("permission_mode") == "acceptEdits"
+
+ def test_permission_mode_not_added_when_invalid(self, basic_request):
+ """permission_mode is not added when invalid mode provided."""
+ options = ParameterValidator.create_enhanced_options(
+ basic_request, permission_mode="invalidMode"
+ )
+ assert "permission_mode" not in options
+
+ def test_max_thinking_tokens_added(self, basic_request):
+ """max_thinking_tokens is added when provided."""
+ options = ParameterValidator.create_enhanced_options(
+ basic_request, max_thinking_tokens=5000
+ )
+ assert options.get("max_thinking_tokens") == 5000
+
+ def test_max_thinking_tokens_warning_for_out_of_range(self, basic_request):
+ """Warning logged when max_thinking_tokens is out of range."""
+ with patch("src.parameter_validator.logger") as mock_logger:
+ # Test negative value
+ ParameterValidator.create_enhanced_options(basic_request, max_thinking_tokens=-100)
+ mock_logger.warning.assert_called()
+
+ mock_logger.reset_mock()
+
+ # Test value above range
+ ParameterValidator.create_enhanced_options(basic_request, max_thinking_tokens=60000)
+ mock_logger.warning.assert_called()
+
+
+class TestParameterValidatorExtractClaudeHeaders:
+ """Test ParameterValidator.extract_claude_headers()"""
+
+ def test_empty_headers_returns_empty_dict(self):
+ """Empty headers dict returns empty options dict."""
+ result = ParameterValidator.extract_claude_headers({})
+ assert result == {}
+
+ def test_extracts_max_turns(self):
+ """X-Claude-Max-Turns header is extracted correctly."""
+ headers = {"x-claude-max-turns": "10"}
+ result = ParameterValidator.extract_claude_headers(headers)
+ assert result.get("max_turns") == 10
+
+ def test_invalid_max_turns_logs_warning(self):
+ """Invalid X-Claude-Max-Turns logs warning and is ignored."""
+ headers = {"x-claude-max-turns": "not-a-number"}
+ with patch("src.parameter_validator.logger") as mock_logger:
+ result = ParameterValidator.extract_claude_headers(headers)
+ assert "max_turns" not in result
+ mock_logger.warning.assert_called_once()
+
+ def test_extracts_allowed_tools(self):
+ """X-Claude-Allowed-Tools header is extracted correctly."""
+ headers = {"x-claude-allowed-tools": "Read,Write,Bash"}
+ result = ParameterValidator.extract_claude_headers(headers)
+ assert result.get("allowed_tools") == ["Read", "Write", "Bash"]
+
+ def test_allowed_tools_strips_whitespace(self):
+ """Tool names have whitespace stripped."""
+ headers = {"x-claude-allowed-tools": " Read , Write , Bash "}
+ result = ParameterValidator.extract_claude_headers(headers)
+ assert result.get("allowed_tools") == ["Read", "Write", "Bash"]
+
+ def test_extracts_disallowed_tools(self):
+ """X-Claude-Disallowed-Tools header is extracted correctly."""
+ headers = {"x-claude-disallowed-tools": "Edit,Delete"}
+ result = ParameterValidator.extract_claude_headers(headers)
+ assert result.get("disallowed_tools") == ["Edit", "Delete"]
+
+ def test_extracts_permission_mode(self):
+ """X-Claude-Permission-Mode header is extracted correctly."""
+ headers = {"x-claude-permission-mode": "bypassPermissions"}
+ result = ParameterValidator.extract_claude_headers(headers)
+ assert result.get("permission_mode") == "bypassPermissions"
+
+ def test_extracts_max_thinking_tokens(self):
+ """X-Claude-Max-Thinking-Tokens header is extracted correctly."""
+ headers = {"x-claude-max-thinking-tokens": "5000"}
+ result = ParameterValidator.extract_claude_headers(headers)
+ assert result.get("max_thinking_tokens") == 5000
+
+ def test_invalid_max_thinking_tokens_logs_warning(self):
+ """Invalid X-Claude-Max-Thinking-Tokens logs warning."""
+ headers = {"x-claude-max-thinking-tokens": "invalid"}
+ with patch("src.parameter_validator.logger") as mock_logger:
+ result = ParameterValidator.extract_claude_headers(headers)
+ assert "max_thinking_tokens" not in result
+ mock_logger.warning.assert_called_once()
+
+ def test_extracts_multiple_headers(self):
+ """Multiple Claude headers are all extracted."""
+ headers = {
+ "x-claude-max-turns": "5",
+ "x-claude-allowed-tools": "Read,Write",
+ "x-claude-permission-mode": "default",
+ "x-claude-max-thinking-tokens": "3000",
+ }
+ result = ParameterValidator.extract_claude_headers(headers)
+ assert result.get("max_turns") == 5
+ assert result.get("allowed_tools") == ["Read", "Write"]
+ assert result.get("permission_mode") == "default"
+ assert result.get("max_thinking_tokens") == 3000
+
+
+class TestCompatibilityReporter:
+ """Test CompatibilityReporter.generate_compatibility_report()"""
+
+ @pytest.fixture
+ def minimal_request(self):
+ """Request with minimal parameters."""
+ return ChatCompletionRequest(
+ model="claude-sonnet-4-5-20250929",
+ messages=[Message(role="user", content="Hello")],
+ )
+
+ def test_supported_parameters_identified(self, minimal_request):
+ """Model and messages are identified as supported."""
+ report = CompatibilityReporter.generate_compatibility_report(minimal_request)
+ assert "model" in report["supported_parameters"]
+ assert "messages" in report["supported_parameters"]
+
+ def test_stream_identified_as_supported(self):
+ """Stream parameter is identified as supported."""
+ request = ChatCompletionRequest(
+ model="claude-sonnet-4-5-20250929",
+ messages=[Message(role="user", content="Hello")],
+ stream=True,
+ )
+ report = CompatibilityReporter.generate_compatibility_report(request)
+ assert "stream" in report["supported_parameters"]
+
+ def test_user_identified_as_supported(self):
+ """User parameter is identified as supported (for logging)."""
+ request = ChatCompletionRequest(
+ model="claude-sonnet-4-5-20250929",
+ messages=[Message(role="user", content="Hello")],
+ user="test_user",
+ )
+ report = CompatibilityReporter.generate_compatibility_report(request)
+ assert "user (for logging)" in report["supported_parameters"]
+
+ def test_temperature_unsupported_when_not_default(self):
+ """Non-default temperature is flagged as unsupported."""
+ request = ChatCompletionRequest(
+ model="claude-sonnet-4-5-20250929",
+ messages=[Message(role="user", content="Hello")],
+ temperature=0.8,
+ )
+ report = CompatibilityReporter.generate_compatibility_report(request)
+ assert "temperature" in report["unsupported_parameters"]
+ assert len(report["suggestions"]) > 0
+
+ def test_top_p_unsupported_when_not_default(self):
+ """Non-default top_p is flagged as unsupported."""
+ request = ChatCompletionRequest(
+ model="claude-sonnet-4-5-20250929",
+ messages=[Message(role="user", content="Hello")],
+ top_p=0.9,
+ )
+ report = CompatibilityReporter.generate_compatibility_report(request)
+ assert "top_p" in report["unsupported_parameters"]
+
+ def test_max_tokens_unsupported(self):
+ """max_tokens is flagged as unsupported."""
+ request = ChatCompletionRequest(
+ model="claude-sonnet-4-5-20250929",
+ messages=[Message(role="user", content="Hello")],
+ max_tokens=500,
+ )
+ report = CompatibilityReporter.generate_compatibility_report(request)
+ assert "max_tokens" in report["unsupported_parameters"]
+
+ def test_stop_sequences_unsupported(self):
+ """stop sequences are flagged as unsupported."""
+ request = ChatCompletionRequest(
+ model="claude-sonnet-4-5-20250929",
+ messages=[Message(role="user", content="Hello")],
+ stop=["END"],
+ )
+ report = CompatibilityReporter.generate_compatibility_report(request)
+ assert "stop" in report["unsupported_parameters"]
+
+ def test_penalties_unsupported(self):
+ """presence_penalty and frequency_penalty are flagged as unsupported."""
+ request = ChatCompletionRequest(
+ model="claude-sonnet-4-5-20250929",
+ messages=[Message(role="user", content="Hello")],
+ presence_penalty=0.5,
+ frequency_penalty=0.5,
+ )
+ report = CompatibilityReporter.generate_compatibility_report(request)
+ assert "presence_penalty" in report["unsupported_parameters"]
+ assert "frequency_penalty" in report["unsupported_parameters"]
+
+ def test_logit_bias_unsupported(self):
+ """logit_bias is flagged as unsupported."""
+ request = ChatCompletionRequest(
+ model="claude-sonnet-4-5-20250929",
+ messages=[Message(role="user", content="Hello")],
+ logit_bias={"hello": 2.0},
+ )
+ report = CompatibilityReporter.generate_compatibility_report(request)
+ assert "logit_bias" in report["unsupported_parameters"]
+
+ def test_report_has_all_sections(self, minimal_request):
+ """Report contains all expected sections."""
+ report = CompatibilityReporter.generate_compatibility_report(minimal_request)
+ assert "supported_parameters" in report
+ assert "unsupported_parameters" in report
+ assert "warnings" in report
+ assert "suggestions" in report
+
+ def test_minimal_request_has_no_unsupported(self, minimal_request):
+ """Minimal request with defaults has no unsupported parameters."""
+ report = CompatibilityReporter.generate_compatibility_report(minimal_request)
+ assert len(report["unsupported_parameters"]) == 0
diff --git a/tests/test_property_based.py b/tests/test_property_based.py
new file mode 100644
index 0000000..f0b3edf
--- /dev/null
+++ b/tests/test_property_based.py
@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+"""
+Property-based tests using Hypothesis for edge case discovery.
+
+These tests generate random inputs to find edge cases that manual testing might miss.
+"""
+
+import pytest
+from hypothesis import given, strategies as st, settings, assume
+
+from src.message_adapter import MessageAdapter
+from src.parameter_validator import ParameterValidator
+from src.constants import CLAUDE_MODELS
+
+
+class TestMessageAdapterProperties:
+ """Property-based tests for MessageAdapter."""
+
+ @given(content=st.text(min_size=1, max_size=1000))
+ @settings(max_examples=50)
+ def test_filter_content_handles_any_text(self, content: str):
+ """filter_content should handle any text without crashing."""
+ assume("\x00" not in content) # Null bytes
+
+ # Should not crash
+ result = MessageAdapter.filter_content(content)
+ assert isinstance(result, str)
+
+ @given(text=st.text(min_size=0, max_size=10000))
+ @settings(max_examples=30)
+ def test_estimate_tokens_returns_positive(self, text: str):
+ """Token estimation should return a non-negative integer."""
+ result = MessageAdapter.estimate_tokens(text)
+ assert isinstance(result, int)
+ assert result >= 0
+
+
+class TestParameterValidatorProperties:
+ """Property-based tests for ParameterValidator."""
+
+ @given(model=st.sampled_from(CLAUDE_MODELS))
+ @settings(max_examples=20)
+ def test_valid_model_names_accepted(self, model: str):
+ """Valid Claude model names should be accepted."""
+ result = ParameterValidator.validate_model(model)
+ # validate_model always returns True (allows graceful degradation)
+ assert result is True
+
+ @given(model=st.text(min_size=1, max_size=50))
+ @settings(max_examples=30)
+ def test_any_model_name_accepted_gracefully(self, model: str):
+ """Any model name is accepted (graceful degradation)."""
+ assume(model.strip()) # Non-empty
+
+ # validate_model always returns True to allow trying unknown models
+ result = ParameterValidator.validate_model(model)
+ assert result is True
+
+ @given(permission_mode=st.sampled_from(["default", "acceptEdits", "bypassPermissions", "plan"]))
+ @settings(max_examples=10)
+ def test_valid_permission_modes_accepted(self, permission_mode: str):
+ """Valid permission modes should be accepted."""
+ result = ParameterValidator.validate_permission_mode(permission_mode)
+ assert result is True
+
+ @given(permission_mode=st.text(min_size=1, max_size=30))
+ @settings(max_examples=20)
+ def test_invalid_permission_modes_rejected(self, permission_mode: str):
+ """Invalid permission modes should be rejected."""
+ valid_modes = {"default", "acceptEdits", "bypassPermissions", "plan"}
+ assume(permission_mode not in valid_modes)
+
+ result = ParameterValidator.validate_permission_mode(permission_mode)
+ assert result is False
+
+
+class TestTokenEstimation:
+ """Property-based tests for token estimation consistency."""
+
+ @given(text=st.text(min_size=0, max_size=5000))
+ @settings(max_examples=30)
+ def test_token_estimation_non_negative(self, text: str):
+ """Token estimation should always return non-negative value."""
+ tokens = MessageAdapter.estimate_tokens(text)
+ assert tokens >= 0
+
+ @given(
+ prefix=st.text(min_size=10, max_size=100),
+ suffix=st.text(min_size=10, max_size=100),
+ )
+ @settings(max_examples=20)
+ def test_concatenation_increases_tokens(self, prefix: str, suffix: str):
+ """Concatenating text should not decrease token count."""
+ tokens_prefix = MessageAdapter.estimate_tokens(prefix)
+ tokens_suffix = MessageAdapter.estimate_tokens(suffix)
+ tokens_combined = MessageAdapter.estimate_tokens(prefix + suffix)
+
+ # Combined should be at least as many as the larger part
+ # (may be less than sum due to subword tokenization)
+ assert tokens_combined >= min(tokens_prefix, tokens_suffix)
diff --git a/tests/test_rate_limiter_unit.py b/tests/test_rate_limiter_unit.py
new file mode 100644
index 0000000..2b14157
--- /dev/null
+++ b/tests/test_rate_limiter_unit.py
@@ -0,0 +1,283 @@
+#!/usr/bin/env python3
+"""
+Unit tests for src/rate_limiter.py
+
+Tests the rate limiting functions and configuration.
+These are pure unit tests that don't require a running server.
+"""
+
+import pytest
+from unittest.mock import MagicMock, patch
+from fastapi import Request
+from fastapi.responses import JSONResponse
+
+# Need to patch environment before importing the module
+import os
+
+
+class TestGetRateLimitKey:
+ """Test get_rate_limit_key()"""
+
+ def test_returns_remote_address(self):
+ """Should return the remote address from the request."""
+ with patch("src.rate_limiter.get_remote_address") as mock_get_addr:
+ mock_get_addr.return_value = "192.168.1.100"
+ mock_request = MagicMock(spec=Request)
+
+ from src.rate_limiter import get_rate_limit_key
+
+ result = get_rate_limit_key(mock_request)
+ assert result == "192.168.1.100"
+ mock_get_addr.assert_called_once_with(mock_request)
+
+
+class TestCreateRateLimiter:
+ """Test create_rate_limiter()"""
+
+ def test_rate_limiter_disabled_returns_none(self):
+ """When RATE_LIMIT_ENABLED=false, returns None."""
+ with patch.dict(os.environ, {"RATE_LIMIT_ENABLED": "false"}):
+ # Need to reimport to pick up new env var
+ import importlib
+ import src.rate_limiter
+
+ importlib.reload(src.rate_limiter)
+ result = src.rate_limiter.create_rate_limiter()
+ assert result is None
+
+ def test_rate_limiter_enabled_returns_limiter(self):
+ """When RATE_LIMIT_ENABLED=true, returns Limiter instance."""
+ with patch.dict(os.environ, {"RATE_LIMIT_ENABLED": "true"}):
+ import importlib
+ import src.rate_limiter
+
+ importlib.reload(src.rate_limiter)
+ result = src.rate_limiter.create_rate_limiter()
+ assert result is not None
+
+ def test_rate_limiter_disabled_with_0(self):
+ """When RATE_LIMIT_ENABLED=0, returns None."""
+ with patch.dict(os.environ, {"RATE_LIMIT_ENABLED": "0"}):
+ import importlib
+ import src.rate_limiter
+
+ importlib.reload(src.rate_limiter)
+ result = src.rate_limiter.create_rate_limiter()
+ assert result is None
+
+ def test_rate_limiter_disabled_with_no(self):
+ """When RATE_LIMIT_ENABLED=no, returns None."""
+ with patch.dict(os.environ, {"RATE_LIMIT_ENABLED": "no"}):
+ import importlib
+ import src.rate_limiter
+
+ importlib.reload(src.rate_limiter)
+ result = src.rate_limiter.create_rate_limiter()
+ assert result is None
+
+ def test_rate_limiter_enabled_by_default(self):
+ """When RATE_LIMIT_ENABLED not set, rate limiting is enabled."""
+ # Remove the env var if set
+ env_copy = os.environ.copy()
+ if "RATE_LIMIT_ENABLED" in env_copy:
+ del env_copy["RATE_LIMIT_ENABLED"]
+
+ with patch.dict(os.environ, env_copy, clear=True):
+ import importlib
+ import src.rate_limiter
+
+ importlib.reload(src.rate_limiter)
+ result = src.rate_limiter.create_rate_limiter()
+ assert result is not None
+
+
+class TestRateLimitExceededHandler:
+ """Test rate_limit_exceeded_handler()"""
+
+ @pytest.fixture
+ def mock_rate_limit_exceeded(self):
+ """Create a mock RateLimitExceeded exception."""
+ from slowapi.errors import RateLimitExceeded
+
+ # Create a mock Limit object that RateLimitExceeded expects
+ mock_limit = MagicMock()
+ mock_limit.error_message = None
+ mock_exc = MagicMock(spec=RateLimitExceeded)
+ mock_exc.limit = mock_limit
+ return mock_exc
+
+ def test_returns_json_response(self, mock_rate_limit_exceeded):
+ """Returns a JSONResponse."""
+ from src.rate_limiter import rate_limit_exceeded_handler
+
+ mock_request = MagicMock(spec=Request)
+
+ response = rate_limit_exceeded_handler(mock_request, mock_rate_limit_exceeded)
+ assert isinstance(response, JSONResponse)
+
+ def test_returns_429_status(self, mock_rate_limit_exceeded):
+ """Returns 429 Too Many Requests status."""
+ from src.rate_limiter import rate_limit_exceeded_handler
+
+ mock_request = MagicMock(spec=Request)
+
+ response = rate_limit_exceeded_handler(mock_request, mock_rate_limit_exceeded)
+ assert response.status_code == 429
+
+ def test_includes_retry_after_header(self, mock_rate_limit_exceeded):
+ """Response includes Retry-After header."""
+ from src.rate_limiter import rate_limit_exceeded_handler
+
+ mock_request = MagicMock(spec=Request)
+
+ response = rate_limit_exceeded_handler(mock_request, mock_rate_limit_exceeded)
+ assert "Retry-After" in response.headers
+ assert response.headers["Retry-After"] == "60"
+
+
+class TestGetRateLimitForEndpoint:
+ """Test get_rate_limit_for_endpoint()"""
+
+ def test_chat_endpoint_default(self):
+ """Chat endpoint has default rate limit."""
+ with patch.dict(os.environ, {}, clear=False):
+ # Ensure no override
+ if "RATE_LIMIT_CHAT_PER_MINUTE" in os.environ:
+ del os.environ["RATE_LIMIT_CHAT_PER_MINUTE"]
+
+ import importlib
+ import src.rate_limiter
+
+ importlib.reload(src.rate_limiter)
+ result = src.rate_limiter.get_rate_limit_for_endpoint("chat")
+ assert result == "10/minute"
+
+ def test_debug_endpoint_default(self):
+ """Debug endpoint has default rate limit."""
+ import importlib
+ import src.rate_limiter
+
+ # Clear any override
+ env_copy = {k: v for k, v in os.environ.items() if k != "RATE_LIMIT_DEBUG_PER_MINUTE"}
+ with patch.dict(os.environ, env_copy, clear=True):
+ importlib.reload(src.rate_limiter)
+ result = src.rate_limiter.get_rate_limit_for_endpoint("debug")
+ assert result == "2/minute"
+
+ def test_health_endpoint_default(self):
+ """Health endpoint has default rate limit."""
+ import importlib
+ import src.rate_limiter
+
+ env_copy = {k: v for k, v in os.environ.items() if k != "RATE_LIMIT_HEALTH_PER_MINUTE"}
+ with patch.dict(os.environ, env_copy, clear=True):
+ importlib.reload(src.rate_limiter)
+ result = src.rate_limiter.get_rate_limit_for_endpoint("health")
+ assert result == "30/minute"
+
+ def test_session_endpoint_default(self):
+ """Session endpoint has default rate limit."""
+ import importlib
+ import src.rate_limiter
+
+ env_copy = {k: v for k, v in os.environ.items() if k != "RATE_LIMIT_SESSION_PER_MINUTE"}
+ with patch.dict(os.environ, env_copy, clear=True):
+ importlib.reload(src.rate_limiter)
+ result = src.rate_limiter.get_rate_limit_for_endpoint("session")
+ assert result == "15/minute"
+
+ def test_auth_endpoint_default(self):
+ """Auth endpoint has default rate limit."""
+ import importlib
+ import src.rate_limiter
+
+ env_copy = {k: v for k, v in os.environ.items() if k != "RATE_LIMIT_AUTH_PER_MINUTE"}
+ with patch.dict(os.environ, env_copy, clear=True):
+ importlib.reload(src.rate_limiter)
+ result = src.rate_limiter.get_rate_limit_for_endpoint("auth")
+ assert result == "10/minute"
+
+ def test_general_endpoint_default(self):
+ """General/unknown endpoint has default rate limit."""
+ import importlib
+ import src.rate_limiter
+
+ env_copy = {k: v for k, v in os.environ.items() if k != "RATE_LIMIT_PER_MINUTE"}
+ with patch.dict(os.environ, env_copy, clear=True):
+ importlib.reload(src.rate_limiter)
+ result = src.rate_limiter.get_rate_limit_for_endpoint("general")
+ assert result == "30/minute"
+
+ def test_custom_rate_limit_from_env(self):
+ """Rate limit can be customized via environment variable."""
+ import importlib
+ import src.rate_limiter
+
+ with patch.dict(os.environ, {"RATE_LIMIT_CHAT_PER_MINUTE": "50"}):
+ importlib.reload(src.rate_limiter)
+ result = src.rate_limiter.get_rate_limit_for_endpoint("chat")
+ assert result == "50/minute"
+
+ def test_unknown_endpoint_uses_general_default(self):
+ """Unknown endpoint uses general rate limit."""
+ import importlib
+ import src.rate_limiter
+
+ env_copy = {k: v for k, v in os.environ.items() if k != "RATE_LIMIT_PER_MINUTE"}
+ with patch.dict(os.environ, env_copy, clear=True):
+ importlib.reload(src.rate_limiter)
+ result = src.rate_limiter.get_rate_limit_for_endpoint("unknown_endpoint")
+ assert result == "30/minute"
+
+
+class TestRateLimitEndpointDecorator:
+ """Test rate_limit_endpoint decorator factory."""
+
+ def test_decorator_returns_function(self):
+ """Decorator returns a function."""
+ from src.rate_limiter import rate_limit_endpoint
+
+ decorator = rate_limit_endpoint("chat")
+ assert callable(decorator)
+
+ def test_decorator_wraps_function_with_request(self):
+ """Decorated function with request parameter can still be called."""
+ from src.rate_limiter import rate_limit_endpoint
+
+ # slowapi requires a 'request' parameter on decorated functions
+ @rate_limit_endpoint("chat")
+ def my_endpoint(request):
+ return "hello"
+
+ # The function should still be callable (though it may be wrapped)
+ assert callable(my_endpoint)
+
+ def test_decorator_without_limiter(self):
+ """When limiter is None, returns original function unchanged."""
+ import importlib
+ import src.rate_limiter
+
+ # Disable rate limiting
+ with patch.dict(os.environ, {"RATE_LIMIT_ENABLED": "false"}):
+ importlib.reload(src.rate_limiter)
+
+ @src.rate_limiter.rate_limit_endpoint("chat")
+ def my_endpoint():
+ return "hello"
+
+ # Function should work normally
+ assert my_endpoint() == "hello"
+
+
+# Reset module state after tests
+@pytest.fixture(autouse=True)
+def reset_rate_limiter_module():
+ """Reset rate limiter module after each test to avoid test pollution."""
+ yield
+ # Clean up after test
+ import importlib
+ import src.rate_limiter
+
+ # Reset to default state
+ with patch.dict(os.environ, {"RATE_LIMIT_ENABLED": "true"}, clear=False):
+ importlib.reload(src.rate_limiter)
diff --git a/tests/test_sdk_migration.py b/tests/test_sdk_migration.py
index afb1398..6ad2d95 100644
--- a/tests/test_sdk_migration.py
+++ b/tests/test_sdk_migration.py
@@ -16,11 +16,7 @@ class TestSystemPromptFormats:
def test_text_system_prompt_format(self):
"""Test text-based system prompt format."""
options = ClaudeAgentOptions(
- max_turns=1,
- system_prompt={
- "type": "text",
- "text": "You are a helpful assistant."
- }
+ max_turns=1, system_prompt={"type": "text", "text": "You are a helpful assistant."}
)
assert options.system_prompt is not None
assert isinstance(options.system_prompt, dict)
@@ -29,11 +25,7 @@ def test_text_system_prompt_format(self):
def test_preset_system_prompt_format(self):
"""Test preset-based system prompt format."""
options = ClaudeAgentOptions(
- max_turns=1,
- system_prompt={
- "type": "preset",
- "preset": "claude_code"
- }
+ max_turns=1, system_prompt={"type": "preset", "preset": "claude_code"}
)
assert options.system_prompt is not None
assert isinstance(options.system_prompt, dict)
@@ -51,18 +43,13 @@ def test_basic_options_creation(self):
def test_options_with_model(self):
"""Test options with model specification."""
- options = ClaudeAgentOptions(
- max_turns=1,
- model="claude-sonnet-4-5-20250929"
- )
+ options = ClaudeAgentOptions(max_turns=1, model="claude-sonnet-4-5-20250929")
assert options.model == "claude-sonnet-4-5-20250929"
def test_options_with_tools(self):
"""Test options with tool restrictions."""
options = ClaudeAgentOptions(
- max_turns=1,
- allowed_tools=["Read", "Write"],
- disallowed_tools=["Bash"]
+ max_turns=1, allowed_tools=["Read", "Write"], disallowed_tools=["Bash"]
)
assert options.allowed_tools == ["Read", "Write"]
assert options.disallowed_tools == ["Bash"]
@@ -153,10 +140,7 @@ def test_chat_completion_request_creation(self):
from src.models import ChatCompletionRequest
request = ChatCompletionRequest(
- model="claude-sonnet-4-5-20250929",
- messages=[
- {"role": "user", "content": "Hello"}
- ]
+ model="claude-sonnet-4-5-20250929", messages=[{"role": "user", "content": "Hello"}]
)
assert request.model == "claude-sonnet-4-5-20250929"
diff --git a/tests/test_sdk_quick.py b/tests/test_sdk_quick.py
index 5f43734..fd6b1cb 100644
--- a/tests/test_sdk_quick.py
+++ b/tests/test_sdk_quick.py
@@ -1,15 +1,28 @@
#!/usr/bin/env python3
-"""Quick test of Claude Agent SDK to verify migration."""
+"""Quick test of Claude Agent SDK to verify migration.
+
+This is an integration test that calls the Claude API.
+"""
import asyncio
import sys
import os
+import pytest
+
# Ensure we're in the right directory
sys.path.insert(0, os.path.dirname(__file__))
from claude_agent_sdk import query, ClaudeAgentOptions
+# Skip if no API key available (integration test)
+pytestmark = pytest.mark.skipif(
+ not os.getenv("ANTHROPIC_API_KEY"),
+ reason="ANTHROPIC_API_KEY not set - skipping SDK integration test",
+)
+
+
+@pytest.mark.asyncio
async def test_simple_query():
"""Test a simple query with the new SDK."""
print("Testing Claude Agent SDK with simple query...")
@@ -20,21 +33,24 @@ async def test_simple_query():
async for message in query(
prompt="Say 'Hello!' and nothing else.",
options=ClaudeAgentOptions(
- max_turns=1,
- model="claude-3-5-haiku-20241022" # Fastest model for testing
- )
+ max_turns=1, model="claude-3-5-haiku-20241022" # Fastest model for testing
+ ),
):
messages.append(message)
print(f"Got message type: {type(message)}")
# Try to extract content
- if hasattr(message, 'content'):
+ if hasattr(message, "content"):
print(f"Content: {message.content}")
elif isinstance(message, dict):
print(f"Message dict: {message}")
# Break early if we get an assistant message
- msg_type = getattr(message, 'type', None) if hasattr(message, 'type') else message.get("type") if isinstance(message, dict) else None
+ msg_type = (
+ getattr(message, "type", None)
+ if hasattr(message, "type")
+ else message.get("type") if isinstance(message, dict) else None
+ )
if msg_type == "assistant":
print("โ Got assistant response!")
break
@@ -50,9 +66,11 @@ async def test_simple_query():
except Exception as e:
print(f"โ Test failed with error: {e}")
import traceback
+
traceback.print_exc()
return False
+
if __name__ == "__main__":
result = asyncio.run(test_simple_query())
sys.exit(0 if result else 1)
diff --git a/tests/test_session_complete.py b/tests/test_session_complete.py
index d929673..425aeb4 100644
--- a/tests/test_session_complete.py
+++ b/tests/test_session_complete.py
@@ -3,18 +3,23 @@
Comprehensive test for session continuity functionality.
"""
+import pytest
import requests
+
+from tests.conftest import requires_server
import json
import time
BASE_URL = "http://localhost:8000"
+
+@requires_server
def test_session_continuity_comprehensive():
"""Test session continuity with multiple conversation turns."""
print("๐งช Testing comprehensive session continuity...")
-
+
session_id = "comprehensive-test"
-
+
# Conversation sequence to test memory
conversation = [
{"user": "Hello! My name is Charlie and I'm 25 years old.", "expect_memory": None},
@@ -23,137 +28,163 @@ def test_session_continuity_comprehensive():
{"user": "How old am I?", "expect_memory": "25"},
{"user": "What do I do for work?", "expect_memory": "software engineer"},
]
-
+
for i, turn in enumerate(conversation, 1):
print(f"\n{i}๏ธโฃ Turn {i}: {turn['user']}")
-
- response = requests.post(f"{BASE_URL}/v1/chat/completions", json={
- "model": "claude-3-5-sonnet-20241022",
- "messages": [{"role": "user", "content": turn["user"]}],
- "session_id": session_id
- })
-
+
+ response = requests.post(
+ f"{BASE_URL}/v1/chat/completions",
+ json={
+ "model": "claude-3-5-sonnet-20241022",
+ "messages": [{"role": "user", "content": turn["user"]}],
+ "session_id": session_id,
+ },
+ )
+
if response.status_code != 200:
print(f"โ Turn {i} failed: {response.status_code}")
return False
-
+
result = response.json()
- response_text = result['choices'][0]['message']['content']
+ response_text = result["choices"][0]["message"]["content"]
print(f" Response: {response_text[:100]}...")
-
+
# Check if expected information is remembered
if turn["expect_memory"]:
if turn["expect_memory"].lower() in response_text.lower():
print(f" โ
Memory check passed: '{turn['expect_memory']}' found")
else:
- print(f" โ ๏ธ Memory check unclear: '{turn['expect_memory']}' not found, but may still be working")
-
+ print(
+ f" โ ๏ธ Memory check unclear: '{turn['expect_memory']}' not found, but may still be working"
+ )
+
# Check session info
session_info = requests.get(f"{BASE_URL}/v1/sessions/{session_id}")
if session_info.status_code == 200:
info = session_info.json()
print(f"\n๐ Session info: {info['message_count']} messages stored")
expected_messages = len(conversation) * 2 # user + assistant for each turn
- if info['message_count'] == expected_messages:
+ if info["message_count"] == expected_messages:
print(f" โ
Correct message count: {expected_messages}")
else:
- print(f" โ ๏ธ Message count mismatch: expected {expected_messages}, got {info['message_count']}")
-
+ print(
+ f" โ ๏ธ Message count mismatch: expected {expected_messages}, got {info['message_count']}"
+ )
+
# Cleanup
requests.delete(f"{BASE_URL}/v1/sessions/{session_id}")
print(f" ๐งน Session {session_id} cleaned up")
-
+
return True
+
+@requires_server
def test_stateless_vs_session():
"""Test that stateless and session modes work differently."""
print("\n๐งช Testing stateless vs session behavior...")
-
+
# Test stateless (no session_id)
print("1๏ธโฃ Stateless mode:")
- requests.post(f"{BASE_URL}/v1/chat/completions", json={
- "model": "claude-3-5-sonnet-20241022",
- "messages": [{"role": "user", "content": "Remember: my favorite color is blue."}]
- })
-
+ requests.post(
+ f"{BASE_URL}/v1/chat/completions",
+ json={
+ "model": "claude-3-5-sonnet-20241022",
+ "messages": [{"role": "user", "content": "Remember: my favorite color is blue."}],
+ },
+ )
+
# Follow up question without session_id
- response1 = requests.post(f"{BASE_URL}/v1/chat/completions", json={
- "model": "claude-3-5-sonnet-20241022",
- "messages": [{"role": "user", "content": "What's my favorite color?"}]
- })
-
+ response1 = requests.post(
+ f"{BASE_URL}/v1/chat/completions",
+ json={
+ "model": "claude-3-5-sonnet-20241022",
+ "messages": [{"role": "user", "content": "What's my favorite color?"}],
+ },
+ )
+
if response1.status_code == 200:
result1 = response1.json()
- stateless_response = result1['choices'][0]['message']['content']
+ stateless_response = result1["choices"][0]["message"]["content"]
print(f" Stateless response: {stateless_response[:100]}...")
-
- # Test session mode
+
+ # Test session mode
print("2๏ธโฃ Session mode:")
session_id = "color-test-session"
-
- requests.post(f"{BASE_URL}/v1/chat/completions", json={
- "model": "claude-3-5-sonnet-20241022",
- "messages": [{"role": "user", "content": "Remember: my favorite color is red."}],
- "session_id": session_id
- })
-
- response2 = requests.post(f"{BASE_URL}/v1/chat/completions", json={
- "model": "claude-3-5-sonnet-20241022",
- "messages": [{"role": "user", "content": "What's my favorite color?"}],
- "session_id": session_id
- })
-
+
+ requests.post(
+ f"{BASE_URL}/v1/chat/completions",
+ json={
+ "model": "claude-3-5-sonnet-20241022",
+ "messages": [{"role": "user", "content": "Remember: my favorite color is red."}],
+ "session_id": session_id,
+ },
+ )
+
+ response2 = requests.post(
+ f"{BASE_URL}/v1/chat/completions",
+ json={
+ "model": "claude-3-5-sonnet-20241022",
+ "messages": [{"role": "user", "content": "What's my favorite color?"}],
+ "session_id": session_id,
+ },
+ )
+
if response2.status_code == 200:
result2 = response2.json()
- session_response = result2['choices'][0]['message']['content']
+ session_response = result2["choices"][0]["message"]["content"]
print(f" Session response: {session_response[:100]}...")
-
+
if "red" in session_response.lower():
print(" โ
Session mode correctly remembered the color")
else:
print(" โ ๏ธ Session mode didn't clearly show memory, but may still be working")
-
+
# Cleanup
requests.delete(f"{BASE_URL}/v1/sessions/{session_id}")
return True
+
+@requires_server
def test_session_endpoints():
"""Test all session management endpoints."""
print("\n๐งช Testing session management endpoints...")
-
+
# Create some sessions
session_ids = ["endpoint-test-1", "endpoint-test-2", "endpoint-test-3"]
-
+
for session_id in session_ids:
- requests.post(f"{BASE_URL}/v1/chat/completions", json={
- "model": "claude-3-5-sonnet-20241022",
- "messages": [{"role": "user", "content": f"Test session {session_id}"}],
- "session_id": session_id
- })
-
+ requests.post(
+ f"{BASE_URL}/v1/chat/completions",
+ json={
+ "model": "claude-3-5-sonnet-20241022",
+ "messages": [{"role": "user", "content": f"Test session {session_id}"}],
+ "session_id": session_id,
+ },
+ )
+
# Test list sessions
list_response = requests.get(f"{BASE_URL}/v1/sessions")
if list_response.status_code == 200:
sessions = list_response.json()
print(f" โ
Listed {sessions['total']} sessions")
-
- if sessions['total'] >= len(session_ids):
+
+ if sessions["total"] >= len(session_ids):
print(f" โ
Found all test sessions")
else:
print(f" โ ๏ธ Expected at least {len(session_ids)} sessions, found {sessions['total']}")
-
+
# Test get specific session
get_response = requests.get(f"{BASE_URL}/v1/sessions/{session_ids[0]}")
if get_response.status_code == 200:
session_info = get_response.json()
print(f" โ
Retrieved session info: {session_info['message_count']} messages")
-
+
# Test session stats
stats_response = requests.get(f"{BASE_URL}/v1/sessions/stats")
if stats_response.status_code == 200:
stats = stats_response.json()
print(f" โ
Session stats: {stats['session_stats']['active_sessions']} active")
-
+
# Test delete sessions
for session_id in session_ids:
delete_response = requests.delete(f"{BASE_URL}/v1/sessions/{session_id}")
@@ -161,13 +192,14 @@ def test_session_endpoints():
print(f" โ
Deleted session {session_id}")
else:
print(f" โ Failed to delete session {session_id}")
-
+
return True
+
def main():
"""Run comprehensive session tests."""
print("๐ Starting comprehensive session continuity tests...")
-
+
# Test server health
try:
health = requests.get(f"{BASE_URL}/health", timeout=5)
@@ -178,14 +210,14 @@ def main():
except Exception as e:
print(f"โ Server connection error: {e}")
return
-
+
# Run all tests
tests = [
("Session Continuity", test_session_continuity_comprehensive),
("Stateless vs Session", test_stateless_vs_session),
("Session Endpoints", test_session_endpoints),
]
-
+
passed = 0
for test_name, test_func in tests:
try:
@@ -197,15 +229,16 @@ def main():
print(f"โ {test_name} test failed")
except Exception as e:
print(f"โ {test_name} test error: {e}")
-
+
print(f"\n{'='*50}")
print(f"๐ Final Results: {passed}/{len(tests)} tests passed")
-
+
if passed == len(tests):
print("๐ All comprehensive session tests passed!")
print("โจ Session continuity is working correctly!")
else:
print("โ ๏ธ Some tests failed - check the output above")
+
if __name__ == "__main__":
- main()
\ No newline at end of file
+ main()
diff --git a/tests/test_session_continuity.py b/tests/test_session_continuity.py
index 887f44a..26bb143 100644
--- a/tests/test_session_continuity.py
+++ b/tests/test_session_continuity.py
@@ -5,7 +5,10 @@
import asyncio
import json
+import pytest
import requests
+
+from tests.conftest import requires_server
import time
from typing import Dict, Any
@@ -14,17 +17,19 @@
TEST_SESSION_ID = "test-session-123"
+@requires_server
def test_stateless_mode():
"""Test traditional stateless OpenAI-style requests."""
print("๐งช Testing stateless mode...")
-
- response = requests.post(f"{BASE_URL}/v1/chat/completions", json={
- "model": "claude-3-5-sonnet-20241022",
- "messages": [
- {"role": "user", "content": "Hello! My name is Alice."}
- ]
- })
-
+
+ response = requests.post(
+ f"{BASE_URL}/v1/chat/completions",
+ json={
+ "model": "claude-3-5-sonnet-20241022",
+ "messages": [{"role": "user", "content": "Hello! My name is Alice."}],
+ },
+ )
+
if response.status_code == 200:
result = response.json()
print(f"โ
Stateless request successful")
@@ -35,48 +40,51 @@ def test_stateless_mode():
return False
+@requires_server
def test_session_mode():
"""Test session-based requests with conversation continuity."""
print(f"\n๐งช Testing session mode with session_id: {TEST_SESSION_ID}")
-
+
# First message in session
print("1๏ธโฃ First message in session...")
- response1 = requests.post(f"{BASE_URL}/v1/chat/completions", json={
- "model": "claude-3-5-sonnet-20241022",
- "messages": [
- {"role": "user", "content": "Hello! My name is Bob. Remember this name."}
- ],
- "session_id": TEST_SESSION_ID
- })
-
+ response1 = requests.post(
+ f"{BASE_URL}/v1/chat/completions",
+ json={
+ "model": "claude-3-5-sonnet-20241022",
+ "messages": [{"role": "user", "content": "Hello! My name is Bob. Remember this name."}],
+ "session_id": TEST_SESSION_ID,
+ },
+ )
+
if response1.status_code != 200:
print(f"โ First session request failed: {response1.status_code} - {response1.text}")
return False
-
+
result1 = response1.json()
print(f"โ
First session message successful")
print(f" Response: {result1['choices'][0]['message']['content'][:100]}...")
-
+
# Second message in same session - should remember the name
print("2๏ธโฃ Second message in same session...")
- response2 = requests.post(f"{BASE_URL}/v1/chat/completions", json={
- "model": "claude-3-5-sonnet-20241022",
- "messages": [
- {"role": "user", "content": "What's my name?"}
- ],
- "session_id": TEST_SESSION_ID
- })
-
+ response2 = requests.post(
+ f"{BASE_URL}/v1/chat/completions",
+ json={
+ "model": "claude-3-5-sonnet-20241022",
+ "messages": [{"role": "user", "content": "What's my name?"}],
+ "session_id": TEST_SESSION_ID,
+ },
+ )
+
if response2.status_code != 200:
print(f"โ Second session request failed: {response2.status_code} - {response2.text}")
return False
-
+
result2 = response2.json()
print(f"โ
Second session message successful")
print(f" Response: {result2['choices'][0]['message']['content'][:100]}...")
-
+
# Check if the response mentions the name "Bob"
- response_text = result2['choices'][0]['message']['content'].lower()
+ response_text = result2["choices"][0]["message"]["content"].lower()
if "bob" in response_text:
print("โ
Session continuity working - Claude remembered the name!")
return True
@@ -85,22 +93,23 @@ def test_session_mode():
return True # Still successful, maybe Claude responded differently
+@requires_server
def test_session_management_endpoints():
"""Test session management endpoints."""
print(f"\n๐งช Testing session management endpoints...")
-
+
# List sessions
print("1๏ธโฃ Listing sessions...")
response = requests.get(f"{BASE_URL}/v1/sessions")
if response.status_code == 200:
sessions = response.json()
print(f"โ
Sessions listed: {sessions['total']} active sessions")
- if sessions['total'] > 0:
+ if sessions["total"] > 0:
print(f" First session: {sessions['sessions'][0]['session_id']}")
else:
print(f"โ Failed to list sessions: {response.status_code}")
return False
-
+
# Get specific session info
print("2๏ธโฃ Getting session info...")
response = requests.get(f"{BASE_URL}/v1/sessions/{TEST_SESSION_ID}")
@@ -112,7 +121,7 @@ def test_session_management_endpoints():
else:
print(f"โ Failed to get session info: {response.status_code}")
return False
-
+
# Get session stats
print("3๏ธโฃ Getting session stats...")
response = requests.get(f"{BASE_URL}/v1/sessions/stats")
@@ -124,49 +133,58 @@ def test_session_management_endpoints():
else:
print(f"โ Failed to get session stats: {response.status_code}")
return False
-
+
return True
+@requires_server
def test_session_streaming():
"""Test session continuity with streaming."""
print(f"\n๐งช Testing session streaming...")
-
+
# Create a new session for streaming test
stream_session_id = "test-stream-456"
-
- response = requests.post(f"{BASE_URL}/v1/chat/completions", json={
- "model": "claude-3-5-sonnet-20241022",
- "messages": [
- {"role": "user", "content": "Hello! I'm testing streaming. My favorite color is purple."}
- ],
- "session_id": stream_session_id,
- "stream": True
- }, stream=True)
-
+
+ response = requests.post(
+ f"{BASE_URL}/v1/chat/completions",
+ json={
+ "model": "claude-3-5-sonnet-20241022",
+ "messages": [
+ {
+ "role": "user",
+ "content": "Hello! I'm testing streaming. My favorite color is purple.",
+ }
+ ],
+ "session_id": stream_session_id,
+ "stream": True,
+ },
+ stream=True,
+ )
+
if response.status_code != 200:
print(f"โ Streaming request failed: {response.status_code}")
return False
-
+
print("โ
Streaming response received")
-
+
# Follow up with another message in the same session
time.sleep(1) # Give time for the session to be updated
-
- response2 = requests.post(f"{BASE_URL}/v1/chat/completions", json={
- "model": "claude-3-5-sonnet-20241022",
- "messages": [
- {"role": "user", "content": "What's my favorite color?"}
- ],
- "session_id": stream_session_id
- })
-
+
+ response2 = requests.post(
+ f"{BASE_URL}/v1/chat/completions",
+ json={
+ "model": "claude-3-5-sonnet-20241022",
+ "messages": [{"role": "user", "content": "What's my favorite color?"}],
+ "session_id": stream_session_id,
+ },
+ )
+
if response2.status_code == 200:
result = response2.json()
- response_text = result['choices'][0]['message']['content'].lower()
+ response_text = result["choices"][0]["message"]["content"].lower()
print(f"โ
Follow-up message successful")
print(f" Response: {result['choices'][0]['message']['content'][:100]}...")
-
+
if "purple" in response_text:
print("โ
Session continuity working with streaming!")
else:
@@ -180,7 +198,7 @@ def test_session_streaming():
def cleanup_test_sessions():
"""Clean up test sessions."""
print(f"\n๐งน Cleaning up test sessions...")
-
+
for session_id in [TEST_SESSION_ID, "test-stream-456"]:
response = requests.delete(f"{BASE_URL}/v1/sessions/{session_id}")
if response.status_code == 200:
@@ -195,7 +213,7 @@ def main():
"""Run all session continuity tests."""
print("๐ Starting session continuity tests...")
print(f" Server: {BASE_URL}")
-
+
# Test server health first
try:
response = requests.get(f"{BASE_URL}/health", timeout=5)
@@ -207,10 +225,10 @@ def main():
print(f"โ Cannot connect to server: {e}")
print(" Make sure the server is running with: poetry run python main.py")
return
-
+
success_count = 0
total_tests = 4
-
+
# Run tests
tests = [
("Stateless Mode", test_stateless_mode),
@@ -218,7 +236,7 @@ def main():
("Session Management", test_session_management_endpoints),
("Session Streaming", test_session_streaming),
]
-
+
for test_name, test_func in tests:
try:
if test_func():
@@ -227,13 +245,13 @@ def main():
print(f"โ {test_name} test failed")
except Exception as e:
print(f"โ {test_name} test error: {e}")
-
+
# Cleanup
cleanup_test_sessions()
-
+
# Results
print(f"\n๐ Test Results: {success_count}/{total_tests} tests passed")
-
+
if success_count == total_tests:
print("๐ All session continuity tests passed!")
else:
@@ -241,4 +259,4 @@ def main():
if __name__ == "__main__":
- main()
\ No newline at end of file
+ main()
diff --git a/tests/test_session_manager_unit.py b/tests/test_session_manager_unit.py
new file mode 100644
index 0000000..961a385
--- /dev/null
+++ b/tests/test_session_manager_unit.py
@@ -0,0 +1,375 @@
+#!/usr/bin/env python3
+"""
+Unit tests for src/session_manager.py
+
+Tests the Session and SessionManager classes.
+These are pure unit tests that don't require a running server.
+"""
+
+import pytest
+from datetime import datetime, timedelta
+from unittest.mock import MagicMock, patch
+import asyncio
+
+from src.session_manager import Session, SessionManager
+from src.models import Message
+
+
+class TestSession:
+ """Test the Session dataclass."""
+
+ def test_session_creation_with_id(self):
+ """Session can be created with just an ID."""
+ session = Session(session_id="test-123")
+ assert session.session_id == "test-123"
+ assert session.messages == []
+ assert isinstance(session.created_at, datetime)
+ assert isinstance(session.last_accessed, datetime)
+ assert isinstance(session.expires_at, datetime)
+
+ def test_session_expiry_in_future(self):
+ """Newly created session expires in the future."""
+ session = Session(session_id="test-123")
+ assert session.expires_at > datetime.utcnow()
+
+ def test_touch_updates_last_accessed(self):
+ """touch() updates last_accessed time."""
+ session = Session(session_id="test-123")
+ original_accessed = session.last_accessed
+
+ # Small delay to ensure time difference
+ import time
+
+ time.sleep(0.01)
+ session.touch()
+
+ assert session.last_accessed >= original_accessed
+
+ def test_touch_extends_expiration(self):
+ """touch() extends the expiration time."""
+ session = Session(session_id="test-123")
+ original_expires = session.expires_at
+
+ import time
+
+ time.sleep(0.01)
+ session.touch()
+
+ assert session.expires_at >= original_expires
+
+ def test_add_messages_appends_to_list(self):
+ """add_messages() appends new messages to the session."""
+ session = Session(session_id="test-123")
+ msg1 = Message(role="user", content="Hello")
+ msg2 = Message(role="assistant", content="Hi there")
+
+ session.add_messages([msg1])
+ assert len(session.messages) == 1
+
+ session.add_messages([msg2])
+ assert len(session.messages) == 2
+
+ def test_add_messages_touches_session(self):
+ """add_messages() also touches the session."""
+ session = Session(session_id="test-123")
+ original_accessed = session.last_accessed
+
+ import time
+
+ time.sleep(0.01)
+ session.add_messages([Message(role="user", content="Test")])
+
+ assert session.last_accessed >= original_accessed
+
+ def test_get_all_messages_returns_copy(self):
+ """get_all_messages() returns all messages."""
+ session = Session(session_id="test-123")
+ msg1 = Message(role="user", content="Hello")
+ msg2 = Message(role="assistant", content="Hi")
+
+ session.add_messages([msg1, msg2])
+ messages = session.get_all_messages()
+
+ assert len(messages) == 2
+ assert messages[0].content == "Hello"
+ assert messages[1].content == "Hi"
+
+ def test_is_expired_false_for_new_session(self):
+ """Newly created session is not expired."""
+ session = Session(session_id="test-123")
+ assert session.is_expired() is False
+
+ def test_is_expired_true_for_past_expiry(self):
+ """Session with past expiry is expired."""
+ session = Session(session_id="test-123", expires_at=datetime.utcnow() - timedelta(hours=1))
+ assert session.is_expired() is True
+
+ def test_to_session_info_returns_correct_model(self):
+ """to_session_info() returns properly populated SessionInfo."""
+ session = Session(session_id="test-123")
+ session.add_messages([Message(role="user", content="Test")])
+
+ info = session.to_session_info()
+
+ assert info.session_id == "test-123"
+ assert info.message_count == 1
+ assert isinstance(info.created_at, datetime)
+ assert isinstance(info.last_accessed, datetime)
+ assert isinstance(info.expires_at, datetime)
+
+
+class TestSessionManager:
+ """Test the SessionManager class."""
+
+ @pytest.fixture
+ def manager(self):
+ """Create a fresh SessionManager for each test."""
+ return SessionManager(default_ttl_hours=1, cleanup_interval_minutes=5)
+
+ def test_manager_initialization(self, manager):
+ """SessionManager initializes with empty sessions."""
+ assert len(manager.sessions) == 0
+ assert manager.default_ttl_hours == 1
+ assert manager.cleanup_interval_minutes == 5
+
+ def test_get_or_create_session_creates_new(self, manager):
+ """get_or_create_session() creates new session if not exists."""
+ session = manager.get_or_create_session("new-session")
+
+ assert session is not None
+ assert session.session_id == "new-session"
+ assert "new-session" in manager.sessions
+
+ def test_get_or_create_session_returns_existing(self, manager):
+ """get_or_create_session() returns existing session."""
+ session1 = manager.get_or_create_session("existing")
+ session1.add_messages([Message(role="user", content="Test")])
+
+ session2 = manager.get_or_create_session("existing")
+
+ assert session2 is session1
+ assert len(session2.messages) == 1
+
+ def test_get_or_create_replaces_expired_session(self, manager):
+ """get_or_create_session() replaces expired session with new one."""
+ # Create session and add messages first
+ session1 = manager.get_or_create_session("expiring")
+ session1.add_messages([Message(role="user", content="Old")])
+ # Expire AFTER adding messages (add_messages calls touch() which extends expiry)
+ session1.expires_at = datetime.utcnow() - timedelta(hours=1)
+
+ # Should get a new session since the old one is expired
+ session2 = manager.get_or_create_session("expiring")
+
+ assert len(session2.messages) == 0 # New session has no messages
+
+ def test_get_session_returns_none_for_nonexistent(self, manager):
+ """get_session() returns None for non-existent session."""
+ result = manager.get_session("nonexistent")
+ assert result is None
+
+ def test_get_session_returns_existing(self, manager):
+ """get_session() returns existing active session."""
+ manager.get_or_create_session("existing")
+ result = manager.get_session("existing")
+
+ assert result is not None
+ assert result.session_id == "existing"
+
+ def test_get_session_returns_none_for_expired(self, manager):
+ """get_session() returns None and cleans up expired session."""
+ session = manager.get_or_create_session("expiring")
+ session.expires_at = datetime.utcnow() - timedelta(hours=1)
+
+ result = manager.get_session("expiring")
+
+ assert result is None
+ assert "expiring" not in manager.sessions
+
+ def test_delete_session_removes_session(self, manager):
+ """delete_session() removes existing session."""
+ manager.get_or_create_session("to-delete")
+ assert "to-delete" in manager.sessions
+
+ result = manager.delete_session("to-delete")
+
+ assert result is True
+ assert "to-delete" not in manager.sessions
+
+ def test_delete_session_returns_false_for_nonexistent(self, manager):
+ """delete_session() returns False for non-existent session."""
+ result = manager.delete_session("nonexistent")
+ assert result is False
+
+ def test_list_sessions_returns_active_sessions(self, manager):
+ """list_sessions() returns list of active sessions."""
+ manager.get_or_create_session("session-1")
+ manager.get_or_create_session("session-2")
+
+ sessions = manager.list_sessions()
+
+ assert len(sessions) == 2
+ session_ids = [s.session_id for s in sessions]
+ assert "session-1" in session_ids
+ assert "session-2" in session_ids
+
+ def test_list_sessions_excludes_expired(self, manager):
+ """list_sessions() excludes and cleans up expired sessions."""
+ manager.get_or_create_session("active")
+ expired = manager.get_or_create_session("expired")
+ expired.expires_at = datetime.utcnow() - timedelta(hours=1)
+
+ sessions = manager.list_sessions()
+
+ assert len(sessions) == 1
+ assert sessions[0].session_id == "active"
+
+ def test_process_messages_stateless_mode(self, manager):
+ """process_messages() in stateless mode returns messages as-is."""
+ messages = [Message(role="user", content="Hello")]
+
+ result_msgs, session_id = manager.process_messages(messages, session_id=None)
+
+ assert result_msgs == messages
+ assert session_id is None
+
+ def test_process_messages_session_mode(self, manager):
+ """process_messages() in session mode accumulates messages."""
+ msg1 = Message(role="user", content="First")
+ msg2 = Message(role="user", content="Second")
+
+ # First call
+ result1, sid1 = manager.process_messages([msg1], session_id="my-session")
+ assert len(result1) == 1
+ assert sid1 == "my-session"
+
+ # Second call - should have both messages
+ result2, sid2 = manager.process_messages([msg2], session_id="my-session")
+ assert len(result2) == 2
+ assert sid2 == "my-session"
+
+ def test_add_assistant_response_in_session_mode(self, manager):
+ """add_assistant_response() adds response to session."""
+ manager.get_or_create_session("my-session")
+ assistant_msg = Message(role="assistant", content="Hello!")
+
+ manager.add_assistant_response("my-session", assistant_msg)
+
+ session = manager.get_session("my-session")
+ assert len(session.messages) == 1
+ assert session.messages[0].role == "assistant"
+
+ def test_add_assistant_response_stateless_mode_noop(self, manager):
+ """add_assistant_response() does nothing in stateless mode."""
+ assistant_msg = Message(role="assistant", content="Hello!")
+
+ # Should not raise, just do nothing
+ manager.add_assistant_response(None, assistant_msg)
+
+ def test_get_stats_returns_correct_counts(self, manager):
+ """get_stats() returns correct statistics."""
+ manager.get_or_create_session("session-1")
+ session2 = manager.get_or_create_session("session-2")
+ session2.add_messages([Message(role="user", content="Test")])
+
+ # Create expired session
+ expired = manager.get_or_create_session("expired")
+ expired.expires_at = datetime.utcnow() - timedelta(hours=1)
+
+ stats = manager.get_stats()
+
+ assert stats["active_sessions"] == 2
+ assert stats["expired_sessions"] == 1
+ assert stats["total_messages"] == 1
+
+ def test_shutdown_clears_sessions(self, manager):
+ """shutdown() clears all sessions."""
+ manager.get_or_create_session("session-1")
+ manager.get_or_create_session("session-2")
+ assert len(manager.sessions) == 2
+
+ manager.shutdown()
+
+ assert len(manager.sessions) == 0
+
+ def test_cleanup_expired_sessions(self, manager):
+ """_cleanup_expired_sessions() removes only expired sessions."""
+ manager.get_or_create_session("active")
+ expired = manager.get_or_create_session("expired")
+ expired.expires_at = datetime.utcnow() - timedelta(hours=1)
+
+ manager._cleanup_expired_sessions()
+
+ assert "active" in manager.sessions
+ assert "expired" not in manager.sessions
+
+
+class TestSessionManagerAsync:
+ """Test async functionality of SessionManager."""
+
+ @pytest.fixture
+ def manager(self):
+ """Create a fresh SessionManager for each test."""
+ return SessionManager(default_ttl_hours=1, cleanup_interval_minutes=5)
+
+ @pytest.mark.asyncio
+ async def test_start_cleanup_task_creates_task(self, manager):
+ """start_cleanup_task() creates an async task when loop is running."""
+ # Start the cleanup task
+ manager.start_cleanup_task()
+
+ # Task should be created
+ assert manager._cleanup_task is not None
+
+ # Clean up
+ manager.shutdown()
+
+ @pytest.mark.asyncio
+ async def test_start_cleanup_task_idempotent(self, manager):
+ """start_cleanup_task() only creates one task."""
+ manager.start_cleanup_task()
+ first_task = manager._cleanup_task
+
+ manager.start_cleanup_task()
+ second_task = manager._cleanup_task
+
+ assert first_task is second_task
+
+ # Clean up
+ manager.shutdown()
+
+
+class TestSessionManagerThreadSafety:
+ """Test thread safety of SessionManager operations."""
+
+ @pytest.fixture
+ def manager(self):
+ """Create a fresh SessionManager for each test."""
+ return SessionManager()
+
+ def test_concurrent_session_creation(self, manager):
+ """Multiple threads can create sessions concurrently."""
+ import threading
+
+ results = []
+ errors = []
+
+ def create_session(session_id):
+ try:
+ session = manager.get_or_create_session(session_id)
+ results.append(session.session_id)
+ except Exception as e:
+ errors.append(str(e))
+
+ threads = []
+ for i in range(10):
+ t = threading.Thread(target=create_session, args=(f"session-{i}",))
+ threads.append(t)
+ t.start()
+
+ for t in threads:
+ t.join()
+
+ assert len(errors) == 0
+ assert len(results) == 10
+ assert len(manager.sessions) == 10
diff --git a/tests/test_session_simple.py b/tests/test_session_simple.py
index 3c3c2f4..0ddb224 100644
--- a/tests/test_session_simple.py
+++ b/tests/test_session_simple.py
@@ -1,42 +1,50 @@
#!/usr/bin/env python3
"""
Simple test for session continuity functionality.
+
+These are integration tests that require a running server.
"""
+import pytest
import requests
import json
import time
+from tests.conftest import requires_server
+
BASE_URL = "http://localhost:8000"
TEST_SESSION_ID = "test-simple-session"
+
+@requires_server
def test_session_creation():
"""Test creating a session and checking it appears in the list."""
print("๐งช Testing session creation...")
-
+
# Make a request with a session_id
- response = requests.post(f"{BASE_URL}/v1/chat/completions", json={
- "model": "claude-3-5-sonnet-20241022",
- "messages": [
- {"role": "user", "content": "Hello, remember my name is Alice."}
- ],
- "session_id": TEST_SESSION_ID
- })
-
+ response = requests.post(
+ f"{BASE_URL}/v1/chat/completions",
+ json={
+ "model": "claude-3-5-sonnet-20241022",
+ "messages": [{"role": "user", "content": "Hello, remember my name is Alice."}],
+ "session_id": TEST_SESSION_ID,
+ },
+ )
+
if response.status_code != 200:
print(f"โ Session creation failed: {response.status_code}")
return False
-
+
print("โ
Session creation request successful")
-
+
# Check if session appears in the list
sessions_response = requests.get(f"{BASE_URL}/v1/sessions")
if sessions_response.status_code == 200:
sessions_data = sessions_response.json()
print(f"โ
Found {sessions_data['total']} sessions")
-
+
# Check if our session is in the list
- session_ids = [s['session_id'] for s in sessions_data['sessions']]
+ session_ids = [s["session_id"] for s in sessions_data["sessions"]]
if TEST_SESSION_ID in session_ids:
print(f"โ
Session {TEST_SESSION_ID} found in session list")
return True
@@ -47,27 +55,30 @@ def test_session_creation():
print(f"โ Failed to list sessions: {sessions_response.status_code}")
return False
+
+@requires_server
def test_session_continuity():
"""Test that conversation context is maintained across requests."""
print("\n๐งช Testing session continuity...")
-
+
# Follow up message asking about the name
- response = requests.post(f"{BASE_URL}/v1/chat/completions", json={
- "model": "claude-3-5-sonnet-20241022",
- "messages": [
- {"role": "user", "content": "What's my name?"}
- ],
- "session_id": TEST_SESSION_ID
- })
-
+ response = requests.post(
+ f"{BASE_URL}/v1/chat/completions",
+ json={
+ "model": "claude-3-5-sonnet-20241022",
+ "messages": [{"role": "user", "content": "What's my name?"}],
+ "session_id": TEST_SESSION_ID,
+ },
+ )
+
if response.status_code != 200:
print(f"โ Continuity test failed: {response.status_code}")
return False
-
+
result = response.json()
- response_text = result['choices'][0]['message']['content'].lower()
+ response_text = result["choices"][0]["message"]["content"].lower()
print(f"Response: {result['choices'][0]['message']['content'][:100]}...")
-
+
# Check if response mentions Alice
if "alice" in response_text:
print("โ
Session continuity working - name remembered!")
@@ -76,20 +87,22 @@ def test_session_continuity():
print("โ ๏ธ Response doesn't mention Alice, but session continuity may still be working")
return True # Don't fail the test just because of this
+
+@requires_server
def test_session_cleanup():
"""Test session deletion."""
print("\n๐งช Testing session cleanup...")
-
+
# Delete the session
delete_response = requests.delete(f"{BASE_URL}/v1/sessions/{TEST_SESSION_ID}")
if delete_response.status_code == 200:
print("โ
Session deleted successfully")
-
+
# Verify it's gone from the list
sessions_response = requests.get(f"{BASE_URL}/v1/sessions")
if sessions_response.status_code == 200:
sessions_data = sessions_response.json()
- session_ids = [s['session_id'] for s in sessions_data['sessions']]
+ session_ids = [s["session_id"] for s in sessions_data["sessions"]]
if TEST_SESSION_ID not in session_ids:
print("โ
Session successfully removed from list")
return True
@@ -103,10 +116,11 @@ def test_session_cleanup():
print(f"โ Failed to delete session: {delete_response.status_code}")
return False
+
def main():
"""Run simple session tests."""
print("๐ Starting simple session tests...")
-
+
# Test server health
try:
health_response = requests.get(f"{BASE_URL}/health", timeout=5)
@@ -117,14 +131,14 @@ def main():
except Exception as e:
print(f"โ Cannot connect to server: {e}")
return
-
+
# Run tests
tests = [
("Session Creation", test_session_creation),
("Session Continuity", test_session_continuity),
("Session Cleanup", test_session_cleanup),
]
-
+
passed = 0
for test_name, test_func in tests:
try:
@@ -134,13 +148,14 @@ def main():
print(f"โ {test_name} test failed")
except Exception as e:
print(f"โ {test_name} test error: {e}")
-
+
print(f"\n๐ Results: {passed}/{len(tests)} tests passed")
-
+
if passed == len(tests):
print("๐ All session tests passed!")
else:
print("โ ๏ธ Some tests failed")
+
if __name__ == "__main__":
- main()
\ No newline at end of file
+ main()
diff --git a/tests/test_textblock_fix.py b/tests/test_textblock_fix.py
index c5b3409..69fc7db 100644
--- a/tests/test_textblock_fix.py
+++ b/tests/test_textblock_fix.py
@@ -8,92 +8,85 @@
import requests
# Set debug mode
-os.environ['DEBUG_MODE'] = 'true'
+os.environ["DEBUG_MODE"] = "true"
+
def test_textblock_fix():
"""Test that TextBlock content extraction is working."""
print("๐งช Testing TextBlock content extraction fix...")
-
+
# Simple request that should trigger Claude to respond with normal text
request_data = {
"model": "claude-3-7-sonnet-20250219",
- "messages": [
- {
- "role": "user",
- "content": "Hello! Can you briefly introduce yourself?"
- }
- ],
+ "messages": [{"role": "user", "content": "Hello! Can you briefly introduce yourself?"}],
"stream": True,
- "temperature": 0.0
+ "temperature": 0.0,
}
-
+
try:
# Send streaming request
response = requests.post(
- "http://localhost:8000/v1/chat/completions",
- json=request_data,
- stream=True,
- timeout=30
+ "http://localhost:8000/v1/chat/completions", json=request_data, stream=True, timeout=30
)
-
+
print(f"โ
Response status: {response.status_code}")
-
+
if response.status_code != 200:
print(f"โ Request failed: {response.text}")
return False
-
+
# Parse streaming chunks and collect content
all_content = ""
has_role_chunk = False
has_content = False
-
+
for line in response.iter_lines():
if line:
- line_str = line.decode('utf-8')
- if line_str.startswith('data: '):
+ line_str = line.decode("utf-8")
+ if line_str.startswith("data: "):
data_str = line_str[6:] # Remove "data: " prefix
-
+
if data_str == "[DONE]":
break
-
+
try:
chunk_data = json.loads(data_str)
-
+
# Check chunk structure
- if 'choices' in chunk_data and len(chunk_data['choices']) > 0:
- choice = chunk_data['choices'][0]
- delta = choice.get('delta', {})
-
+ if "choices" in chunk_data and len(chunk_data["choices"]) > 0:
+ choice = chunk_data["choices"][0]
+ delta = choice.get("delta", {})
+
# Check for role chunk
- if 'role' in delta:
+ if "role" in delta:
has_role_chunk = True
print(f"โ
Found role chunk")
-
- # Check for content chunk
- if 'content' in delta:
- content = delta['content']
+
+ # Check for content chunk
+ if "content" in delta:
+ content = delta["content"]
all_content += content
has_content = True
print(f"โ
Found content: {content[:50]}...")
-
+
except json.JSONDecodeError as e:
print(f"โ Invalid JSON in chunk: {data_str}")
return False
-
+
print(f"\n๐ Test Results:")
print(f" Has role chunk: {has_role_chunk}")
print(f" Has content: {has_content}")
print(f" Total content length: {len(all_content)}")
print(f" Content preview: {all_content[:200]}...")
-
+
# Check if we got actual content instead of fallback message
fallback_messages = [
"I'm unable to provide a response at the moment",
- "I understand you're testing the system"
+ "I understand you're testing the system",
]
-
+
is_fallback = any(msg in all_content for msg in fallback_messages)
-
+
if has_content and not is_fallback and len(all_content) > 20:
print("\n๐ TextBlock fix is working!")
print("โ
Real content extracted successfully")
@@ -103,18 +96,19 @@ def test_textblock_fix():
print("\nโ TextBlock fix is not working")
print("โ ๏ธ Still receiving fallback content or no content")
return False
-
+
except Exception as e:
print(f"โ Test failed with exception: {e}")
return False
+
def main():
"""Test the TextBlock fix."""
print("๐ Testing TextBlock Content Extraction Fix")
print("=" * 50)
-
+
success = test_textblock_fix()
-
+
print("\n" + "=" * 50)
if success:
print("๐ TextBlock fix test PASSED!")
@@ -122,9 +116,10 @@ def main():
else:
print("โ TextBlock fix test FAILED")
print("โ ๏ธ Issue may still persist")
-
+
return success
+
if __name__ == "__main__":
success = main()
- exit(0 if success else 1)
\ No newline at end of file
+ exit(0 if success else 1)
diff --git a/tests/test_tool_execution.py b/tests/test_tool_execution.py
new file mode 100644
index 0000000..3c8fe34
--- /dev/null
+++ b/tests/test_tool_execution.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+"""
+Tests for tool execution functionality.
+
+Tests the fixes for enable_tools=true parameter:
+- permission_mode passthrough to ClaudeAgentOptions
+- parse_claude_message correctly handling multi-turn ResultMessage
+- DEFAULT_ALLOWED_TOOLS configuration
+"""
+
+import pytest
+from claude_agent_sdk import ClaudeAgentOptions
+
+
+class TestPermissionMode:
+ """Test permission_mode configuration for tool execution."""
+
+ def test_permission_mode_option_exists(self):
+ """Test that ClaudeAgentOptions supports permission_mode."""
+ options = ClaudeAgentOptions(max_turns=1, permission_mode="bypassPermissions")
+ assert options.permission_mode == "bypassPermissions"
+
+ def test_permission_mode_default(self):
+ """Test that permission_mode defaults to None/default."""
+ options = ClaudeAgentOptions(max_turns=1)
+ # permission_mode should be None or "default" when not set
+ assert options.permission_mode in [None, "default", ""]
+
+ def test_permission_mode_accept_edits(self):
+ """Test acceptEdits permission mode."""
+ options = ClaudeAgentOptions(max_turns=1, permission_mode="acceptEdits")
+ assert options.permission_mode == "acceptEdits"
+
+
+class TestDefaultAllowedTools:
+ """Test DEFAULT_ALLOWED_TOOLS constant."""
+
+ def test_default_allowed_tools_defined(self):
+ """Test that DEFAULT_ALLOWED_TOOLS is defined."""
+ from src.constants import DEFAULT_ALLOWED_TOOLS
+
+ assert isinstance(DEFAULT_ALLOWED_TOOLS, list)
+ assert len(DEFAULT_ALLOWED_TOOLS) > 0
+
+ def test_default_allowed_tools_contains_safe_tools(self):
+ """Test that DEFAULT_ALLOWED_TOOLS contains expected safe tools."""
+ from src.constants import DEFAULT_ALLOWED_TOOLS
+
+ # These tools should be in the default allowed set
+ expected_tools = ["Read", "Glob", "Grep", "Bash", "Write", "Edit"]
+ for tool in expected_tools:
+ assert tool in DEFAULT_ALLOWED_TOOLS, f"Expected {tool} in DEFAULT_ALLOWED_TOOLS"
+
+ def test_default_allowed_tools_excludes_dangerous(self):
+ """Test that potentially dangerous tools are excluded by default."""
+ from src.constants import DEFAULT_ALLOWED_TOOLS
+
+ # These tools should NOT be in the default allowed set
+ # (they're in DEFAULT_DISALLOWED_TOOLS)
+ dangerous_tools = ["Task", "WebFetch", "WebSearch"]
+ for tool in dangerous_tools:
+ assert (
+ tool not in DEFAULT_ALLOWED_TOOLS
+ ), f"{tool} should not be in DEFAULT_ALLOWED_TOOLS"
+
+
+class TestParseClaudeMessage:
+ """Test parse_claude_message correctly handles multi-turn conversations."""
+
+ def test_result_message_priority(self):
+ """Test that ResultMessage.result is prioritized over AssistantMessage."""
+ from src.claude_cli import ClaudeCodeCLI
+
+ cli = ClaudeCodeCLI(cwd="/tmp")
+
+ # Simulate multi-turn conversation messages
+ messages = [
+ # First assistant message (initial response)
+ {
+ "content": [type("TextBlock", (), {"text": "I'll list the files."})()],
+ },
+ # Tool use message (not text)
+ {
+ "content": [type("ToolUseBlock", (), {"name": "Bash", "input": {}})()],
+ },
+ # Final result message with full answer
+ {
+ "subtype": "success",
+ "result": "The files are:\n1. file1.txt\n2. file2.txt\n3. file3.txt",
+ },
+ ]
+
+ result = cli.parse_claude_message(messages)
+
+ # Should return the ResultMessage.result, not the first AssistantMessage
+ assert result == "The files are:\n1. file1.txt\n2. file2.txt\n3. file3.txt"
+
+ def test_fallback_to_last_assistant_message(self):
+ """Test fallback to last AssistantMessage when no ResultMessage."""
+ from src.claude_cli import ClaudeCodeCLI
+
+ cli = ClaudeCodeCLI(cwd="/tmp")
+
+ # Simulate messages without ResultMessage
+ messages = [
+ {
+ "content": [type("TextBlock", (), {"text": "First response"})()],
+ },
+ {
+ "content": [type("TextBlock", (), {"text": "Second response"})()],
+ },
+ ]
+
+ result = cli.parse_claude_message(messages)
+
+ # Should return the LAST text, not the first
+ assert result == "Second response"
+
+ def test_handles_empty_messages(self):
+ """Test handling of empty message list."""
+ from src.claude_cli import ClaudeCodeCLI
+
+ cli = ClaudeCodeCLI(cwd="/tmp")
+
+ result = cli.parse_claude_message([])
+ assert result is None
+
+ def test_handles_dict_content_blocks(self):
+ """Test handling of dict-based content blocks (old format)."""
+ from src.claude_cli import ClaudeCodeCLI
+
+ cli = ClaudeCodeCLI(cwd="/tmp")
+
+ messages = [{"content": [{"type": "text", "text": "Hello world"}]}]
+
+ result = cli.parse_claude_message(messages)
+ assert result == "Hello world"
+
+
+class TestClaudeCliPermissionMode:
+ """Test that ClaudeCodeCLI passes permission_mode correctly."""
+
+ def test_run_completion_accepts_permission_mode(self):
+ """Test that run_completion method accepts permission_mode parameter."""
+ from src.claude_cli import ClaudeCodeCLI
+ import inspect
+
+ # Check that permission_mode is in the method signature
+ sig = inspect.signature(ClaudeCodeCLI.run_completion)
+ param_names = list(sig.parameters.keys())
+
+ assert (
+ "permission_mode" in param_names
+ ), "run_completion should accept permission_mode parameter"
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/tests/test_tool_manager_unit.py b/tests/test_tool_manager_unit.py
new file mode 100644
index 0000000..78fea02
--- /dev/null
+++ b/tests/test_tool_manager_unit.py
@@ -0,0 +1,438 @@
+#!/usr/bin/env python3
+"""
+Unit tests for src/tool_manager.py
+
+Tests the ToolMetadata, ToolConfiguration, and ToolManager classes.
+These are pure unit tests that don't require a running server.
+"""
+
+import pytest
+from datetime import datetime, timedelta
+from unittest.mock import patch, MagicMock
+import threading
+import time
+
+from src.tool_manager import (
+ ToolMetadata,
+ ToolConfiguration,
+ ToolManager,
+ TOOL_METADATA,
+ tool_manager,
+)
+from src.constants import CLAUDE_TOOLS, DEFAULT_ALLOWED_TOOLS, DEFAULT_DISALLOWED_TOOLS
+
+
+class TestToolMetadata:
+ """Test the ToolMetadata dataclass."""
+
+ def test_creation_with_required_fields(self):
+ """ToolMetadata can be created with just required fields."""
+ metadata = ToolMetadata(
+ name="TestTool",
+ description="A test tool",
+ category="test",
+ )
+ assert metadata.name == "TestTool"
+ assert metadata.description == "A test tool"
+ assert metadata.category == "test"
+ assert metadata.parameters == {}
+ assert metadata.examples == []
+ assert metadata.is_safe is True
+ assert metadata.requires_network is False
+
+ def test_creation_with_all_fields(self):
+ """ToolMetadata can be created with all fields."""
+ metadata = ToolMetadata(
+ name="FullTool",
+ description="Full description",
+ category="system",
+ parameters={"param1": "value1", "param2": "value2"},
+ examples=["Example 1", "Example 2"],
+ is_safe=False,
+ requires_network=True,
+ )
+ assert metadata.name == "FullTool"
+ assert metadata.parameters == {"param1": "value1", "param2": "value2"}
+ assert len(metadata.examples) == 2
+ assert metadata.is_safe is False
+ assert metadata.requires_network is True
+
+ def test_tool_metadata_has_bash_tool(self):
+ """TOOL_METADATA contains Bash tool."""
+ assert "Bash" in TOOL_METADATA
+ bash = TOOL_METADATA["Bash"]
+ assert bash.name == "Bash"
+ assert bash.category == "system"
+ assert "command" in bash.parameters
+
+ def test_tool_metadata_has_read_tool(self):
+ """TOOL_METADATA contains Read tool."""
+ assert "Read" in TOOL_METADATA
+ read = TOOL_METADATA["Read"]
+ assert read.name == "Read"
+ assert read.category == "file"
+ assert read.is_safe is True
+
+ def test_tool_metadata_has_webfetch_tool(self):
+ """TOOL_METADATA contains WebFetch tool with network requirement."""
+ assert "WebFetch" in TOOL_METADATA
+ webfetch = TOOL_METADATA["WebFetch"]
+ assert webfetch.requires_network is True
+
+ def test_tool_metadata_task_is_unsafe(self):
+ """Task tool is marked as unsafe (can spawn sub-agents)."""
+ assert "Task" in TOOL_METADATA
+ task = TOOL_METADATA["Task"]
+ assert task.is_safe is False
+ assert task.category == "agent"
+
+
+class TestToolConfiguration:
+ """Test the ToolConfiguration dataclass."""
+
+ def test_creation_with_defaults(self):
+ """ToolConfiguration creates with default values."""
+ config = ToolConfiguration()
+ assert config.allowed_tools is None
+ assert config.disallowed_tools is None
+ assert isinstance(config.created_at, datetime)
+ assert isinstance(config.updated_at, datetime)
+
+ def test_creation_with_allowed_tools(self):
+ """ToolConfiguration can be created with allowed tools."""
+ config = ToolConfiguration(allowed_tools=["Bash", "Read", "Write"])
+ assert config.allowed_tools == ["Bash", "Read", "Write"]
+ assert config.disallowed_tools is None
+
+ def test_creation_with_disallowed_tools(self):
+ """ToolConfiguration can be created with disallowed tools."""
+ config = ToolConfiguration(disallowed_tools=["Task", "WebSearch"])
+ assert config.allowed_tools is None
+ assert config.disallowed_tools == ["Task", "WebSearch"]
+
+ def test_creation_with_both_lists(self):
+ """ToolConfiguration can have both allowed and disallowed."""
+ config = ToolConfiguration(
+ allowed_tools=["Bash", "Read", "Write", "Task"],
+ disallowed_tools=["Task"],
+ )
+ assert "Task" in config.allowed_tools
+ assert "Task" in config.disallowed_tools
+
+ def test_get_effective_tools_with_allowed_only(self):
+ """get_effective_tools uses allowed_tools when set."""
+ config = ToolConfiguration(allowed_tools=["Bash", "Read"])
+ effective = config.get_effective_tools()
+ assert effective == {"Bash", "Read"}
+
+ def test_get_effective_tools_with_disallowed_only(self):
+ """get_effective_tools removes disallowed from all tools."""
+ config = ToolConfiguration(disallowed_tools=["Task"])
+ effective = config.get_effective_tools()
+ assert "Task" not in effective
+ # Should have most other tools
+ assert "Bash" in effective
+ assert "Read" in effective
+
+ def test_get_effective_tools_with_both(self):
+ """get_effective_tools applies both allowed and disallowed."""
+ config = ToolConfiguration(
+ allowed_tools=["Bash", "Read", "Write", "Task"],
+ disallowed_tools=["Task", "Write"],
+ )
+ effective = config.get_effective_tools()
+ assert effective == {"Bash", "Read"}
+
+ def test_get_effective_tools_defaults_to_all_claude_tools(self):
+ """When nothing set, uses all CLAUDE_TOOLS."""
+ config = ToolConfiguration()
+ effective = config.get_effective_tools()
+ assert effective == set(CLAUDE_TOOLS)
+
+ def test_update_sets_allowed_tools(self):
+ """update() can set allowed_tools."""
+ config = ToolConfiguration()
+ original_updated = config.updated_at
+
+ time.sleep(0.001) # Ensure time difference
+ config.update(allowed_tools=["Bash"])
+
+ assert config.allowed_tools == ["Bash"]
+ assert config.updated_at > original_updated
+
+ def test_update_sets_disallowed_tools(self):
+ """update() can set disallowed_tools."""
+ config = ToolConfiguration()
+ config.update(disallowed_tools=["Task"])
+
+ assert config.disallowed_tools == ["Task"]
+
+ def test_update_with_none_preserves_existing(self):
+ """update() with None doesn't clear existing values."""
+ config = ToolConfiguration(allowed_tools=["Bash"])
+ config.update(disallowed_tools=["Task"])
+
+ assert config.allowed_tools == ["Bash"]
+ assert config.disallowed_tools == ["Task"]
+
+
+class TestToolManager:
+ """Test the ToolManager class."""
+
+ @pytest.fixture
+ def manager(self):
+ """Create a fresh ToolManager for each test."""
+ return ToolManager()
+
+ def test_initialization(self, manager):
+ """ToolManager initializes with global config and empty session configs."""
+ assert manager.global_config is not None
+ assert manager.session_configs == {}
+ assert manager.global_config.allowed_tools == list(DEFAULT_ALLOWED_TOOLS)
+ assert manager.global_config.disallowed_tools == list(DEFAULT_DISALLOWED_TOOLS)
+
+ def test_get_tool_metadata_existing(self, manager):
+ """get_tool_metadata returns metadata for existing tool."""
+ metadata = manager.get_tool_metadata("Bash")
+ assert metadata is not None
+ assert metadata.name == "Bash"
+ assert metadata.category == "system"
+
+ def test_get_tool_metadata_nonexistent(self, manager):
+ """get_tool_metadata returns None for nonexistent tool."""
+ metadata = manager.get_tool_metadata("NonExistentTool")
+ assert metadata is None
+
+ def test_list_all_tools(self, manager):
+ """list_all_tools returns all tool metadata."""
+ tools = manager.list_all_tools()
+ assert len(tools) == len(TOOL_METADATA)
+ assert all(isinstance(t, ToolMetadata) for t in tools)
+
+ def test_get_global_config(self, manager):
+ """get_global_config returns the global configuration."""
+ config = manager.get_global_config()
+ assert config is manager.global_config
+
+ def test_update_global_config(self, manager):
+ """update_global_config updates the global configuration."""
+ result = manager.update_global_config(
+ allowed_tools=["Bash", "Read"],
+ disallowed_tools=["Task"],
+ )
+ assert result.allowed_tools == ["Bash", "Read"]
+ assert result.disallowed_tools == ["Task"]
+
+ def test_get_session_config_nonexistent(self, manager):
+ """get_session_config returns None for nonexistent session."""
+ config = manager.get_session_config("nonexistent-session")
+ assert config is None
+
+ def test_set_session_config_creates_new(self, manager):
+ """set_session_config creates new config for session."""
+ result = manager.set_session_config(
+ session_id="session-123",
+ allowed_tools=["Bash"],
+ )
+ assert result.allowed_tools == ["Bash"]
+ assert "session-123" in manager.session_configs
+
+ def test_set_session_config_updates_existing(self, manager):
+ """set_session_config updates existing session config."""
+ manager.set_session_config("session-123", allowed_tools=["Bash"])
+ manager.set_session_config("session-123", disallowed_tools=["Task"])
+
+ config = manager.get_session_config("session-123")
+ assert config.allowed_tools == ["Bash"]
+ assert config.disallowed_tools == ["Task"]
+
+ def test_delete_session_config_existing(self, manager):
+ """delete_session_config removes existing session config."""
+ manager.set_session_config("session-123", allowed_tools=["Bash"])
+ assert "session-123" in manager.session_configs
+
+ result = manager.delete_session_config("session-123")
+ assert result is True
+ assert "session-123" not in manager.session_configs
+
+ def test_delete_session_config_nonexistent(self, manager):
+ """delete_session_config returns False for nonexistent session."""
+ result = manager.delete_session_config("nonexistent")
+ assert result is False
+
+ def test_get_effective_config_no_session(self, manager):
+ """get_effective_config returns global config when no session."""
+ config = manager.get_effective_config()
+ assert config is manager.global_config
+
+ def test_get_effective_config_with_session(self, manager):
+ """get_effective_config returns session config when exists."""
+ manager.set_session_config("session-123", allowed_tools=["Bash"])
+ config = manager.get_effective_config("session-123")
+
+ assert config is not manager.global_config
+ assert config.allowed_tools == ["Bash"]
+
+ def test_get_effective_config_missing_session_uses_global(self, manager):
+ """get_effective_config uses global when session doesn't exist."""
+ config = manager.get_effective_config("nonexistent")
+ assert config is manager.global_config
+
+ def test_get_effective_tools_global(self, manager):
+ """get_effective_tools returns sorted list from global config."""
+ manager.update_global_config(allowed_tools=["Write", "Bash", "Read"])
+ tools = manager.get_effective_tools()
+
+ assert tools == ["Bash", "Read", "Write"] # Sorted
+
+ def test_get_effective_tools_session(self, manager):
+ """get_effective_tools returns tools from session config."""
+ manager.set_session_config("session-123", allowed_tools=["Grep", "Glob"])
+ tools = manager.get_effective_tools("session-123")
+
+ assert tools == ["Glob", "Grep"] # Sorted
+
+ def test_validate_tools_all_valid(self, manager):
+ """validate_tools returns True for valid tools."""
+ result = manager.validate_tools(["Bash", "Read", "Write"])
+ assert result == {"Bash": True, "Read": True, "Write": True}
+
+ def test_validate_tools_some_invalid(self, manager):
+ """validate_tools returns False for invalid tools."""
+ result = manager.validate_tools(["Bash", "FakeTool", "Read"])
+ assert result == {"Bash": True, "FakeTool": False, "Read": True}
+
+ def test_validate_tools_empty_list(self, manager):
+ """validate_tools handles empty list."""
+ result = manager.validate_tools([])
+ assert result == {}
+
+ def test_get_stats(self, manager):
+ """get_stats returns statistics about tools."""
+ # Add some session configs
+ manager.set_session_config("session-1", allowed_tools=["Bash"])
+ manager.set_session_config("session-2", allowed_tools=["Read"])
+
+ stats = manager.get_stats()
+
+ assert stats["total_tools"] == len(CLAUDE_TOOLS)
+ assert stats["session_configs"] == 2
+ assert "tool_categories" in stats
+ assert "file" in stats["tool_categories"]
+ assert "system" in stats["tool_categories"]
+
+ def test_global_allowed_count_in_stats(self, manager):
+ """get_stats shows correct global allowed count."""
+ manager.update_global_config(allowed_tools=["Bash", "Read", "Write"])
+ stats = manager.get_stats()
+ assert stats["global_allowed"] == 3
+
+ def test_global_disallowed_count_in_stats(self, manager):
+ """get_stats shows correct global disallowed count."""
+ manager.update_global_config(disallowed_tools=["Task", "WebSearch"])
+ stats = manager.get_stats()
+ assert stats["global_disallowed"] == 2
+
+
+class TestToolManagerThreadSafety:
+ """Test thread safety of ToolManager operations."""
+
+ @pytest.fixture
+ def manager(self):
+ """Create a fresh ToolManager for each test."""
+ return ToolManager()
+
+ def test_concurrent_session_creation(self, manager):
+ """Multiple threads can create session configs concurrently."""
+ results = []
+ errors = []
+
+ def create_session(session_id):
+ try:
+ manager.set_session_config(session_id, allowed_tools=["Bash"])
+ results.append(session_id)
+ except Exception as e:
+ errors.append(str(e))
+
+ threads = []
+ for i in range(20):
+ t = threading.Thread(target=create_session, args=(f"session-{i}",))
+ threads.append(t)
+ t.start()
+
+ for t in threads:
+ t.join()
+
+ assert len(errors) == 0
+ assert len(results) == 20
+ assert len(manager.session_configs) == 20
+
+ def test_concurrent_config_updates(self, manager):
+ """Multiple threads can update global config concurrently."""
+ errors = []
+
+ def update_config(tool_name):
+ try:
+ manager.update_global_config(allowed_tools=[tool_name])
+ except Exception as e:
+ errors.append(str(e))
+
+ threads = []
+ tools = ["Bash", "Read", "Write", "Edit", "Glob"]
+ for tool in tools:
+ t = threading.Thread(target=update_config, args=(tool,))
+ threads.append(t)
+ t.start()
+
+ for t in threads:
+ t.join()
+
+ assert len(errors) == 0
+ # One of the tools should be set (last one to update wins)
+ assert manager.global_config.allowed_tools is not None
+
+
+class TestToolMetadataCategories:
+ """Test tool categories in TOOL_METADATA."""
+
+ def test_file_tools_category(self):
+ """File tools are correctly categorized."""
+ file_tools = ["Glob", "Grep", "Read", "Edit", "Write", "NotebookEdit"]
+ for tool_name in file_tools:
+ assert TOOL_METADATA[tool_name].category == "file"
+
+ def test_system_tools_category(self):
+ """System tools are correctly categorized."""
+ system_tools = ["Bash", "BashOutput", "KillShell"]
+ for tool_name in system_tools:
+ assert TOOL_METADATA[tool_name].category == "system"
+
+ def test_web_tools_category(self):
+ """Web tools are correctly categorized."""
+ web_tools = ["WebFetch", "WebSearch"]
+ for tool_name in web_tools:
+ assert TOOL_METADATA[tool_name].category == "web"
+ assert TOOL_METADATA[tool_name].requires_network is True
+
+ def test_productivity_tools_category(self):
+ """Productivity tools are correctly categorized."""
+ productivity_tools = ["TodoWrite", "Skill", "SlashCommand"]
+ for tool_name in productivity_tools:
+ assert TOOL_METADATA[tool_name].category == "productivity"
+
+ def test_agent_tools_category(self):
+ """Agent tools are correctly categorized."""
+ assert TOOL_METADATA["Task"].category == "agent"
+
+
+class TestGlobalToolManagerInstance:
+ """Test the global tool_manager instance."""
+
+ def test_global_instance_exists(self):
+ """Global tool_manager instance is available."""
+ assert tool_manager is not None
+ assert isinstance(tool_manager, ToolManager)
+
+ def test_global_instance_has_default_config(self):
+ """Global instance has default configuration."""
+ assert tool_manager.global_config is not None
diff --git a/tests/test_working_directory.py b/tests/test_working_directory.py
index a275036..d552cba 100644
--- a/tests/test_working_directory.py
+++ b/tests/test_working_directory.py
@@ -15,33 +15,36 @@
from src.claude_cli import ClaudeCodeCLI
+
def test_default_temp_directory():
"""Test that default working directory is a temp directory."""
print("Testing default temp directory creation...")
-
+
# Ensure CLAUDE_CWD is not set
original_cwd = os.environ.pop("CLAUDE_CWD", None)
-
+
try:
# Create CLI instance without cwd parameter
cli = ClaudeCodeCLI()
-
+
# Check that a temp directory was created
assert cli.temp_dir is not None, "Temp directory should be created"
- assert cli.temp_dir.startswith(tempfile.gettempdir()), f"Temp dir should be in system temp: {cli.temp_dir}"
+ assert cli.temp_dir.startswith(
+ tempfile.gettempdir()
+ ), f"Temp dir should be in system temp: {cli.temp_dir}"
assert "claude_code_workspace_" in cli.temp_dir, "Temp dir should have correct prefix"
assert os.path.exists(cli.cwd), f"Working directory should exist: {cli.cwd}"
assert str(cli.cwd) == cli.temp_dir, "Working directory should be the temp directory"
-
+
print(f" โ Created temp directory: {cli.temp_dir}")
-
+
# Clean up manually for testing
if cli.temp_dir and os.path.exists(cli.temp_dir):
shutil.rmtree(cli.temp_dir)
print(f" โ Cleaned up temp directory")
-
+
return True
-
+
except AssertionError as e:
print(f" โ {e}")
return False
@@ -57,26 +60,26 @@ def test_default_temp_directory():
def test_env_var_directory():
"""Test that CLAUDE_CWD environment variable is respected."""
print("\nTesting CLAUDE_CWD environment variable...")
-
+
# Create a test directory
test_dir = tempfile.mkdtemp(prefix="test_claude_cwd_")
original_cwd = os.environ.get("CLAUDE_CWD")
-
+
try:
# Set CLAUDE_CWD environment variable
os.environ["CLAUDE_CWD"] = test_dir
-
+
# Create CLI instance - it reads from env var directly
cli = ClaudeCodeCLI(cwd=os.environ.get("CLAUDE_CWD"))
-
+
# Check that the specified directory is used
assert cli.temp_dir is None, "No temp directory should be created when CLAUDE_CWD exists"
assert str(cli.cwd) == test_dir, f"Working directory should be {test_dir}, got {cli.cwd}"
-
+
print(f" โ Using CLAUDE_CWD: {test_dir}")
-
+
return True
-
+
except AssertionError as e:
print(f" โ {e}")
return False
@@ -96,22 +99,22 @@ def test_env_var_directory():
def test_explicit_cwd_parameter():
"""Test that explicit cwd parameter takes precedence."""
print("\nTesting explicit cwd parameter...")
-
+
# Create a test directory
test_dir = tempfile.mkdtemp(prefix="test_explicit_cwd_")
-
+
try:
# Create CLI instance with explicit cwd
cli = ClaudeCodeCLI(cwd=test_dir)
-
+
# Check that the specified directory is used
assert cli.temp_dir is None, "No temp directory should be created when cwd is provided"
assert str(cli.cwd) == test_dir, f"Working directory should be {test_dir}, got {cli.cwd}"
-
+
print(f" โ Using explicit cwd: {test_dir}")
-
+
return True
-
+
except AssertionError as e:
print(f" โ {e}")
return False
@@ -127,9 +130,9 @@ def test_explicit_cwd_parameter():
def test_nonexistent_directory_error():
"""Test that specifying a non-existent directory raises an error."""
print("\nTesting non-existent directory handling...")
-
+
non_existent_dir = "/this/directory/does/not/exist/12345"
-
+
try:
# Try to create CLI instance with non-existent directory
cli = ClaudeCodeCLI(cwd=non_existent_dir)
@@ -150,31 +153,31 @@ def test_nonexistent_directory_error():
def test_cross_platform_compatibility():
"""Test that temp directory creation works across platforms."""
print("\nTesting cross-platform compatibility...")
-
+
try:
# Get platform-specific temp directory
system_temp = tempfile.gettempdir()
print(f" System temp directory: {system_temp}")
-
+
# Create CLI instance
cli = ClaudeCodeCLI()
-
+
# Verify temp directory is in the correct location
assert cli.temp_dir.startswith(system_temp), f"Temp dir should be in {system_temp}"
-
+
# Verify path handling works correctly
assert isinstance(cli.cwd, Path), "Working directory should be a Path object"
assert cli.cwd.exists(), "Working directory should exist"
-
+
print(f" โ Platform: {os.name}")
print(f" โ Temp directory created correctly")
-
+
# Clean up
if cli.temp_dir and os.path.exists(cli.temp_dir):
shutil.rmtree(cli.temp_dir)
-
+
return True
-
+
except Exception as e:
print(f" โ Error: {e}")
return False
@@ -185,15 +188,15 @@ def main():
print("=" * 60)
print("Testing Working Directory Configuration")
print("=" * 60)
-
+
tests = [
test_default_temp_directory,
test_env_var_directory,
test_explicit_cwd_parameter,
test_nonexistent_directory_error,
- test_cross_platform_compatibility
+ test_cross_platform_compatibility,
]
-
+
results = []
for test in tests:
try:
@@ -201,11 +204,11 @@ def main():
except Exception as e:
print(f"\nโ Test {test.__name__} failed with exception: {e}")
results.append(False)
-
+
print("\n" + "=" * 60)
passed = sum(results)
total = len(results)
-
+
if passed == total:
print(f"โ
All {total} tests passed!")
return 0
@@ -215,4 +218,4 @@ def main():
if __name__ == "__main__":
- sys.exit(main())
\ No newline at end of file
+ sys.exit(main())