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.
This commit is contained in:
Yuneng Jiang 2026-08-01 15:25:26 -07:00
parent b1fd20f4cd
commit 7447f9babc
No known key found for this signature in database
4 changed files with 155 additions and 4 deletions

View file

@ -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:

View file

@ -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

View file

@ -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())

6
uv.lock generated
View file

@ -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" },