Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions .github/workflows/build-litellm.yml
Original file line number Diff line number Diff line change
@@ -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-<ver>-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
Comment thread
justeph marked this conversation as resolved.
# 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 }}
Loading