From 698e550e056ac118b68e264b63985cda629a49c7 Mon Sep 17 00:00:00 2001 From: M B Date: Sun, 15 Feb 2026 07:59:56 +0000 Subject: [PATCH 1/6] ci: add cross-platform install and api smoke matrix --- .github/workflows/install-matrix.yml | 208 +++++++++++++++++++++++++++ claude_code_api/core/database.py | 3 +- claude_code_api/main.py | 6 +- docs/dev.md | 17 +++ tests/conftest.py | 129 ++++++++++++----- 5 files changed, 319 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/install-matrix.yml diff --git a/.github/workflows/install-matrix.yml b/.github/workflows/install-matrix.yml new file mode 100644 index 0000000..9e85b59 --- /dev/null +++ b/.github/workflows/install-matrix.yml @@ -0,0 +1,208 @@ +name: Install Matrix + API Smoke + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + inputs: + run_live_claude: + description: Run optional live Claude + Haiku validation + required: false + type: boolean + default: false + live_model: + description: Model ID for optional live validation + required: false + type: string + default: claude-haiku-4-5-20251001 + +permissions: + contents: read + +jobs: + install-check: + name: Install ${{ matrix.install_mode }} | ${{ matrix.os }} | py${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + python-version: + - '3.11' + - '3.12' + install_mode: + - pep517 + - fallback + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Upgrade packaging tools + run: python -m pip install --upgrade pip setuptools wheel + + - name: Install package with PEP 517 editable mode + if: ${{ matrix.install_mode == 'pep517' }} + run: python -m pip install -e . --use-pep517 + + - name: Install package with editable fallback mode + if: ${{ matrix.install_mode == 'fallback' }} + run: python -m pip install -e . + + - name: Validate dependency graph + run: python -m pip check + + - name: Validate runtime imports + run: python -c "import claude_code_api, greenlet, sqlalchemy; print('install-import-smoke-ok')" + + api-smoke: + name: API smoke | ${{ matrix.os }} | py${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + needs: install-check + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + python-version: + - '3.11' + - '3.12' + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Upgrade packaging tools + run: python -m pip install --upgrade pip setuptools wheel + + - name: Install project with test extras + run: python -m pip install -e ".[test]" --use-pep517 + + - name: Run deterministic tests (excluding live e2e) + run: python -m pytest -q -m "not e2e" + + - name: Run API health smoke test + run: python -m pytest -q tests/test_end_to_end.py::TestHealthAndBasics::test_health_check + + - name: Run API models smoke test + run: python -m pytest -q tests/test_end_to_end.py::TestModelsAPI::test_list_models + + - name: Upload test artifacts on failure + if: ${{ failure() }} + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: api-smoke-artifacts-${{ matrix.os }}-py${{ matrix.python-version }} + path: | + .pytest_cache + dist/tests + if-no-files-found: ignore + retention-days: 14 + + live-claude-haiku: + name: Live Claude + Haiku validation (manual) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.run_live_claude && secrets.ANTHROPIC_API_KEY != '' }} + runs-on: ubuntu-latest + needs: api-smoke + continue-on-error: true + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.12' + + - name: Upgrade packaging tools + run: python -m pip install --upgrade pip setuptools wheel + + - name: Install project with test extras + run: python -m pip install -e ".[test]" --use-pep517 + + - name: Install Claude CLI + run: curl -fsSL https://claude.ai/install.sh | bash + + - name: Add Claude CLI path + run: echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Configure Claude auth from API key + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + python - <<'PY' + import json + import os + from pathlib import Path + + config_dir = Path.home() / ".config" / "claude" + config_dir.mkdir(parents=True, exist_ok=True) + config = {"apiKey": os.environ["ANTHROPIC_API_KEY"], "autoUpdate": False} + with (config_dir / "config.json").open("w", encoding="utf-8") as handle: + json.dump(config, handle) + PY + + - name: Start API server + run: | + nohup python -m claude_code_api.main > api-server.log 2>&1 & + echo "$!" > api-server.pid + + - name: Wait for health endpoint + run: | + python - <<'PY' + import time + import urllib.error + import urllib.request + + url = "http://127.0.0.1:8000/health" + deadline = time.time() + 60 + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=5) as response: + if response.status == 200: + raise SystemExit(0) + except urllib.error.URLError: + pass + time.sleep(2) + + raise SystemExit("API health check failed after 60 seconds") + PY + + - name: Run live E2E against Haiku + env: + CLAUDE_CODE_API_E2E: '1' + CLAUDE_CODE_API_BASE_URL: http://127.0.0.1:8000 + CLAUDE_CODE_API_TEST_MODEL: ${{ inputs.live_model }} + run: python -m pytest -q tests/test_e2e_live_api.py -m e2e -v + + - name: Stop API server + if: ${{ always() }} + run: | + if [ -f api-server.pid ]; then + kill "$(cat api-server.pid)" || true + fi + + - name: Upload live validation logs + if: ${{ always() }} + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: live-claude-haiku-logs + path: | + api-server.log + dist/tests + if-no-files-found: ignore + retention-days: 14 diff --git a/claude_code_api/core/database.py b/claude_code_api/core/database.py index 32844a4..2740864 100644 --- a/claude_code_api/core/database.py +++ b/claude_code_api/core/database.py @@ -17,8 +17,7 @@ update, ) from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import relationship +from sqlalchemy.orm import declarative_base, relationship from claude_code_api.models.claude import get_default_model from claude_code_api.utils.time import utc_now diff --git a/claude_code_api/main.py b/claude_code_api/main.py index 8cb6468..8d5c630 100644 --- a/claude_code_api/main.py +++ b/claude_code_api/main.py @@ -132,11 +132,7 @@ async def http_exception_handler(request, exc): @app.exception_handler(RequestValidationError) async def validation_exception_handler(request, exc): """Return OpenAI-style errors for validation failures.""" - status_code = getattr( - status, - "HTTP_422_UNPROCESSABLE_CONTENT", - status.HTTP_422_UNPROCESSABLE_ENTITY, - ) + status_code = getattr(status, "HTTP_422_UNPROCESSABLE_CONTENT", 422) for error in exc.errors(): if error.get("type") in {"value_error.jsondecode", "json_invalid"}: status_code = status.HTTP_400_BAD_REQUEST diff --git a/docs/dev.md b/docs/dev.md index 1f19203..531171f 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -80,6 +80,23 @@ Coverage-only artifacts for Sonar: make coverage-sonar ``` +## CI Matrix Testing + +- Cross-platform install and API smoke checks run in `.github/workflows/install-matrix.yml`. +- Matrix coverage includes: + - OS: `ubuntu-latest`, `macos-latest`, `windows-latest` + - Python: `3.11`, `3.12` + - Install modes: explicit PEP 517 editable and editable fallback path (`pip install -e .`) +- The workflow runs deterministic tests with `pytest -m "not e2e"` and targeted API smoke checks for `/health` and `/v1/models`. + +Optional live validation (manual only): + +- Trigger `Install Matrix + API Smoke` via `workflow_dispatch`. +- Set `run_live_claude=true`. +- Provide repository secret `ANTHROPIC_API_KEY`. +- Optional input `live_model` defaults to `claude-haiku-4-5-20251001`. +- The live job is non-blocking (`continue-on-error`) while reliability is being established. + ## Logging - Logging is configured centrally in `claude_code_api/core/logging_config.py`. diff --git a/tests/conftest.py b/tests/conftest.py index 5085f0f..76c32ac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import json import os import shutil +import sys import tempfile from pathlib import Path @@ -19,6 +20,91 @@ PROJECT_ROOT = Path(__file__).parent.parent +def _serialize_fixture_rules(fixture_rules, fixtures_dir: Path): + serialized_rules = [] + for rule in fixture_rules: + matches = [str(match).lower() for match in rule.get("match", []) if match] + fixture_file = rule.get("file") + if not fixture_file or not matches: + continue + serialized_rules.append( + { + "matches": matches, + "fixture_path": str(fixtures_dir / fixture_file), + } + ) + return serialized_rules + + +def _create_mock_claude_binary( + temp_dir: str, + default_fixture: Path, + fixture_rules, + fixtures_dir: Path, +) -> str: + """Create a mock Claude CLI launcher that works on POSIX and Windows.""" + serialized_rules = _serialize_fixture_rules(fixture_rules, fixtures_dir) + runner_path = Path(temp_dir) / "claude_mock.py" + runner_code = "\n".join( + [ + "#!/usr/bin/env python3", + "import sys", + "", + f"DEFAULT_FIXTURE = {str(default_fixture)!r}", + f"FIXTURE_RULES = {serialized_rules!r}", + "", + "def _extract_prompt(args):", + " for idx, value in enumerate(args):", + " if value == '-p' and idx + 1 < len(args):", + " return args[idx + 1]", + " return ''", + "", + "def _resolve_fixture(prompt):", + " prompt_lower = prompt.lower()", + " fixture_path = DEFAULT_FIXTURE", + " for rule in FIXTURE_RULES:", + " if any(match in prompt_lower for match in rule['matches']):", + " fixture_path = rule['fixture_path']", + " return fixture_path", + "", + "def main():", + " args = sys.argv[1:]", + " if args and args[0] == '--version':", + " print('Claude Code 1.0.0')", + " return 0", + " prompt = _extract_prompt(args)", + " fixture_path = _resolve_fixture(prompt)", + " with open(fixture_path, 'r', encoding='utf-8') as handle:", + " sys.stdout.write(handle.read())", + " return 0", + "", + "if __name__ == '__main__':", + " raise SystemExit(main())", + "", + ] + ) + runner_path.write_text(runner_code, encoding="utf-8") + os.chmod(runner_path, 0o755) + + if os.name == "nt": + launcher_path = Path(temp_dir) / "claude.cmd" + launcher_code = f'@echo off\r\n"{sys.executable}" "{runner_path}" %*\r\n' + launcher_path.write_text(launcher_code, encoding="utf-8") + return str(launcher_path) + + launcher_path = Path(temp_dir) / "claude" + launcher_code = "\n".join( + [ + "#!/usr/bin/env sh", + f'exec "{sys.executable}" "{runner_path}" "$@"', + "", + ] + ) + launcher_path.write_text(launcher_code, encoding="utf-8") + os.chmod(launcher_path, 0o755) + return str(launcher_path) + + @pytest.fixture(scope="session", autouse=True) def setup_test_environment(): """Setup test environment before all tests.""" @@ -55,43 +141,12 @@ def setup_test_environment(): except Exception as exc: raise RuntimeError(f"Failed to parse fixture index: {exc}") from exc - # Create a mock binary that replays recorded JSONL fixtures - mock_path = os.path.join(temp_dir, "claude") - with open(mock_path, "w") as f: - f.write("#!/usr/bin/env bash\n") - f.write( - 'if [ "$1" == "--version" ]; then echo "Claude Code 1.0.0"; exit 0; fi\n' - ) - f.write('prompt=""\n') - f.write('args=("$@")\n') - f.write("for ((i=0; i<${#args[@]}; i++)); do\n") - f.write(' if [ "${args[$i]}" == "-p" ]; then\n') - f.write(' prompt="${args[$((i+1))]}"\n') - f.write(" break\n") - f.write(" fi\n") - f.write("done\n") - f.write( - 'prompt_lower="$(printf "%s" "$prompt" | tr "[:upper:]" "[:lower:]")"\n' - ) - f.write(f'fixture_default="{default_fixture}"\n') - f.write('fixture_match="$fixture_default"\n') - for rule in fixture_rules: - matches = rule.get("match", []) - fixture_file = rule.get("file") - if not fixture_file or not matches: - continue - fixture_path = fixtures_dir / fixture_file - for match in matches: - match_escaped = str(match).replace('"', '\\"') - line = ( - f'if echo "$prompt_lower" | grep -q "{match_escaped}"; ' - f'then fixture_match="{fixture_path}"; ' - "fi\n" - ) - f.write(line) - f.write('cat "$fixture_match"\n') - os.chmod(mock_path, 0o755) - settings.claude_binary_path = mock_path + settings.claude_binary_path = _create_mock_claude_binary( + temp_dir=temp_dir, + default_fixture=default_fixture, + fixture_rules=fixture_rules, + fixtures_dir=fixtures_dir, + ) else: # Ensure the real binary is available when requested if not shutil.which(settings.claude_binary_path) and not os.path.exists( From b2a72dbbb278af21658037c908e8ed545d86c015 Mon Sep 17 00:00:00 2001 From: M B Date: Sun, 15 Feb 2026 08:02:46 +0000 Subject: [PATCH 2/6] ci: fix workflow-dispatch input context in matrix workflow --- .github/workflows/install-matrix.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/install-matrix.yml b/.github/workflows/install-matrix.yml index 9e85b59..1be7cca 100644 --- a/.github/workflows/install-matrix.yml +++ b/.github/workflows/install-matrix.yml @@ -115,7 +115,7 @@ jobs: live-claude-haiku: name: Live Claude + Haiku validation (manual) - if: ${{ github.event_name == 'workflow_dispatch' && inputs.run_live_claude && secrets.ANTHROPIC_API_KEY != '' }} + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.run_live_claude == 'true' && secrets.ANTHROPIC_API_KEY != '' }} runs-on: ubuntu-latest needs: api-smoke continue-on-error: true @@ -186,7 +186,7 @@ jobs: env: CLAUDE_CODE_API_E2E: '1' CLAUDE_CODE_API_BASE_URL: http://127.0.0.1:8000 - CLAUDE_CODE_API_TEST_MODEL: ${{ inputs.live_model }} + CLAUDE_CODE_API_TEST_MODEL: ${{ github.event.inputs.live_model || 'claude-haiku-4-5-20251001' }} run: python -m pytest -q tests/test_e2e_live_api.py -m e2e -v - name: Stop API server From 92d3a9777cfc71e481656a2e8b104441acc7ad04 Mon Sep 17 00:00:00 2001 From: M B Date: Sun, 15 Feb 2026 08:03:52 +0000 Subject: [PATCH 3/6] ci: move secret guard out of job-level condition --- .github/workflows/install-matrix.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/install-matrix.yml b/.github/workflows/install-matrix.yml index 1be7cca..91e69c5 100644 --- a/.github/workflows/install-matrix.yml +++ b/.github/workflows/install-matrix.yml @@ -115,7 +115,7 @@ jobs: live-claude-haiku: name: Live Claude + Haiku validation (manual) - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.run_live_claude == 'true' && secrets.ANTHROPIC_API_KEY != '' }} + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.run_live_claude == 'true' }} runs-on: ubuntu-latest needs: api-smoke continue-on-error: true @@ -140,6 +140,15 @@ jobs: - name: Add Claude CLI path run: echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Verify ANTHROPIC_API_KEY is configured + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + if [ -z "${ANTHROPIC_API_KEY:-}" ]; then + echo "ANTHROPIC_API_KEY secret is required for live Claude validation." + exit 1 + fi + - name: Configure Claude auth from API key env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} From 1e4e440dd362d8e6a8b64219f0f65f5d32ada74c Mon Sep 17 00:00:00 2001 From: M B Date: Sun, 15 Feb 2026 08:06:02 +0000 Subject: [PATCH 4/6] build: add explicit greenlet runtime dependency --- pyproject.toml | 1 + setup.py | 1 + 2 files changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 80cee6b..a66c8a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "python-multipart>=0.0.6", "pydantic-settings>=2.1.0", "sqlalchemy>=2.0.23", + "greenlet>=3.0.0", "aiosqlite>=0.19.0", "alembic>=1.13.0", "passlib[bcrypt]>=1.7.4", diff --git a/setup.py b/setup.py index bb7c14d..ebcc010 100644 --- a/setup.py +++ b/setup.py @@ -24,6 +24,7 @@ "python-multipart>=0.0.6", "pydantic-settings>=2.1.0", "sqlalchemy>=2.0.23", + "greenlet>=3.0.0", "aiosqlite>=0.19.0", "alembic>=1.13.0", "passlib[bcrypt]>=1.7.4", From e764df50d2400f711ead43473ad6c37217f867ca Mon Sep 17 00:00:00 2001 From: M B Date: Sun, 15 Feb 2026 08:12:54 +0000 Subject: [PATCH 5/6] build: include requests in test extras --- pyproject.toml | 1 + setup.py | 1 + 2 files changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index a66c8a8..7beb1ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ test = [ "pytest-asyncio>=0.21.0", "pytest-cov>=4.1.0", "httpx>=0.25.0", + "requests>=2.31.0", "pytest-mock>=3.12.0", ] dev = [ diff --git a/setup.py b/setup.py index ebcc010..ea8ac83 100644 --- a/setup.py +++ b/setup.py @@ -37,6 +37,7 @@ "pytest-asyncio>=0.21.0", "pytest-cov>=4.1.0", "httpx>=0.25.0", + "requests>=2.31.0", "pytest-mock>=3.12.0", ], "dev": [ From 4c5a2f129806e4cc21a867d4883c9d0ba8d96a20 Mon Sep 17 00:00:00 2001 From: M B Date: Sun, 15 Feb 2026 08:16:49 +0000 Subject: [PATCH 6/6] test: make security path assertions cross-platform --- tests/test_security.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_security.py b/tests/test_security.py index 42c5916..e53ddb7 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1,3 +1,5 @@ +import os + import pytest from fastapi import HTTPException @@ -7,7 +9,8 @@ def test_validate_path_valid(): base = "/tmp/projects" path = "project1" - assert validate_path(path, base) == "/tmp/projects/project1" + expected = os.path.realpath(os.path.join(base, "project1")) + assert validate_path(path, base) == expected def test_validate_path_traversal(): @@ -31,4 +34,5 @@ def test_validate_path_absolute_traversal(): def test_validate_path_absolute_valid(): base = "/tmp/projects" path = "/tmp/projects/project1" - assert validate_path(path, base) == "/tmp/projects/project1" + expected = os.path.realpath(path) + assert validate_path(path, base) == expected