diff --git a/.circleci/config.yml b/.circleci/config.yml index 2f01b6de4f3..c3a34a97b3b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -281,6 +281,32 @@ jobs: uv build --wheel --out-dir dist uv run --no-sync python tests/windows_tests/check_windows_wheel_install.py + base_sdk_install: + docker: + - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c + auth: + username: ${DOCKERHUB_USERNAME} + password: ${DOCKERHUB_PASSWORD} + working_directory: ~/project + steps: + - checkout + - setup_google_dns + - install_uv + - run: + name: Build the wheel + environment: + UV_HTTP_TIMEOUT: "300" + command: | + uv build --wheel --out-dir dist + - run: + name: Install the wheel with no extras and smoke-check it + environment: + UV_HTTP_TIMEOUT: "300" + command: | + uv venv /tmp/base-sdk --python 3.12 + VIRTUAL_ENV=/tmp/base-sdk uv pip install dist/*.whl + /tmp/base-sdk/bin/python tests/base_sdk_tests/check_base_sdk_install.py + local_testing_part1: docker: - &python312_image @@ -3031,6 +3057,8 @@ workflows: only: - main - /litellm_.*/ + - base_sdk_install: + filters: *main_branches - local_testing_part1: filters: *main_branches - local_testing_part2: diff --git a/pyproject.toml b/pyproject.toml index 57394acf8d1..addf56a3588 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "jinja2>=3.1.6,<4.0", "aiohttp>=3.14.2,<4.0", "pydantic>=2.10.0,<3.0.0", + "pydantic-settings>=2.14.1,<3.0", "jsonschema>=4.0.0,<5.0", ] @@ -70,7 +71,6 @@ proxy = [ "polars>=1.38.1,<2.0", "soundfile>=0.12.1,<1.0", "pyroscope-io>=0.8.16,<1.0; sys_platform != 'win32'", - "pydantic-settings>=2.14.1,<3.0", "expression>=5.6.0,<6.0", ] # Thin client install for the `lite` CLI on developer laptops. The CLI's heavy diff --git a/tests/base_sdk_tests/check_base_sdk_install.py b/tests/base_sdk_tests/check_base_sdk_install.py new file mode 100644 index 00000000000..f3a4f2c0454 --- /dev/null +++ b/tests/base_sdk_tests/check_base_sdk_install.py @@ -0,0 +1,123 @@ +"""Smoke-check that a base ``pip install litellm`` (no extras) is importable and usable. + +Run against a virtualenv that has the built wheel installed with no extras, using +that venv's own interpreter and nothing else. Deliberately stdlib-only: pytest would +pull ``packaging``, ``pluggy`` and ``iniconfig`` into the environment and could mask +the very class of undeclared-dependency bug this guards against. +""" + +import importlib.util +import sys +import traceback +from collections.abc import Callable + +EXTRAS_ONLY_MODULES = ("fastapi", "boto3", "uvicorn") + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def check_environment_is_base_only() -> str: + present = tuple(name for name in EXTRAS_ONLY_MODULES if importlib.util.find_spec(name) is not None) + _require( + not present, + f"{', '.join(present)} installed, so this environment is not base-only and the run proves nothing", + ) + return f"no extras-only packages present ({', '.join(EXTRAS_ONLY_MODULES)})" + + +def check_import() -> str: + from importlib.metadata import version + + import litellm + + _require(bool(litellm.__file__), "litellm has no __file__") + return f"imported litellm {version('litellm')}" + + +def check_completion() -> str: + import litellm + + response = litellm.completion( + model="gpt-4o", + messages=[{"role": "user", "content": "ping"}], + mock_response="pong", + ) + content = response.choices[0].message.content + _require(content == "pong", f"mock completion returned {content!r}") + return "mock completion round-trips" + + +def check_embedding() -> str: + import litellm + + response = litellm.embedding( + model="text-embedding-3-small", + input=["ping"], + mock_response=[[0.1, 0.2]], + ) + _require(len(response.data) == 1, f"mock embedding returned {len(response.data)} rows") + return "mock embedding round-trips" + + +def check_bundled_model_metadata() -> str: + import litellm + + max_input_tokens = litellm.get_model_info("gpt-4o")["max_input_tokens"] + _require( + isinstance(max_input_tokens, int) and max_input_tokens > 0, + f"get_model_info returned max_input_tokens={max_input_tokens!r}", + ) + prompt_cost, completion_cost = litellm.cost_per_token(model="gpt-4o", prompt_tokens=1000, completion_tokens=1000) + _require( + prompt_cost > 0 and completion_cost > 0, + f"cost_per_token returned ({prompt_cost}, {completion_cost})", + ) + return f"bundled pricing metadata readable (gpt-4o max_input_tokens={max_input_tokens})" + + +def check_token_counter() -> str: + import litellm + + count = litellm.token_counter(model="gpt-4o", text="hello world") + _require(count > 0, f"token_counter returned {count!r}") + return f"token_counter returned {count}" + + +CHECKS: tuple[tuple[str, Callable[[], str]], ...] = ( + ("environment is base-only", check_environment_is_base_only), + ("import litellm", check_import), + ("chat completion", check_completion), + ("embedding", check_embedding), + ("bundled model metadata", check_bundled_model_metadata), + ("token counter", check_token_counter), +) + + +def _run(check: Callable[[], str]) -> tuple[bool, str]: + try: + return True, check() + except Exception: + return False, traceback.format_exc() + + +def main() -> int: + print(f"base SDK smoke check on {sys.executable}") + for label, check in CHECKS: + passed, detail = _run(check) + if not passed: + print(f"FAIL {label}:\n{detail}") + print(f"A base `pip install litellm` is broken at: {label}") + print("Something needed at import or call time is missing from [project].dependencies") + print("in pyproject.toml. Declaring it only in an extra is what causes this.") + return 1 + print(f"PASS {label}: {detail}") + + print(f"\nall {len(CHECKS)} checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index cb9ba896cd7..0bfe9208872 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-07-29T19:04:35.546526Z" +exclude-newer = "2026-07-29T22:09:54.255381Z" exclude-newer-span = "P3D" [manifest] @@ -4128,6 +4128,7 @@ dependencies = [ { name = "jsonschema" }, { name = "openai" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "python-dotenv" }, { name = "tiktoken" }, { name = "tokenizers" }, @@ -4183,7 +4184,6 @@ proxy = [ { name = "mcp" }, { name = "orjson" }, { name = "polars" }, - { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "pynacl" }, { name = "pyroscope-io", marker = "sys_platform != 'win32'" }, @@ -4385,7 +4385,7 @@ requires-dist = [ { name = "prisma", marker = "extra == 'extra-proxy'", specifier = ">=0.11.0,<1.0" }, { name = "prometheus-client", marker = "extra == 'proxy-runtime'", specifier = ">=0.20.0,<1.0" }, { name = "pydantic", specifier = ">=2.10.0,<3.0.0" }, - { name = "pydantic-settings", marker = "extra == 'proxy'", specifier = ">=2.14.1,<3.0" }, + { name = "pydantic-settings", specifier = ">=2.14.1,<3.0" }, { name = "pyjwt", marker = "extra == 'proxy'", specifier = ">=2.13.0,<3.0" }, { name = "pynacl", marker = "extra == 'proxy'", specifier = ">=1.6.2,<2.0" }, { name = "pypdf", marker = "extra == 'proxy-runtime'", specifier = ">=6.12.0,<7.0" },