edgequake-llm 0.4.0

Multi-provider LLM abstraction library with caching, rate limiting, and cost tracking
Documentation
name: Publish edgequake-litellm to PyPI

# Triggered by pushing a Python version tag:  git tag py-v0.1.0 && git push --tags
# Also supports manual dispatch with optional dry-run.
on:
  push:
    tags:
      - "py-v[0-9]+.[0-9]+.[0-9]+"
  workflow_dispatch:
    inputs:
      dry-run:
        description: "Dry run — build wheels but do NOT upload to PyPI"
        required: false
        default: "true"
        type: choice
        options: ["true", "false"]

env:
  CARGO_TERM_COLOR: always
  RUST_BACKTRACE: 1
  WORKING_DIR: edgequake-litellm

# Only one publish at a time
concurrency:
  group: python-publish
  cancel-in-progress: false

jobs:
  # ─── 0. Pre-publish quality gate ───────────────────────────────────────────
  preflight:
    name: Pre-publish checks
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python 3.11
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install stable Rust + clippy + rustfmt
        uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy, rustfmt

      - name: Cache Cargo
        uses: actions/cache@v4
        with:
          path: |
            ~/.cargo/registry
            ~/.cargo/git
            target
          key: ${{ runner.os }}-cargo-publish-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: ${{ runner.os }}-cargo-publish-

      - name: Install Python build tools
        working-directory: ${{ env.WORKING_DIR }}
        run: pip install maturin "ruff>=0.3" "mypy>=1.8" "pytest>=8.0" "pytest-asyncio>=0.24"

      # 1. Rust formatting
      - name: Check Rust formatting
        working-directory: ${{ env.WORKING_DIR }}
        run: cargo fmt --all -- --check

      # 2. Rust lint
      - name: Clippy
        working-directory: ${{ env.WORKING_DIR }}
        run: cargo clippy --all-features -- -D warnings

      # 3. Python lint
      - name: Ruff check
        working-directory: ${{ env.WORKING_DIR }}
        run: ruff check python/

      # 4. Build dev wheel and run unit tests
      - name: Build (PEP-517) and run unit tests
        working-directory: ${{ env.WORKING_DIR }}
        run: |
          pip install . -v
          pytest tests/ -q -k "not e2e" --tb=short

      # 5. Verify tag matches pyproject.toml version (tag-triggered runs only)
      - name: Verify tag matches pyproject.toml version
        if: startsWith(github.ref, 'refs/tags/py-v')
        shell: bash
        run: |
          TAG="${GITHUB_REF_NAME}"                    # e.g. py-v0.1.0
          EXPECTED="py-v$(grep '^version' ${{ env.WORKING_DIR }}/pyproject.toml | head -1 | cut -d'"' -f2)"
          echo "Git tag       : $TAG"
          echo "pyproject.toml: $EXPECTED"
          if [ "$TAG" != "$EXPECTED" ]; then
            echo "::error::Tag ($TAG) does not match pyproject.toml version ($EXPECTED). Bump version before tagging."
            exit 1
          fi

      # 6. Verify pyproject.toml and Cargo.toml versions match
      - name: Verify pyproject.toml and Cargo.toml versions match
        shell: bash
        run: |
          PY_VER=$(grep '^version' ${{ env.WORKING_DIR }}/pyproject.toml | head -1 | cut -d'"' -f2)
          RS_VER=$(grep '^version' ${{ env.WORKING_DIR }}/Cargo.toml | head -1 | cut -d'"' -f2)
          echo "pyproject.toml: $PY_VER"
          echo "Cargo.toml    : $RS_VER"
          if [ "$PY_VER" != "$RS_VER" ]; then
            echo "::error::Version mismatch — pyproject.toml ($PY_VER) != Cargo.toml ($RS_VER)"
            exit 1
          fi

      # 7. Maturin dry-run build to catch packaging errors early
      - name: Maturin dry-run wheel build
        working-directory: ${{ env.WORKING_DIR }}
        run: maturin build --release --out /tmp/maturin-check

  # ─── 1. Build source distribution (sdist) ──────────────────────────────────
  sdist:
    name: Build sdist
    runs-on: ubuntu-latest
    needs: preflight
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python 3.11
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install maturin
        run: pip install maturin

      - name: Build sdist
        working-directory: ${{ env.WORKING_DIR }}
        run: maturin sdist --out dist

      - name: Upload sdist
        uses: actions/upload-artifact@v4
        with:
          name: sdist
          path: ${{ env.WORKING_DIR }}/dist/*.tar.gz

  # ─── 2. Build binary wheels (all platforms + architectures) ────────────────
  #
  #  Platform / Arch matrix:
  #    Linux   manylinux  x86_64
  #    Linux   manylinux  aarch64   (cross via QEMU)
  #    Linux   musllinux  x86_64    (Alpine / Docker)
  #    Linux   musllinux  aarch64   (Alpine ARM, cross via QEMU)
  #    macOS              x86_64    (Intel — macos-13)
  #    macOS              arm64     (Apple Silicon — macos-latest)
  #    Windows            x86_64
  #    Windows            aarch64   (cross-compile on x86_64 runner)
  #
  build-wheels:
    name: Build wheel (${{ matrix.label }})
    needs: preflight
    strategy:
      fail-fast: false
      matrix:
        include:
          # ── Linux manylinux x86_64 ──────────────────────────────────────────
          - label: linux-x86_64-manylinux
            os: ubuntu-latest
            target: x86_64
            manylinux: auto

          # ── Linux manylinux aarch64 (QEMU cross-compile) ────────────────────
          - label: linux-aarch64-manylinux
            os: ubuntu-latest
            target: aarch64
            manylinux: auto
            can-fail: true  # ring crate cross-compile issues with old GCC in manylinux2014
          # ── Linux musllinux x86_64 (Alpine) ────────────────────────────────
          - label: linux-x86_64-musl
            os: ubuntu-latest
            target: x86_64
            manylinux: musllinux_1_2

          # ── Linux musllinux aarch64 (Alpine ARM, QEMU cross-compile) ────────
          - label: linux-aarch64-musl
            os: ubuntu-latest
            target: aarch64
            manylinux: musllinux_1_2
            can-fail: true  # ring crate cross-compile may be flaky on QEMU

          # ── macOS Intel (x86_64) ────────────────────────────────────────────
          - label: macos-x86_64
            os: macos-latest  # ARM64 runner cross-compiles x86_64 (avoids flaky macos-13)
            target: x86_64
            manylinux: ""

          # ── macOS Apple Silicon (arm64) ─────────────────────────────────────
          - label: macos-arm64
            os: macos-latest
            target: aarch64
            manylinux: ""

          # ── Windows x86_64 ──────────────────────────────────────────────────
          - label: windows-x86_64
            os: windows-latest
            target: x86_64
            manylinux: ""


    runs-on: ${{ matrix.os }}
    continue-on-error: ${{ matrix.can-fail == true }}

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python 3.11
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Build wheel (maturin-action)
        uses: PyO3/maturin-action@v1
        with:
          working-directory: ${{ env.WORKING_DIR }}
          target: ${{ matrix.target }}
          manylinux: ${{ matrix.manylinux }}
          # abi3 wheel — one wheel per platform covers Python 3.9–3.13+
          args: --release --out dist
          sccache: "true"
          # Ensure Python 3.9+ is the default inside the manylinux container
          # so that PyO3's abi3-py39 feature generates correct wheel tags.
          before-script-linux: |
            if [ -d /opt/python/cp39-cp39/bin ]; then
              export PATH=/opt/python/cp39-cp39/bin:$PATH
            fi
        env:
          # Fix ring crate cross-compilation for aarch64 Linux
          # -D__ARM_ARCH=8 needed for old GCC in manylinux2014 container (glibc)
          CFLAGS_aarch64_unknown_linux_gnu: "-march=armv8-a -D__ARM_ARCH=8"
          # Same fix for musl aarch64 (different target triple env var)
          CFLAGS_aarch64_unknown_linux_musl: "-march=armv8-a -D__ARM_ARCH=8"

      - name: Upload wheel artifact
        uses: actions/upload-artifact@v4
        with:
          name: wheel-${{ matrix.label }}
          path: ${{ env.WORKING_DIR }}/dist/*.whl

  # ─── 3. Smoke test the native platform wheels ──────────────────────────────
  smoke-test:
    name: Smoke test (${{ matrix.os }})
    needs: build-wheels
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-latest
            wheel-pattern: "wheel-linux-x86_64-manylinux"
          - os: macos-latest
            wheel-pattern: "wheel-macos-arm64"
          # Note: macos-x86_64 wheel is cross-compiled; skip smoke test (arch mismatch)
          - os: windows-latest
            wheel-pattern: "wheel-windows-x86_64"
    runs-on: ${{ matrix.os }}
    continue-on-error: ${{ matrix.can-fail == true }}

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python 3.11
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Download wheel
        uses: actions/download-artifact@v4
        with:
          name: ${{ matrix.wheel-pattern }}
          path: dist

      - name: Install wheel
        run: pip install edgequake-litellm --find-links dist --no-index

      - name: Smoke test — mock provider (no API keys needed)
        shell: python
        run: |
          import edgequake_litellm as eq

          # 1. Basic completion
          resp = eq.completion("mock/test", [{"role": "user", "content": "hi"}])
          assert resp.content is not None, "resp.content is None"
          assert isinstance(resp.content, str), f"resp.content is {type(resp.content)}"

          # 2. litellm-compatible access path
          assert resp.choices[0].message.content is not None, "choices path failed"

          # 3. Version
          assert eq.__version__ != "0.0.0-dev", f"dev version: {eq.__version__}"

          print(f"Smoke test PASSED — version={eq.__version__}, content={resp.content!r}")

      - name: Smoke test — unit tests (no API keys)
        run: |
          pip install "pytest>=8.0" "pytest-asyncio>=0.24"
          pytest edgequake-litellm/tests/ -q -k "not e2e" --tb=short

  # ─── 4. Publish to PyPI ─────────────────────────────────────────────────────
  publish:
    name: Publish to PyPI
    needs: [build-wheels, sdist]
    runs-on: ubuntu-latest
    # Run publish even if some wheel builds failed (continue-on-error platforms)
    if: always() && needs.sdist.result == 'success'
    permissions:
      id-token: write  # Required for OIDC Trusted Publishers on PyPI

    steps:
      - name: Download all wheels
        uses: actions/download-artifact@v4
        with:
          pattern: wheel-*
          path: dist
          merge-multiple: true

      - name: Download sdist
        uses: actions/download-artifact@v4
        with:
          name: sdist
          path: dist

      - name: List dist contents
        run: ls -la dist/

      - name: Determine publish method
        if: >-
          startsWith(github.ref, 'refs/tags/py-v') ||
          (github.event_name == 'workflow_dispatch' && github.event.inputs.dry-run == 'false')
        id: pub-method
        env:
          PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
        run: |
          if [ -n "$PYPI_API_TOKEN" ]; then
            echo "method=token" >> "$GITHUB_OUTPUT"
            echo "Using API token auth (PYPI_API_TOKEN secret found)"
          else
            echo "method=oidc" >> "$GITHUB_OUTPUT"
            echo "Using OIDC Trusted Publisher auth (no PYPI_API_TOKEN secret)"
          fi

      - name: Publish to PyPI (OIDC Trusted Publisher)
        if: >-
          steps.pub-method.outputs.method == 'oidc' &&
          (startsWith(github.ref, 'refs/tags/py-v') ||
          (github.event_name == 'workflow_dispatch' && github.event.inputs.dry-run == 'false'))
        uses: pypa/gh-action-pypi-publish@release/v1
        with:
          packages-dir: dist/
          verbose: true
          attestations: false
          # Configure pending publisher at: https://pypi.org/manage/account/publishing/
          # Required settings: Project name=edgequake-litellm, Owner=raphaelmansuy,
          #   Repository=edgequake-llm, Workflow=python-publish.yml, Environment=(blank)

      - name: Publish to PyPI (API Token)
        if: >-
          steps.pub-method.outputs.method == 'token' &&
          (startsWith(github.ref, 'refs/tags/py-v') ||
          (github.event_name == 'workflow_dispatch' && github.event.inputs.dry-run == 'false'))
        uses: pypa/gh-action-pypi-publish@release/v1
        with:
          packages-dir: dist/
          user: __token__
          password: ${{ secrets.PYPI_API_TOKEN }}
          verbose: true
          attestations: false

      - name: Dry-run summary (no upload)
        if: >-
          github.event_name == 'workflow_dispatch' && github.event.inputs.dry-run == 'true'
        run: |
          echo "DRY RUN — wheels built but NOT uploaded to PyPI."
          echo ""
          echo "Artifacts ready to publish:"
          ls -la dist/
          echo ""
          echo "To publish for real, push a tag:  git tag py-v<version> && git push --tags"
          echo "Or re-run this workflow with dry-run=false."