From 7447f9babc7432e8e1434d684dff4ce7dc20335f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 1 Aug 2026 15:25:26 -0700 Subject: [PATCH] fix(deps): move pydantic-settings into the base dependencies `import litellm` reaches litellm/integrations/otel/model/config.py via litellm_core_utils/litellm_logging.py, so pydantic-settings is needed at import time. It was declared only in the `proxy` extra, which left a plain `pip install litellm` unimportable on every platform. Adds tests/base_sdk_tests/check_base_sdk_install.py and a base_sdk_install CircleCI job that builds the wheel, installs it into a clean venv with no extras, and smoke-checks the import, a mock completion, a mock embedding, the bundled pricing metadata and the token counter. The check is stdlib-only on purpose; installing pytest into that venv would add packaging, pluggy and iniconfig and could mask the class of undeclared dependency it exists to catch. Previously the Windows job was the only one installing without extras, so this class of break was caught by accident rather than by design. --- .circleci/config.yml | 28 ++++ pyproject.toml | 2 +- .../base_sdk_tests/check_base_sdk_install.py | 123 ++++++++++++++++++ uv.lock | 6 +- 4 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 tests/base_sdk_tests/check_base_sdk_install.py 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" },