diff --git a/.github/workflows/image-scan.yml b/.github/workflows/image-scan.yml index 3e2bca8779d..d769b6ab8af 100644 --- a/.github/workflows/image-scan.yml +++ b/.github/workflows/image-scan.yml @@ -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: diff --git a/.github/workflows/test-linting.yml b/.github/workflows/test-linting.yml index cbeea71ad0f..fd1d2973951 100644 --- a/.github/workflows/test-linting.yml +++ b/.github/workflows/test-linting.yml @@ -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 diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index f1456b1fde8..8ac157c94f5 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 4cef63c8d71..94557bcf68c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docker/Dockerfile.database b/docker/Dockerfile.database index 20f810686a3..2ebffc50b5e 100644 --- a/docker/Dockerfile.database +++ b/docker/Dockerfile.database @@ -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 diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 658bed831ba..1e1c7d73091 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -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. diff --git a/packaging/litellm-proxy/pyproject.toml b/packaging/litellm-proxy/pyproject.toml new file mode 100644 index 00000000000..172f40a7228 --- /dev/null +++ b/packaging/litellm-proxy/pyproject.toml @@ -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" }, +] diff --git a/pyproject.toml b/pyproject.toml index ee6cb6c1dcc..0b81618355d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/scripts/build_core_distribution.py b/scripts/build_core_distribution.py index 41d7208d2c3..c458f6cb44f 100644 --- a/scripts/build_core_distribution.py +++ b/scripts/build_core_distribution.py @@ -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__": diff --git a/tests/base_sdk_tests/test_core_distribution.py b/tests/base_sdk_tests/test_core_distribution.py index c22fa4e54fc..04536630200 100644 --- a/tests/base_sdk_tests/test_core_distribution.py +++ b/tests/base_sdk_tests/test_core_distribution.py @@ -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 { diff --git a/tests/code_coverage_tests/liccheck.ini b/tests/code_coverage_tests/liccheck.ini index cd951c6cd28..d89badfb33e 100644 --- a/tests/code_coverage_tests/liccheck.ini +++ b/tests/code_coverage_tests/liccheck.ini @@ -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 diff --git a/uv.lock b/uv.lock index 954b9f90a75..fb591264fc3 100644 --- a/uv.lock +++ b/uv.lock @@ -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"