diff --git a/.github/workflows/build-litellm.yml b/.github/workflows/build-litellm.yml new file mode 100644 index 0000000..2cd4f42 --- /dev/null +++ b/.github/workflows/build-litellm.yml @@ -0,0 +1,214 @@ +# SPDX-FileCopyrightText: 2026 The RISE Project +# SPDX-License-Identifier: MIT +--- +# litellm is no longer pure-Python: since the 1.9x line its build-backend is +# maturin and it ships a PyO3/Rust extension (litellm.rust_bridge._native, crate +# at litellm-rust/crates/python-bridge). Upstream's PyPI release publishes exactly +# ONE Linux wheel per arch - litellm--cp310-abi3 - loadable on every CPython +# 3.10-3.14 (requires-python is ">=3.10, <3.15"), and there is NO free-threaded +# (cp314t) wheel. The abi3 pyo3 feature that yields that single wheel is added at +# release time and is NOT in the git tag (see the "abi3 floor" step below), so we +# set it ourselves - pinned to abi3-py312 (RISE's minimum published Python) - and +# build one cp312-abi3 wheel, then let cibuildwheel reuse+test it on 3.12/3.13/3.14. +name: Build litellm wheels (riscv64) + +on: + workflow_dispatch: + inputs: + version: + description: 'litellm version to build (git tag without leading v, e.g. 1.97.0)' + required: true + default: '1.97.0' + pull_request: + paths: + - '.github/workflows/build-litellm.yml' + +concurrency: + group: ${{ github.workflow }}-${{ inputs.version || '1.97.0' }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read # to fetch code (actions/checkout) + +env: + # `inputs.version` is empty on pull_request events; default to 1.97.0 there. + LITELLM_VERSION: ${{ inputs.version || '1.97.0' }} + +jobs: + # Build the sdist ourselves from an upstream checkout (never consume the + # prebuilt PyPI sdist). The litellm sdist is self-contained and + # arch-independent: it bundles the committed litellm-rust/Cargo.lock (so crate + # versions are pinned - no floating-dep resolve, and upstream's own test-rust.yml + # builds `--locked`) and the prebuilt Next.js admin UI under + # litellm/proxy/_experimental/out/** (checked into the repo at the tag), so the + # riscv64 bdist needs neither npm/codegen nor git history. Build it once on + # ubuntu-latest (see CLAUDE.md gotcha 4). + python_sdist: + runs-on: ubuntu-latest + outputs: + sdist_artifact_name: ${{ steps.build_sdist.outputs.sdist_artifact_name }} + package_version: ${{ steps.build_sdist.outputs.package_version }} + steps: + - name: Checkout litellm v${{ env.LITELLM_VERSION }} + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: BerriAI/litellm + ref: v${{ env.LITELLM_VERSION }} + persist-credentials: false + + - name: Install Python + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + python-version: '3.12' + activate-environment: true + enable-cache: false + + - name: Set a fixed abi3 floor for the pyo3 extension + run: | + set -euo pipefail + # litellm ships a single abi3 wheel per arch on PyPI, but the abi3 pyo3 + # feature that produces it is NOT in the git tag: the tag has + # `features = ["extension-module"]`, and BerriAI adds the abi3 feature + # only in their release-time packaging (the PyPI sdist's Cargo.toml has + # `abi3-py310`; `git checkout v1.97.0` does not). Since we build the sdist + # from source rather than consuming the prebuilt PyPI sdist, we must set + # the abi3 feature ourselves - otherwise maturin's abi3 probe finds no + # abi3 feature and falls back to per-interpreter wheels (cp312-cp312, + # cp313-cp313, ...), recompiling the Rust world once per Python. + # + # We pin an explicit floor of `abi3-py312` (NOT the bare `abi3` feature): + # * bare `abi3` -> maturin tags Abi3Version::CurrentPython, i.e. a + # DIFFERENT cpNN-abi3 per build interpreter (cp313-abi3, cp314-abi3), + # which is exactly the spray we must not publish; + # * `abi3-py312` -> maturin always tags `cp312-abi3` regardless of the + # build interpreter, so every interpreter in the matrix produces the + # one identical wheel (deduped by find_compatible_wheel). + # 3.12 is RISE's minimum published Python, so a cp312-abi3 floor (loadable + # on 3.12/3.13/3.14) is the right target - we intentionally do NOT mirror + # upstream's 3.10 floor. Idempotent: only rewrites the + # extension-module-only line; a no-op if an abi3 feature is ever committed + # upstream. Bake it in BEFORE building the sdist so maturin captures it. + bridge_manifest="litellm-rust/crates/python-bridge/Cargo.toml" + sed -i -E 's/^(pyo3 = \{ workspace = true, features = \[)("extension-module")(\] \})/\1\2, "abi3-py312"\3/' "${bridge_manifest}" + echo "pyo3 feature line now:" + grep -n 'pyo3 = ' "${bridge_manifest}" + grep -q 'abi3-py312' "${bridge_manifest}" || { echo "::error::failed to set abi3-py312 (upstream line shape changed?)"; exit 1; } + + - name: Build sdist + id: build_sdist + run: | + set -euo pipefail + rm -rf dist + # maturin is the build backend; `python -m build --sdist` invokes it to + # assemble the source tree (Cargo.lock + prebuilt frontend already in the + # checkout, plus the abi3 floor set above, are packaged in). + # twine check validates the metadata. + uv pip install 'maturin==1.9.4' build twine + python -m build --sdist --outdir dist + twine check dist/* + + sdist_name="$(ls dist)" + { + echo "sdist_artifact_name=${sdist_name}" + echo "package_version=$(echo "${sdist_name}" | sed -En 's/litellm-(.+)\.tar\.gz/\1/p')" + } >> "$GITHUB_OUTPUT" + + - name: Upload sdist artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.build_sdist.outputs.sdist_artifact_name }} + path: dist/${{ steps.build_sdist.outputs.sdist_artifact_name }} + if-no-files-found: error + + build_wheels: + needs: [python_sdist] + name: Build litellm ${{ inputs.version || '1.97.0' }} cp312-abi3-manylinux_riscv64 + runs-on: ubuntu-24.04-riscv + timeout-minutes: 90 + steps: + - name: Fetch sdist artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ needs.python_sdist.outputs.sdist_artifact_name }} + path: dist + + - name: Build and test wheel + uses: pypa/cibuildwheel@1828c10ab37f080699c7b81cea34097c684a7074 # v4.2.0 + with: + # cibuildwheel accepts an sdist tarball directly: it extracts it and + # builds from the self-contained tree (Cargo.lock + prebuilt frontend + + # the abi3 floor we set are all bundled). + package-dir: dist/${{ needs.python_sdist.outputs.sdist_artifact_name }} + env: + CIBW_ARCHS: riscv64 + # With the abi3-py312 floor set on the sdist, maturin tags every build + # `cp312-abi3` regardless of the interpreter. Selecting three interpreters + # therefore does NOT rebuild three times: cibuildwheel compiles the Rust + # once (first tag -> cp312-abi3 wheel), then find_compatible_wheel matches + # that wheel for cp313/cp314 ("Skipping build step...") while STILL running + # the test phase on each - so we get ONE cp312-abi3 wheel out but genuine + # load-and-test coverage on 3.12/3.13/3.14. cp314t is excluded (abi3 can't + # target free-threaded, and fastuuid/other riscv64 runtime deps have no + # cp314t wheels). + CIBW_BUILD: "cp312-* cp313-* cp314-*" + # rustup.rs has no riscv64 musl host toolchain (same reason + # build-fastuuid/tiktoken skip it). + CIBW_SKIP: "*-musllinux_*" + # litellm ships no [tool.cibuildwheel], so install the Rust toolchain its + # maturin backend needs in-container and put it on PATH. Upstream builds + # on stable (test-rust.yml); the crate is edition 2024 / rust-version 1.88 + # with no nightly features, so the default stable rustup satisfies it. + CIBW_BEFORE_ALL_LINUX: >- + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + # CIBW_ENVIRONMENT applies to BOTH build and test. Point pip at our + # registry (needed so riscv64-only test deps - fastuuid, tiktoken, hf-xet, + # multidict, frozenlist, pyyaml - resolve; harmless at build time) and add + # cargo to PATH for the maturin build. We deliberately do NOT set + # only-binary here - see CIBW_TEST_ENVIRONMENT (gotcha 12). + CIBW_ENVIRONMENT: >- + PATH="$PATH:$HOME/.cargo/bin" + PIP_EXTRA_INDEX_URL=https://pypi.riseproject.dev/simple/ + # Test phase only: force wheels-only so the full runtime closure (13 deps) + # must resolve as riscv64 wheels instead of silently source-building a + # heavy dep on the runner. Verified the closure resolves to riscv64 wheels + # across public PyPI + our registry for cp312/cp313/cp314. + CIBW_TEST_ENVIRONMENT: >- + PIP_ONLY_BINARY=:all: + # Smoke-test the COMPILED extension, not just `import litellm`. The bridge + # loader (litellm/rust_bridge/loader.py) catches ImportError and returns + # None, so a plain import would pass even with a broken/missing _native. + # Assert the native module is really loaded and call gil_stats() (a + # zero-arg native fn returning {"releases": int}, no network/side effects). + CIBW_TEST_COMMAND: >- + python -c "import litellm; + from litellm.rust_bridge import _native; + from litellm.rust_bridge.loader import native_bridge_available, get_native_bridge; + assert native_bridge_available() is True, 'native bridge unavailable'; + assert get_native_bridge() is _native; + s = _native.gil_stats(); + assert isinstance(s, dict) and 'releases' in s, s; + print('litellm _native OK on', __import__('sys').version.split()[0], '-> gil_stats()=', s)" + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: litellm-${{ env.LITELLM_VERSION }}-cp312-abi3-manylinux_riscv64 + path: ./wheelhouse/*.whl + if-no-files-found: error + + publish: + name: Publish litellm ${{ inputs.version || '1.97.0' }} to GitLab + needs: [python_sdist, build_wheels] + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Publish wheels and open docs PR + uses: riseproject-dev/python-wheels/actions/publish-wheels@main + with: + artifact-pattern: litellm-${{ needs.python_sdist.outputs.package_version }}-*-manylinux_riscv64 + gitlab-username: ${{ vars.GITLAB_DEPLOY_USER }} + gitlab-token: ${{ secrets.GITLAB_DEPLOY_TOKEN }} + gitlab-project-id: ${{ vars.GITLAB_PROJECT_ID }} + gh-token: ${{ secrets.GITHUB_TOKEN }}