feat(packaging): build litellm-proxy gateway wheels (#49605)

* feat(packaging): ship litellm-proxy wheels with the proxy extra

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(packaging): keep litellm-proxy out of the proxy extra until PyPI has it

The current release pipeline publishes litellm with whatever pyproject.toml lists, so pinning an unpublished package would break pip install litellm[proxy]. Images and CI still install the locally built gateway wheel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
yujonglee 2026-11-06 09:37:12 +09:00 • committed by GitHub
parent 6282300598
commit 79710a6ff1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 139 additions and 28 deletions

View file

@ -53,7 +53,7 @@ jobs:
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 30
timeout-minutes: 60
permissions:
contents: read
steps:

View file

@ -21,7 +21,7 @@ jobs:
pull-requests: read
actions: read
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 30
services:
redis:
image: redis@sha256:e2debfb7956fa12c7ddc79d7e645c8cf26b30c99a6e9161ea9bf4171e1668a5f

View file

@ -45,7 +45,7 @@ jobs:
id: rust-bridge-key
if: steps.changes.outputs.decision != 'skip'
env:
RUST_BRIDGE_CACHE_KEY: ${{ runner.os }}-rust-bridge-editable-${{ hashFiles('litellm-rust/Cargo.lock', 'litellm-rust/Cargo.toml', 'litellm-rust/crates/**', 'model_prices_and_context_window.json', 'pyproject.toml') }}
RUST_BRIDGE_CACHE_KEY: ${{ runner.os }}-rust-bridge-editable-${{ hashFiles('litellm-rust/Cargo.lock', 'litellm-rust/Cargo.toml', 'litellm-rust/crates/**', 'model_prices_and_context_window.json', 'pyproject.toml', 'packaging/litellm-proxy/pyproject.toml') }}
run: echo "key=$RUST_BRIDGE_CACHE_KEY" >> "$GITHUB_OUTPUT"
- name: Set up Python
@ -98,6 +98,18 @@ jobs:
mkdir -p rust-bridge-dist/litellm/rust_bridge
cp litellm/rust_bridge/_native.abi3.so rust-bridge-dist/litellm/rust_bridge/_native.abi3.so
- name: Build the gateway wheel once for test shards
if: steps.changes.outputs.decision != 'skip' && steps.rust-bridge-cache.outputs.cache-hit != 'true'
timeout-minutes: 15
working-directory: packaging/litellm-proxy
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_GHA_VERSION: litellm-ci-v1
SCCACHE_GHA_RW_MODE: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'READ_WRITE' || 'READ_ONLY' }}
CARGO_INCREMENTAL: "0"
run: uv run --no-project --with maturin==1.15.0 maturin build --profile dev --strip --out ../../rust-bridge-dist
- name: Upload Rust bridge artifact
if: steps.changes.outputs.decision != 'skip'
timeout-minutes: 10
@ -1024,7 +1036,7 @@ jobs:
if [ -z "$RUST_BRIDGE_ARTIFACT" ]; then
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra extra_proxy --extra proxy-runtime --extra utils
else
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra extra_proxy --extra proxy-runtime --extra utils --no-install-project
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra extra_proxy --extra proxy-runtime --extra utils --no-install-project --no-install-package litellm-proxy
uv pip install --no-deps --python .venv/bin/python rust-bridge-dist/*.whl
cp rust-bridge-dist/litellm/rust_bridge/_native.abi3.so litellm/rust_bridge/_native.abi3.so
uv run --no-sync python -c "import importlib.metadata; import litellm.rust_bridge._native; print(importlib.metadata.version('litellm'))"
@ -1486,7 +1498,7 @@ jobs:
if [ -z "$RUST_BRIDGE_ARTIFACT" ]; then
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra extra_proxy --extra proxy-runtime --extra utils
else
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra extra_proxy --extra proxy-runtime --extra utils --no-install-project
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra extra_proxy --extra proxy-runtime --extra utils --no-install-project --no-install-package litellm-proxy
uv pip install --no-deps --python .venv/bin/python rust-bridge-dist/*.whl
cp rust-bridge-dist/litellm/rust_bridge/_native.abi3.so litellm/rust_bridge/_native.abi3.so
fi

View file

@ -76,6 +76,7 @@ ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
COPY pyproject.toml uv.lock ./
COPY enterprise/pyproject.toml enterprise/
COPY litellm-proxy-extras/pyproject.toml litellm-proxy-extras/
COPY packaging/litellm-proxy/pyproject.toml packaging/litellm-proxy/
# Install third-party dependencies (cached unless pyproject.toml/uv.lock change)
RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \
@ -101,7 +102,8 @@ COPY --from=ui-builder /ui/out/. litellm/proxy/_experimental/out/
RUN sed -i 's/\r$//' docker/build_admin_ui.sh && chmod +x docker/build_admin_ui.sh && ./docker/build_admin_ui.sh
# Install project and workspace packages (fast - deps already cached)
RUN uv sync --frozen --no-default-groups --no-editable \
RUN uv build --package litellm-proxy --wheel --out-dir /wheels
RUN uv sync --frozen --no-install-package litellm-proxy --no-default-groups --no-editable \
--extra proxy \
--extra proxy-runtime \
--group admin-mcp \
@ -111,6 +113,8 @@ RUN uv sync --frozen --no-default-groups --no-editable \
--extra bedrock-realtime \
--python python3.13
RUN uv pip install --python /app/.venv/bin/python --no-deps /wheels/litellm_proxy-*.whl
RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
npm_config_cache=/root/.npm \
prisma generate --schema=./schema.prisma

View file

@ -74,6 +74,7 @@ ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
COPY pyproject.toml uv.lock ./
COPY enterprise/pyproject.toml enterprise/
COPY litellm-proxy-extras/pyproject.toml litellm-proxy-extras/
COPY packaging/litellm-proxy/pyproject.toml packaging/litellm-proxy/
# Install third-party dependencies (cached unless pyproject.toml/uv.lock change)
RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \
@ -99,7 +100,8 @@ COPY --from=ui-builder /ui/out/. litellm/proxy/_experimental/out/
RUN sed -i 's/\r$//' docker/build_admin_ui.sh && chmod +x docker/build_admin_ui.sh && ./docker/build_admin_ui.sh
# Install project and workspace packages (fast - deps already cached)
RUN uv sync --frozen --no-default-groups --no-editable \
RUN uv build --package litellm-proxy --wheel --out-dir /wheels
RUN uv sync --frozen --no-install-package litellm-proxy --no-default-groups --no-editable \
--extra proxy \
--extra proxy-runtime \
--group admin-mcp \
@ -109,6 +111,8 @@ RUN uv sync --frozen --no-default-groups --no-editable \
--extra bedrock-realtime \
--python python3.13
RUN uv pip install --python /app/.venv/bin/python --no-deps /wheels/litellm_proxy-*.whl
RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
npm_config_cache=/root/.npm \
prisma generate --schema=./schema.prisma

View file

@ -41,10 +41,14 @@ COPY pyproject.toml README.md rust-toolchain.toml ./
COPY .cargo ./.cargo
COPY litellm-rust ./litellm-rust
COPY model_prices_and_context_window.json ./
COPY packaging/litellm-proxy/pyproject.toml packaging/litellm-proxy/
RUN --mount=type=cache,target=/root/.cache/uv \
mkdir -p litellm/rust_bridge /wheels && \
touch litellm/__init__.py litellm/rust_bridge/__init__.py && \
uvx --python python3.13 --from maturin==1.15.0 --with puccinialin==0.1.14 \
python -c "import maturin; maturin.build_wheel('/wheels')" && \
cd packaging/litellm-proxy && \
uvx --python python3.13 --from maturin==1.15.0 --with puccinialin==0.1.14 \
python -c "import maturin; maturin.build_wheel('/wheels')"
@ -85,6 +89,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=enterprise/pyproject.toml,target=enterprise/pyproject.toml \
--mount=type=bind,source=litellm-proxy-extras/pyproject.toml,target=litellm-proxy-extras/pyproject.toml \
--mount=type=bind,source=packaging/litellm-proxy/pyproject.toml,target=packaging/litellm-proxy/pyproject.toml \
uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \
--extra proxy \
--extra proxy-runtime \
@ -98,7 +103,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-default-groups --no-editable \
uv sync --frozen --no-install-project --no-install-package litellm-proxy --no-default-groups --no-editable \
--extra proxy \
--extra proxy-runtime \
--group admin-mcp \
@ -108,7 +113,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--python python3.13
RUN --mount=type=bind,from=rust-native,source=/wheels,target=/wheels \
uv pip install --python /app/.venv/bin/python --no-deps /wheels/litellm-*.whl
uv pip install --python /app/.venv/bin/python --no-deps /wheels/litellm-*.whl /wheels/litellm_proxy-*.whl
# PYTHONPATH=/app makes the source tree shadow the installed package, so the
# compiled Rust extension must live next to the source or it is never imported.

View file

@ -0,0 +1,22 @@
[project]
name = "litellm-proxy"
version = "1.109.0"
description = "Opt-in Rust HTTP forwarding server for the LiteLLM Python proxy"
requires-python = ">=3.10, <3.16"
license = "MIT"
[build-system]
requires = ["maturin==1.15.0"]
build-backend = "maturin"
[tool.maturin]
manifest-path = "../../litellm-rust/crates/gateway/Cargo.toml"
bindings = "bin"
targets = [{ name = "litellm-gateway", kind = "bin" }]
profile = "release"
editable-profile = "dev"
include = [
{ path = "rust-toolchain.toml", format = "sdist" },
{ path = ".cargo/config.toml", format = "sdist" },
{ path = "model_prices_and_context_window.json", format = "sdist" },
]

View file

@ -359,6 +359,7 @@ exclude = [
]
[tool.uv]
concurrent-builds = 1
cache-keys = [
{ file = "pyproject.toml" },
{ file = "rust-toolchain.toml" },
@ -395,11 +396,12 @@ brotli = "2026-10-22T14:01:00Z"
pypdf = "2026-10-28T17:01:00Z"
[tool.uv.sources]
litellm-proxy = { workspace = true }
litellm-proxy-extras = { workspace = true }
litellm-enterprise = { workspace = true }
[tool.uv.workspace]
members = ["enterprise", "litellm-proxy-extras"]
members = ["enterprise", "litellm-proxy-extras", "packaging/litellm-proxy"]
[tool.commitizen]
version = "1.109.0"

View file

@ -1,4 +1,4 @@
"""Build the independent core distribution from shared repository sources."""
"""Build standalone core or gateway distributions from shared repository sources."""
import argparse
import shutil
@ -19,8 +19,16 @@ SOURCES: Final = (
"LICENSE",
)
IN_TREE_RUST_PATH: Final = "../../litellm-rust/"
def stage_core_distribution(source: Path, destination: Path) -> None:
class BuildOptions(argparse.Namespace):
package: str
out_dir: Path
sdist_only: bool
def stage_core_distribution(source: Path, destination: Path, *, package: str = "litellm-core") -> None:
version: Final = subprocess.run(
["uv", "version", "--short"], cwd=source, check=True, capture_output=True, text=True
).stdout.strip()
@ -53,8 +61,7 @@ def stage_core_distribution(source: Path, destination: Path) -> None:
)(directory, names)
destination.mkdir(parents=True, exist_ok=True)
for name in SOURCES:
path: Final = source / name
for name, path in ((name, source / name) for name in SOURCES if package == "litellm-core" or name != "litellm"):
if path.is_dir():
shutil.copytree(
path,
@ -63,15 +70,19 @@ def stage_core_distribution(source: Path, destination: Path) -> None:
)
else:
shutil.copy2(path, destination / name)
shutil.copy2(source / "packaging/litellm-core/pyproject.toml", destination / "pyproject.toml")
template: Final = (source / "packaging" / package / "pyproject.toml").read_text()
if package == "litellm-proxy" and IN_TREE_RUST_PATH not in template:
raise SystemExit(f"packaging/{package}/pyproject.toml no longer points at {IN_TREE_RUST_PATH}")
metadata: Final = template.replace(IN_TREE_RUST_PATH, "litellm-rust/")
(destination / "pyproject.toml").write_text(metadata)
subprocess.run(["uv", "version", version, "--frozen"], cwd=destination, check=True)
def build_core_distribution(output: Path, *, sdist_only: bool = False) -> None:
def build_core_distribution(output: Path, *, sdist_only: bool = False, package: str = "litellm-core") -> None:
output_path: Final = output.resolve()
with tempfile.TemporaryDirectory(prefix="litellm-core-") as temporary:
stage: Final = Path(temporary)
stage_core_distribution(ROOT, stage)
stage_core_distribution(ROOT, stage, package=package)
subprocess.run(
["uv", "build", "--python", sys.executable, "--out-dir", str(output_path)]
+ (["--sdist"] if sdist_only else []),
@ -82,12 +93,13 @@ def build_core_distribution(output: Path, *, sdist_only: bool = False) -> None:
def main() -> None:
parser: Final = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--package", choices=("litellm-core", "litellm-proxy"), default="litellm-core")
parser.add_argument("--out-dir", type=Path, default=ROOT / "dist/core")
parser.add_argument(
"--sdist-only", action="store_true", help="Build only the source archive, without compiling a wheel"
)
args: Final = parser.parse_args()
build_core_distribution(Path(args.out_dir), sdist_only=args.sdist_only)
args: Final = parser.parse_args(namespace=BuildOptions())
build_core_distribution(Path(args.out_dir), sdist_only=args.sdist_only, package=args.package)
if __name__ == "__main__":

View file

@ -20,12 +20,21 @@ else:
ROOT: Final = Path(__file__).resolve().parents[2]
@pytest.mark.parametrize(
("package_cli_args", "package"),
[
(("--package", "litellm-proxy"), "litellm-proxy"),
((), "litellm-core"),
],
)
@pytest.mark.parametrize("sdist_only", [False, True])
def test_build_command_selects_requested_distributions(tmp_path: Path, sdist_only: bool) -> None:
def test_build_command_selects_requested_distributions(
tmp_path: Path, sdist_only: bool, package_cli_args: tuple[str, ...], package: str
) -> None:
from scripts.build_core_distribution import main
output: Final = tmp_path / "dist"
arguments: Final = ["build_core_distribution.py", "--out-dir", str(output)] + (
arguments: Final = ["build_core_distribution.py", *package_cli_args, "--out-dir", str(output)] + (
["--sdist-only"] if sdist_only else []
)
with (
@ -42,6 +51,7 @@ def test_build_command_selects_requested_distributions(tmp_path: Path, sdist_onl
assert command[command.index("--out-dir") + 1] == str(output)
assert run.call_args.kwargs["check"] is True
assert run.call_args.kwargs["cwd"] == stage.call_args.args[1]
assert stage.call_args.kwargs["package"] == package
assert not stage.call_args.args[1].exists()
@ -53,7 +63,10 @@ def source_repository(tmp_path: Path) -> Path:
return source
def test_staging_stamps_release_version_without_modifying_sources(tmp_path: Path, source_repository: Path) -> None:
@pytest.mark.parametrize("package", ["litellm-core", "litellm-proxy"])
def test_staging_stamps_release_version_without_modifying_sources(
tmp_path: Path, source_repository: Path, package: str
) -> None:
from scripts.build_core_distribution import SOURCES, stage_core_distribution
source: Final = source_repository
@ -61,16 +74,44 @@ def test_staging_stamps_release_version_without_modifying_sources(tmp_path: Path
path: Final = source / name
path.write_text(f"shared {name}")
(source / "pyproject.toml").write_text('[project]\nname = "litellm"\nversion = "9.8.7rc1"\n')
manifest: Final = source / "packaging/litellm-core/pyproject.toml"
manifest: Final = source / "packaging" / package / "pyproject.toml"
manifest.parent.mkdir(parents=True)
manifest.write_bytes((ROOT / "packaging/litellm-core/pyproject.toml").read_bytes())
manifest.write_bytes((ROOT / "packaging" / package / "pyproject.toml").read_bytes())
original: Final = manifest.read_bytes()
stage: Final = tmp_path / "stage"
stage_core_distribution(source, stage)
assert tomllib.loads((stage / "pyproject.toml").read_text())["project"]["version"] == "9.8.7rc1"
stage_core_distribution(source, stage, package=package)
staged_project: Final = tomllib.loads((stage / "pyproject.toml").read_text())
assert staged_project["project"]["name"] == package
assert staged_project["project"]["version"] == "9.8.7rc1"
if package == "litellm-proxy":
assert staged_project["tool"]["maturin"]["targets"] == [{"name": "litellm-gateway", "kind": "bin"}]
assert not (stage / "litellm").exists()
assert all(
(stage / name).exists()
for name in ("litellm-rust", ".cargo", "rust-toolchain.toml", "model_prices_and_context_window.json")
)
assert manifest.read_bytes() == original
assert (source / "pyproject.toml").read_text() == '[project]\nname = "litellm"\nversion = "9.8.7rc1"\n'
assert all((stage / name).read_bytes() == (source / name).read_bytes() for name in SOURCES)
expected: Final = tuple(name for name in SOURCES if package == "litellm-core" or name != "litellm")
assert all((stage / name).read_bytes() == (source / name).read_bytes() for name in expected)
def test_gateway_staging_refuses_manifest_without_in_tree_rust_path(tmp_path: Path, source_repository: Path) -> None:
from scripts.build_core_distribution import SOURCES, stage_core_distribution
source: Final = source_repository
for name in SOURCES:
(source / name).write_text(f"shared {name}")
(source / "pyproject.toml").write_text('[project]\nname = "litellm"\nversion = "9.8.7"\n')
manifest: Final = source / "packaging" / "litellm-proxy" / "pyproject.toml"
manifest.parent.mkdir(parents=True)
manifest.write_text(
(ROOT / "packaging" / "litellm-proxy" / "pyproject.toml")
.read_text()
.replace("../../litellm-rust/", "../../rust/")
)
with pytest.raises(SystemExit, match="no longer points at"):
stage_core_distribution(source, tmp_path / "stage", package="litellm-proxy")
@pytest.fixture(scope="module")
@ -99,7 +140,9 @@ def test_core_wheel_metadata_and_resources(distributions: tuple[Path, Path]) ->
assert metadata["Version"] == tomllib.loads((ROOT / "pyproject.toml").read_text())["project"]["version"]
assert not metadata.get_all("Provides-Extra")
assert not any(n.endswith(".dist-info/entry_points.txt") for n in names)
requirements: Final = tomllib.loads((ROOT / "packaging/litellm-core/pyproject.toml").read_text())["project"]["dependencies"]
requirements: Final = tomllib.loads((ROOT / "packaging/litellm-core/pyproject.toml").read_text())["project"][
"dependencies"
]
for python_version in ("3.10", "3.11", "3.12", "3.13", "3.14"):
environment: Final = {"python_version": python_version, "python_full_version": python_version + ".0"}
assert {

View file

@ -89,6 +89,7 @@ importlib-metadata: >=6.8.0 # Apache 2.0 License
tokenizers: >=0.20.2 # Apache 2.0 License
jinja2: >=3.1.4 # BSD 3-Clause License
litellm-proxy-extras: >=0.1.1 # MIT License
litellm-proxy: >=1.109.0 # MIT, verified in repository LICENSE for litellm-rust and packaging/litellm-proxy
litellm-enterprise: >=0.1.1 # LiteLLM Enterprise License
a2a-sdk: >=0.3.22 # Apache 2.0 license
anyio: >=4.5.0 # Unknown license

6
uv.lock generated
View file

@ -22,6 +22,7 @@ pypdf = "2026-10-28T17:01:00Z"
members = [
"litellm",
"litellm-enterprise",
"litellm-proxy",
"litellm-proxy-extras",
]
constraints = [
@ -5526,6 +5527,11 @@ name = "litellm-enterprise"
version = "0.1.81"
source = { editable = "enterprise" }
[[package]]
name = "litellm-proxy"
version = "1.109.0"
source = { editable = "packaging/litellm-proxy" }
[[package]]
name = "litellm-proxy-extras"
version = "0.4.112"