Merge remote-tracking branch 'upstream/litellm_internal_staging' into deepkeep-as-internal

This commit is contained in:
Yaniv Israel 2026-06-17 21:11:40 +03:00
commit cb6ee50951
42 changed files with 1866 additions and 7797 deletions

View file

@ -87,14 +87,9 @@ jobs:
run: |
uv run --no-sync python -c "import openai; print(f'OpenAI version: {openai.__version__}')"
- name: Run MyPy type checking
run: |
cd litellm
(uv run --no-sync mypy . || true) | uv run --no-sync python ../scripts/type_check_gate.py --tool mypy
- name: Run basedpyright type checking
run: |
(uv run --no-sync basedpyright --outputjson || true) | uv run --no-sync python scripts/type_check_gate.py --tool basedpyright
(uv run --no-sync basedpyright --outputjson || true) | uv run --no-sync python scripts/type_check_gate.py
- name: Check for circular imports
run: |
@ -133,56 +128,6 @@ jobs:
run: |
python scripts/budget_ratchet_check.py --base "$BASE_SHA"
any-discipline:
# Separate job: the first run cold-builds litellm's type cache (~2 min, ~3 GB),
# so keep it off the main lint job's time budget. Subsequent runs reuse the
# cached .mypy_cache_any and only re-type-check the changed files.
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
# Check out the PR head, not the default refs/pull/N/merge: the merge ref
# folds in newer base commits, which the diff-based gates (ruff delta,
# Any-discipline) would otherwise blame on this branch.
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
clean: true
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
version: "0.10.9"
- name: Install dependencies
run: |
uv sync --frozen
# Keyed on deps + mypy config (which fix the type cache's validity), not on
# source content, so changed files always differ from the restored cache.
# The gate also defensively invalidates each target's cache entry, so
# correctness never depends on cache freshness -- this is purely for speed.
- name: Restore Any-gate type cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: .mypy_cache_any
key: any-mypy-cache-${{ runner.os }}-py3.12-${{ hashFiles('uv.lock', 'litellm/mypy.ini') }}
restore-keys: |
any-mypy-cache-${{ runner.os }}-py3.12-
- name: Check Any discipline (per-file budget on changed files)
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
uv run --no-sync python scripts/check_any_discipline.py --changed --base "$BASE_SHA"
secret-scan:
runs-on: ubuntu-latest
timeout-minutes: 5

2
.gitignore vendored
View file

@ -76,8 +76,6 @@ tests/local_testing/log.txt
.codegpt
litellm/proxy/_new_new_secret_config.yaml
litellm/proxy/custom_guardrail.py
**/.mypy_cache/
**/.mypy_cache_any/
litellm/proxy/application.log
tests/llm_translation/vertex_test_account.json
tests/llm_translation/test_vertex_key.json

View file

@ -36,11 +36,9 @@ Don't hesitate to use values in .env to get needed API keys and other secrets, a
Run tests, format your code, and lint your code before each commit
When you fix violations gated by `ruff-strict-budget.json`, `mypy-code-budget.json`, `basedpyright-code-budget.json`, or `any-discipline-budget.json`, run `make lint-budget-update` and commit the lowered baselines so the ceilings ratchet down instead of leaving stale headroom
When you fix violations gated by `ruff-strict-budget.json` or `basedpyright-code-budget.json`, run `make lint-budget-update` and commit the lowered baselines so the ceilings ratchet down instead of leaving stale headroom
If you're trying to create a new function that relies on untyped stuff, instead of adding more Any's and bringing it closer to the max, just validate it in the caller with Pydantic (a model or `TypeAdapter` that returns the typed thing or raises will do) and then pass the now typed variable in
The Any-discipline gate (`make lint-any`, also a CI job) fails when a changed file under `litellm/` carries more `Any`-typed values than its grandfathered ceiling in `any-discipline-budget.json` (each file's captured count plus 50% headroom). It flags values whose inferred type *contains* `Any`, including the `X | Any` unions mypy/basedpyright accept. Editing a legacy file is fine as long as you don't push its `Any` count past the ceiling; a brand-new file must be `Any`-free. Fix a value by giving it a concrete type (if you're given untyped input, validate with Pydantic). Ideally `# any-ok: <reason>` is never used; treat it as a last resort for a genuine typed/untyped boundary that Pydantic truly can't model
If you're trying to create a new function that relies on untyped stuff, instead of adding more Any's and pushing `reportAny` / `reportExplicitAny` closer to their basedpyright ceilings, just validate it in the caller with Pydantic (a model or `TypeAdapter` that returns the typed thing or raises will do) and then pass the now typed variable in
If you get an LIT001 or LIT002 fail, refactor the code to follow functional programming best practices rather than introducing mutable data structures. For example, build values in one shot with comprehensions or generators wrapped in `tuple()` / `frozenset()` instead of seeding an empty `list`/`dict`/`set` and mutating it over time. Ideally `# mutable-ok` is never used; reach for it only as a genuine last resort when an immutable rewrite is truly impossible, and always pair it with a real reason

View file

@ -154,8 +154,7 @@ Individual linting commands:
```bash
make format-check # Check Black formatting
make lint-ruff # Run Ruff linting
make lint-mypy # Run MyPy type checking
make lint-any # Gate changed files against their per-file Any budget
make lint-basedpyright # Run basedpyright type checking
make check-circular-imports # Check for circular imports
make check-import-safety # Check import safety
```
@ -217,7 +216,7 @@ LiteLLM follows the [Google Python Style Guide](https://google.github.io/stylegu
Our automated quality checks include:
- **Black** for consistent code formatting
- **Ruff** for linting and code quality
- **MyPy** for static type checking
- **basedpyright** for static type checking
- **Circular import detection**
- **Import safety validation**
@ -231,7 +230,7 @@ If `make lint` fails:
1. **Formatting issues**: Run `make format` to auto-fix
2. **Ruff issues**: Check the output and fix manually
3. **MyPy issues**: Add proper type hints
3. **basedpyright issues**: Add proper type hints
4. **Circular imports**: Refactor import dependencies
5. **Import safety**: Fix any unprotected imports
@ -246,7 +245,7 @@ If `make test-unit` fails:
### 3. Common Development Tips
- **Use type hints**: MyPy requires proper type annotations
- **Use type hints**: basedpyright requires proper type annotations
- **Write descriptive commit messages**: Help reviewers understand your changes
- **Keep PRs focused**: One feature/fix per PR
- **Test edge cases**: Don't just test the happy path

View file

@ -5,8 +5,8 @@
test-unit-integrations test-unit-core-utils test-unit-other test-unit-root \
test-proxy-unit-a test-proxy-unit-b test-integration test-unit-helm \
info lint lint-dev format \
lint-mypy lint-mypy-budget-update lint-basedpyright lint-basedpyright-budget-update \
lint-ruff-budget lint-any lint-ruff-budget-update lint-budget-update lint-any-budget-update \
lint-basedpyright lint-basedpyright-budget-update \
lint-ruff-budget lint-ruff-budget-update lint-budget-update \
install-dev install-proxy-dev install-test-deps install-hooks \
install-helm-unittest check-circular-imports check-import-safety
@ -22,18 +22,14 @@ help:
@echo " make install-hooks - Install git hooks (Conventional Commits + Branches)"
@echo " make format - Apply Black code formatting"
@echo " make format-check - Check Black code formatting (matches CI)"
@echo " make lint - Run all linting (Ruff, MyPy, Black check, circular imports, import safety)"
@echo " make lint - Run all linting (Ruff, basedpyright, Black check, circular imports, import safety)"
@echo " make lint-ruff - Run Ruff linting only"
@echo " make lint-mypy - Run MyPy (disallow_untyped_defs), gated by per-rule error counts"
@echo " make lint-mypy-budget-update - Re-capture the MyPy per-rule budget (ratchet)"
@echo " make lint-basedpyright - Run basedpyright strict, gated by per-rule error counts"
@echo " make lint-basedpyright-budget-update - Re-capture the basedpyright per-rule budget (ratchet)"
@echo " make lint-black - Check Black formatting (matches CI)"
@echo " make lint-ruff-budget - Gate the codebase total of each strict ruff rule against its ceiling"
@echo " make lint-any - Gate changed files under litellm/ against their per-file Any budget"
@echo " make lint-ruff-budget-update - Re-capture per-rule baselines in ruff-strict-budget.json (ratchet)"
@echo " make lint-budget-update - Re-capture all four ratchet budgets (ruff + mypy + basedpyright + any)"
@echo " make lint-any-budget-update - Re-capture the per-file Any budget across the whole tree (ratchet)"
@echo " make lint-budget-update - Re-capture all ratchet budgets (ruff + basedpyright)"
@echo " make check-circular-imports - Check for circular imports"
@echo " make check-import-safety - Check import safety"
@echo " make test - Run all tests"
@ -127,17 +123,11 @@ lint-ruff-FULL-dev: install-dev
if [ -n "$$files" ]; then echo "$$files" | xargs $(UV_RUN) ruff check; \
else echo "No changed .py files to check."; fi
lint-mypy: install-dev
cd litellm && ($(UV_RUN) mypy . || true) | $(UV_RUN) python ../scripts/type_check_gate.py --tool mypy
lint-mypy-budget-update: install-dev
cd litellm && ($(UV_RUN) mypy . || true) | $(UV_RUN) python ../scripts/type_check_gate.py --tool mypy --update
lint-basedpyright: install-dev
($(UV_RUN) basedpyright --outputjson || true) | $(UV_RUN) python scripts/type_check_gate.py --tool basedpyright
($(UV_RUN) basedpyright --outputjson || true) | $(UV_RUN) python scripts/type_check_gate.py
lint-basedpyright-budget-update: install-dev
($(UV_RUN) basedpyright --outputjson || true) | $(UV_RUN) python scripts/type_check_gate.py --tool basedpyright --update
($(UV_RUN) basedpyright --outputjson || true) | $(UV_RUN) python scripts/type_check_gate.py --update
lint-black: format-check
@ -150,14 +140,8 @@ lint-ruff-budget: install-dev
lint-ruff-budget-update: install-dev
$(UV_RUN) python scripts/ruff_strict_gate.py --update
# Ratchet all four budgets in one shot (ruff strict + mypy + basedpyright + any)
lint-budget-update: lint-ruff-budget-update lint-mypy-budget-update lint-basedpyright-budget-update lint-any-budget-update
lint-any: install-dev
$(UV_RUN) python scripts/check_any_discipline.py --changed
lint-any-budget-update: install-dev
$(UV_RUN) python scripts/check_any_discipline.py --update
# Ratchet all budgets in one shot (ruff strict + basedpyright)
lint-budget-update: lint-ruff-budget-update lint-basedpyright-budget-update
check-circular-imports: install-dev
cd litellm && $(UV_RUN) python ../tests/documentation_tests/test_circular_imports.py && cd ..
@ -166,10 +150,10 @@ check-import-safety: install-dev
@$(UV_RUN) python -c "from litellm import *; print('[from litellm import *] OK! no issues!');" || (echo '🚨 import failed, this means you introduced unprotected imports! 🚨'; exit 1)
# Combined linting (matches test-linting.yml workflow)
lint: format-check lint-ruff lint-mypy lint-basedpyright check-circular-imports check-import-safety lint-ruff-budget lint-any
lint: format-check lint-ruff lint-basedpyright check-circular-imports check-import-safety lint-ruff-budget
# Faster linting for local development (only checks changed code)
lint-dev: lint-format-changed lint-mypy lint-any check-circular-imports check-import-safety
lint-dev: lint-format-changed check-circular-imports check-import-safety
# Testing targets
test: install-test-deps

File diff suppressed because it is too large Load diff

View file

@ -5455,21 +5455,19 @@ class StandardLoggingPayloadSetup:
error_information = StandardLoggingPayloadSetup.get_error_information(
original_exception=original_exception,
)
if not metadata.get("client_disconnected"): # any-ok: untyped metadata
if not metadata.get("client_disconnected"):
return error_information, error_str
client_disconnect_error = metadata.get( # any-ok: untyped metadata
"error_information"
)
if isinstance(client_disconnect_error, dict): # any-ok: untyped metadata
client_disconnect_error = metadata.get("error_information")
if isinstance(client_disconnect_error, dict):
error_information = cast(
StandardLoggingPayloadErrorInformation,
client_disconnect_error, # any-ok: untyped metadata
client_disconnect_error,
)
else:
error_information = cast(
StandardLoggingPayloadErrorInformation,
{ # any-ok: untyped metadata
{
"error_code": "499",
"error_message": "Client disconnected the request",
"error_class": "ClientDisconnected",
@ -5808,7 +5806,7 @@ def get_standard_logging_object_payload(
error_information, error_str = (
StandardLoggingPayloadSetup.get_error_information_for_logging_payload(
metadata=metadata, # any-ok: untyped metadata
metadata=metadata,
original_exception=original_exception,
error_str=error_str,
)

View file

@ -2203,7 +2203,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
inference_geo = _usage["inference_geo"]
service_tier = cast(
str | None,
_usage.get("service_tier"), # any-ok: untyped usage dict
_usage.get("service_tier"),
)
iterations: list[Any] | None = _usage.get("iterations")

View file

@ -205,53 +205,40 @@ class HostedVLLMChatConfig(OpenAIGPTConfig):
tool_calls: list[ChatCompletionAssistantToolCall] = []
content_blocks: list[object] = []
has_structured_content = False
for c in existing_content: # any-ok: untyped content
if (
isinstance(c, dict) # any-ok: untyped content
and c.get("type") == "text" # any-ok: untyped content
):
text_parts.append( # any-ok: untyped content
c.get("text", "") # any-ok: untyped content
)
content_blocks.append(c) # any-ok: untyped content
elif (
isinstance(c, dict) # any-ok: untyped content
and c.get("type") == "tool_use" # any-ok: untyped content
):
tool_input = c.get("input", {}) # any-ok: untyped content
for c in existing_content:
if isinstance(c, dict) and c.get("type") == "text":
text_parts.append(c.get("text", ""))
content_blocks.append(c)
elif isinstance(c, dict) and c.get("type") == "tool_use":
tool_input = c.get("input", {})
tool_calls.append(
ChatCompletionAssistantToolCall(
id=c.get("id"), # any-ok: untyped content
id=c.get("id"),
type="function",
function=ChatCompletionToolCallFunctionChunk(
name=c.get("name"), # any-ok: untyped content
name=c.get("name"),
arguments=(
tool_input
if isinstance(
tool_input, # any-ok: untyped content
str, # any-ok: untyped content
)
else json.dumps(
tool_input # any-ok: untyped content
tool_input,
str,
)
else json.dumps(tool_input)
),
),
)
)
else:
content_blocks.append(c) # any-ok: untyped content
content_blocks.append(c)
has_structured_content = True
if tool_calls:
existing_tool_calls = message.get("tool_calls")
if isinstance(existing_tool_calls, list):
existing_tool_call_ids = {
tool_call.get("id") # any-ok: untyped content
tool_call.get("id")
for tool_call in existing_tool_calls
if isinstance(
tool_call, dict
) # any-ok: untyped content
and tool_call.get("id")
is not None # any-ok: untyped content
if isinstance(tool_call, dict)
and tool_call.get("id") is not None
}
new_tool_calls = [
tool_call
@ -264,7 +251,7 @@ class HostedVLLMChatConfig(OpenAIGPTConfig):
)
else:
message["tool_calls"] = tool_calls
content_str = "\n".join(text_parts) # any-ok: untyped content
content_str = "\n".join(text_parts)
new_content = (
content_blocks if has_structured_content else content_str
)

View file

@ -1,22 +0,0 @@
[mypy]
warn_return_any = True
ignore_missing_imports = True
disallow_untyped_defs = True
mypy_path = litellm/stubs
namespace_packages = True
disable_error_code =
annotation-unchecked,
import-untyped
[mypy-litellm.*]
ignore_missing_imports = False
[mypy-google.*]
ignore_missing_imports = True
[mypy-cryptography.hazmat.bindings._rust.x509]
ignore_errors = True
[mypy-fastuuid.*]
ignore_missing_imports = True
ignore_errors = True

View file

@ -110,46 +110,32 @@ async def _record_streaming_client_disconnect_if_needed(
if not disconnected:
return False
logging_obj = request_data.get("litellm_logging_obj") # any-ok: untyped request
if logging_obj is not None: # any-ok: untyped request
litellm_params = (
logging_obj.model_call_details.setdefault( # any-ok: untyped request
"litellm_params", {}
)
)
logging_obj = request_data.get("litellm_logging_obj")
if logging_obj is not None:
litellm_params = logging_obj.model_call_details.setdefault("litellm_params", {})
_apply_client_disconnect_metadata(litellm_params.setdefault("metadata", {}))
_apply_client_disconnect_metadata(
litellm_params.setdefault("metadata", {}) # any-ok: untyped request
)
_apply_client_disconnect_metadata(
logging_obj.model_call_details.setdefault( # any-ok: untyped request
"metadata", {}
)
logging_obj.model_call_details.setdefault("metadata", {})
)
_apply_client_disconnect_metadata(
request_data.setdefault("metadata", {}) # any-ok: untyped request
)
litellm_params = request_data.setdefault( # any-ok: untyped request
"litellm_params", {} # any-ok: untyped request
)
_apply_client_disconnect_metadata(
litellm_params.setdefault("metadata", {}) # any-ok: untyped request
)
_apply_client_disconnect_metadata(request_data.setdefault("metadata", {}))
litellm_params = request_data.setdefault("litellm_params", {})
_apply_client_disconnect_metadata(litellm_params.setdefault("metadata", {}))
verbose_proxy_logger.debug(
"Recorded streaming client disconnect with error_code=499 for litellm_call_id=%s",
request_data.get("litellm_call_id"), # any-ok: untyped request
request_data.get("litellm_call_id"),
)
return True
async def _cancel_pending_gather_tasks(tasks: list["asyncio.Task[Any]"]) -> None:
pending_tasks = [task for task in tasks if not task.done()] # any-ok: untyped task
for task in pending_tasks: # any-ok: untyped task
task.cancel() # any-ok: untyped task
for task in pending_tasks: # any-ok: untyped task
pending_tasks = [task for task in tasks if not task.done()]
for task in pending_tasks:
task.cancel()
for task in pending_tasks:
try:
await task # any-ok: untyped request
await task
except (asyncio.CancelledError, Exception): # noqa: BLE001
pass
@ -1401,24 +1387,22 @@ class ProxyBaseLLMRequestProcessing:
user_model=user_model,
user_api_key_dict=user_api_key_dict,
)
llm_call_task = asyncio.create_task(llm_call) # any-ok: untyped task
tasks.append(llm_call_task) # any-ok: untyped task
llm_call_task = asyncio.create_task(llm_call)
tasks.append(llm_call_task)
llm_responses = asyncio.gather(
*tasks
) # run the moderation check in parallel to the actual llm api call
try:
if general_settings.get( # any-ok: untyped request
"cancel_on_disconnect", False
):
responses = await _await_llm_call_cancelling_on_disconnect( # any-ok: untyped request
request, llm_responses # any-ok: untyped task
if general_settings.get("cancel_on_disconnect", False):
responses = await _await_llm_call_cancelling_on_disconnect(
request, llm_responses
)
else:
responses = await llm_responses # any-ok: untyped request
responses = await llm_responses
finally:
await _cancel_pending_gather_tasks(tasks) # any-ok: untyped task
await _cancel_pending_gather_tasks(tasks)
response = responses[1]
@ -2477,18 +2461,16 @@ class ProxyBaseLLMRequestProcessing:
recorded_client_disconnect = (
await _record_streaming_client_disconnect_if_needed(
request,
request_data, # any-ok: untyped request
client_disconnected, # any-ok: untyped request
request_data,
client_disconnected,
)
)
if recorded_client_disconnect:
ProxyLogging._fire_deferred_stream_logging(
request_data # any-ok: untyped request
)
ProxyLogging._fire_deferred_stream_logging(request_data)
if hasattr(response, "aclose"): # any-ok: untyped request
if hasattr(response, "aclose"):
try:
await response.aclose() # any-ok: untyped request
await response.aclose()
except BaseException as e: # noqa: BLE001
verbose_proxy_logger.debug(
"async_streaming_data_generator: error closing response stream: %s",
@ -2624,8 +2606,8 @@ class ProxyBaseLLMRequestProcessing:
finally:
await ProxyBaseLLMRequestProcessing._finalize_streaming_generator_cleanup(
request=request,
request_data=request_data, # any-ok: untyped request
response=response, # any-ok: untyped request
request_data=request_data,
response=response,
stream_completed=stream_completed,
client_disconnected=client_disconnected,
)

View file

@ -0,0 +1,167 @@
"""Team-scoped (BYOK) model-name translation for the model listing endpoints.
`/v1/models`, `/models`, and `GET /v1/models/{id}` should surface the public
`team_public_model_name` rather than the internal routing key
`model_name_{team_id}_{uuid}`, consistent with `/v1/model/info`. The internal
key still routes regardless; this is a presentation-layer swap only and does not
touch access-group or auth semantics (see issue #28382). Operators can pin the
legacy internal names with `general_settings.use_team_public_model_name: false`.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from litellm.router import Router
class TeamModelNameTranslator:
"""Translates internal team routing keys to their public names for the model
listing/retrieve responses. Stateless; the live router and general_settings
are injected per call so the unit tests can drive it without globals.
"""
@staticmethod
def _internal_public_pair(model: object) -> tuple[str, str] | None:
"""`(internal_routing_key, public_name)` for a team-scoped row, else None."""
if not isinstance(model, dict):
return None
model_dict = cast(dict[str, object], model) # any-ok: checked
model_info_raw: object = model_dict.get("model_info")
if not isinstance(model_info_raw, Mapping):
return None
model_info = cast(Mapping[str, object], model_info_raw) # any-ok: checked
team_id = model_info.get("team_id")
team_public = model_info.get("team_public_model_name")
name = model_dict.get("model_name")
if (
isinstance(team_id, str)
and isinstance(team_public, str)
and isinstance(name, str)
and team_id
and team_public
and name.startswith(f"model_name_{team_id}_")
):
return name, team_public
return None
@staticmethod
def _is_enabled(general_settings: Mapping[str, object]) -> bool:
return general_settings.get("use_team_public_model_name", True) is not False
@staticmethod
def build_internal_to_public_map(
llm_router: "Router | None",
general_settings: Mapping[str, object],
) -> dict[str, str]:
"""Internal team routing key -> public `team_public_model_name`.
Empty when disabled via the legacy flag, the router is absent, or the
router model list is malformed.
"""
if llm_router is None or not TeamModelNameTranslator._is_enabled(
general_settings
):
return {}
router_model_list = llm_router.get_model_list()
if not isinstance(router_model_list, list):
return {}
return dict(
pair
for pair in (
TeamModelNameTranslator._internal_public_pair(model)
for model in router_model_list
)
if pair is not None
)
@staticmethod
def _response_to_lookup_map(
model_names: list[str],
internal_to_public: dict[str, str],
) -> dict[str, str]:
"""Map each public response id to the first internal lookup id seen in
`model_names`, preserving first-occurrence order. First-wins keeps list
and retrieve in agreement on which accessible deployment a shared public
id resolves to: a global iterated before a colliding team alias stays
the listed entry, and sibling team rows collapse to their first
occurrence.
"""
result: dict[str, str] = {}
for name in model_names:
result.setdefault(internal_to_public.get(name, name), name)
return result
@staticmethod
def listing_entries(
model_names: list[str],
llm_router: "Router | None",
general_settings: Mapping[str, object],
) -> list[tuple[str, str]]:
"""`(response_id, metadata_lookup_id)` for each listed model, de-duplicated
by response_id while preserving order.
For team-scoped rows `response_id` is the public name shown to the client,
while `metadata_lookup_id` stays the internal routing key so downstream
metadata/fallback lookups (keyed by the routing name) still resolve. The
lookup id is always one of `model_names` (the caller's accessible set), so
a public name shared across teams never resolves to another team's
internal key. Both ids are identical for unmapped names (globals,
access-group keys).
"""
internal_to_public = TeamModelNameTranslator.build_internal_to_public_map(
llm_router, general_settings
)
if not internal_to_public:
return [(name, name) for name in model_names]
return list(
TeamModelNameTranslator._response_to_lookup_map(
model_names, internal_to_public
).items()
)
@staticmethod
def translate_listing(
model_names: list[str],
llm_router: "Router | None",
general_settings: Mapping[str, object],
) -> list[str]:
"""Public-name view of `model_names` (the `response_id` of each listing
entry). Sibling deployments sharing a public name collapse to one entry
while preserving order; unmapped names pass through.
"""
return [
entry[0]
for entry in TeamModelNameTranslator.listing_entries(
model_names, llm_router, general_settings
)
]
@staticmethod
def resolve_public_name(
model_id: str,
available_models: list[str],
llm_router: "Router | None",
general_settings: Mapping[str, object],
) -> str:
"""Resolve a public team name back to the internal routing key the router
indexes by, so `GET /v1/models/{id}` accepts the name the listing returns.
Resolution is restricted to `available_models` (the caller's accessible
set) so colliding public names across teams never resolve across an access
boundary. Uses the same first-occurrence dedup as `listing_entries` so a
public id advertised by `/v1/models` resolves to the same internal
deployment that the listing's metadata was built from. Returns `model_id`
unchanged when it is not an accessible public team name (already-internal
names and globals pass through).
"""
internal_to_public = TeamModelNameTranslator.build_internal_to_public_map(
llm_router, general_settings
)
if not internal_to_public:
return model_id
return TeamModelNameTranslator._response_to_lookup_map(
available_models, internal_to_public
).get(model_id, model_id)

View file

@ -25,15 +25,9 @@ async def get_ui_config():
or general_settings.get("auto_redirect_ui_login_to_sso", False) is True
)
admin_ui_disabled = os.getenv("DISABLE_ADMIN_UI", "false").lower() == "true"
hide_default_credentials_hint = bool( # any-ok: untyped settings
os.getenv( # any-ok: untyped settings
"LITELLM_HIDE_DEFAULT_CREDENTIALS_HINT", "false"
).lower()
== "true"
or general_settings.get( # any-ok: untyped settings
"hide_default_credentials_hint", False
)
is True
hide_default_credentials_hint = bool(
os.getenv("LITELLM_HIDE_DEFAULT_CREDENTIALS_HINT", "false").lower() == "true"
or general_settings.get("hide_default_credentials_hint", False) is True
)
sso_configured = _has_user_setup_sso()
@ -48,7 +42,7 @@ async def get_ui_config():
auto_redirect_to_sso=sso_configured and auto_redirect_ui_login_to_sso,
admin_ui_disabled=admin_ui_disabled,
sso_configured=sso_configured,
hide_default_credentials_hint=hide_default_credentials_hint, # any-ok: untyped settings
hide_default_credentials_hint=hide_default_credentials_hint,
is_control_plane=is_control_plane,
workers=proxy_config.worker_registry if is_control_plane else [],
)

View file

@ -107,7 +107,7 @@ async def google_stream_generate_content(
data["stream"] = True
# google-genai SDK (?alt=sse) must not receive OpenAI's data: [DONE] terminator.
data["_litellm_skip_openai_stream_done"] = True
data["_litellm_raw_sse_stream"] = True # any-ok: untyped request
data["_litellm_raw_sse_stream"] = True
processor = ProxyBaseLLMRequestProcessing(data=data)
try:

View file

@ -25,7 +25,11 @@ from litellm.llms.custom_httpx.http_handler import (
httpxSpecialProvider,
)
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import GenericGuardrailAPIInputs, GuardrailStatus
from litellm.types.utils import (
GenericGuardrailAPIInputs,
GuardrailStatus,
GuardrailTracingDetail,
)
from .base import OpenAIGuardrailBase
@ -287,6 +291,7 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail):
start_time=start_time,
end_time=end_time,
event_type=event_type,
tracing_detail=self._build_tracing_detail(guardrail_response),
)
return response
@ -328,9 +333,36 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail):
start_time=start_time,
end_time=end_time,
event_type=event_type,
tracing_detail=self._build_tracing_detail(guardrail_response),
)
raise e
@staticmethod
def _build_tracing_detail(
guardrail_response: Union[dict, str, Exception],
) -> Optional[GuardrailTracingDetail]:
"""
Pull the flagged category names out of the moderation response so trace
backends can index a short, queryable ``guardrail_violation_categories``
attribute instead of the full ``guardrail_response`` blob, whose
``category_scores`` map (one float per category) blows past indexed-field
length limits on backends like ELK (1024 chars).
"""
if not isinstance(guardrail_response, dict):
return None
results = guardrail_response.get("results") or []
violation_categories = [
category
for result in results
if isinstance(result, dict)
for category, is_flagged in (result.get("categories") or {}).items()
if is_flagged
]
if not violation_categories:
return None
return GuardrailTracingDetail(violation_categories=violation_categories)
@staticmethod
def get_config_model() -> Optional[Type["GuardrailConfigModel"]]:
"""

View file

@ -747,12 +747,12 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
# to the model would carry anonymization tokens and the response would echo them.
if (
self.should_run_guardrail(
data=data, # any-ok: untyped request
event_type=GuardrailEventHooks.pre_call, # any-ok: untyped request
data=data,
event_type=GuardrailEventHooks.pre_call,
)
is not True
):
return data # any-ok: untyped request
return data
try:
content_safety = data.get("content_safety", None)

View file

@ -3239,10 +3239,8 @@ async def _get_model_max_budget_current_spend(
f"{VIRTUAL_KEY_SPEND_CACHE_KEY_PREFIX}:"
f"{api_key_hash}:{model}:{budget_config.budget_duration}"
)
current_spend: float | None = (
await user_api_key_cache.async_get_cache( # any-ok: untyped dump
key=virtual_key_model_spend_cache_key,
)
current_spend: float | None = await user_api_key_cache.async_get_cache(
key=virtual_key_model_spend_cache_key,
)
if current_spend is None:
model_without_prefix = model.split("/")[-1] if "/" in model else model
@ -3250,13 +3248,11 @@ async def _get_model_max_budget_current_spend(
f"{VIRTUAL_KEY_SPEND_CACHE_KEY_PREFIX}:"
f"{api_key_hash}:{model_without_prefix}:{budget_config.budget_duration}"
)
current_spend = (
await user_api_key_cache.async_get_cache( # any-ok: untyped dump
key=virtual_key_model_spend_cache_key,
)
current_spend = await user_api_key_cache.async_get_cache(
key=virtual_key_model_spend_cache_key,
)
try:
return float(current_spend or 0.0) # any-ok: untyped dump
return float(current_spend or 0.0)
except (TypeError, ValueError):
return 0.0
@ -3365,27 +3361,17 @@ async def info_key_fn_v2(
k_dict = k.model_dump()
except Exception:
k_dict = k.dict()
k_token_hash = k_dict.pop("token", None) # any-ok: untyped dump
k_token_hash = k_dict.pop("token", None)
model_max_budget = (
k_dict.get("model_max_budget") or {} # any-ok: untyped dump
)
budget_table = (
k_dict.get("litellm_budget_table") or {} # any-ok: untyped dump
)
if not model_max_budget and isinstance( # any-ok: untyped dump
budget_table, dict # any-ok: untyped dump
):
model_max_budget = (
budget_table.get("model_max_budget") or {} # any-ok: untyped dump
)
if model_max_budget and k_token_hash: # any-ok: untyped dump
k_dict["model_max_budget_usage"] = ( # any-ok: untyped dump
await _build_model_max_budget_usage( # any-ok: untyped dump
api_key_hash=k_token_hash, # any-ok: untyped dump
model_max_budget=model_max_budget, # any-ok: untyped dump
user_api_key_cache=user_api_key_cache,
)
model_max_budget = k_dict.get("model_max_budget") or {}
budget_table = k_dict.get("litellm_budget_table") or {}
if not model_max_budget and isinstance(budget_table, dict):
model_max_budget = budget_table.get("model_max_budget") or {}
if model_max_budget and k_token_hash:
k_dict["model_max_budget_usage"] = await _build_model_max_budget_usage(
api_key_hash=k_token_hash,
model_max_budget=model_max_budget,
user_api_key_cache=user_api_key_cache,
)
filtered_key_info.append(k_dict)
@ -3470,27 +3456,17 @@ async def info_key_fn(
except Exception:
# if using pydantic v1
key_info = key_info.dict()
key_token_hash = key_info.pop("token") # any-ok: untyped dump
key_token_hash = key_info.pop("token")
model_max_budget = (
key_info.get("model_max_budget") or {} # any-ok: untyped dump
)
budget_table = (
key_info.get("litellm_budget_table") or {} # any-ok: untyped dump
)
if not model_max_budget and isinstance( # any-ok: untyped dump
budget_table, dict # any-ok: untyped dump
):
model_max_budget = (
budget_table.get("model_max_budget") or {} # any-ok: untyped dump
)
if model_max_budget and key_token_hash: # any-ok: untyped dump
key_info["model_max_budget_usage"] = ( # any-ok: untyped dump
await _build_model_max_budget_usage( # any-ok: untyped dump
api_key_hash=key_token_hash, # any-ok: untyped dump
model_max_budget=model_max_budget, # any-ok: untyped dump
user_api_key_cache=user_api_key_cache,
)
model_max_budget = key_info.get("model_max_budget") or {}
budget_table = key_info.get("litellm_budget_table") or {}
if not model_max_budget and isinstance(budget_table, dict):
model_max_budget = budget_table.get("model_max_budget") or {}
if model_max_budget and key_token_hash:
key_info["model_max_budget_usage"] = await _build_model_max_budget_usage(
api_key_hash=key_token_hash,
model_max_budget=model_max_budget,
user_api_key_cache=user_api_key_cache,
)
# Attach object_permission if object_permission_id is set

View file

@ -194,11 +194,7 @@ def _is_valid_cli_sso_user_code(user_code: str | None) -> bool:
def _cli_sso_verification_uri_complete_enabled() -> bool:
from litellm.proxy.proxy_server import general_settings
return bool(
general_settings.get( # any-ok: operator opt-in read from the untyped general_settings dict
"allow_cli_sso_verification_uri_complete", False
)
)
return bool(general_settings.get("allow_cli_sso_verification_uri_complete", False))
def _cli_sso_start_response_body(

View file

@ -14,6 +14,8 @@ from litellm.types.utils import SpecialEnums
if TYPE_CHECKING:
from fastapi import Request
from litellm.router import Router
def _is_base64_encoded_unified_file_id(b64_uid: str) -> Union[str, Literal[False]]:
# Ensure b64_uid is a string and not a mock object
@ -300,6 +302,92 @@ def get_credentials_for_model(
return credentials
def get_team_provider_credentials(
llm_router: Optional["Router"],
team_models: List[str],
custom_llm_provider: str,
team_id: Optional[str] = None,
) -> Optional[dict]:
"""
Resolve upstream credentials for a provider-scoped file operation
(e.g. GET /v1/files), which doesn't pin a model.
Priority:
1. The team's own (BYOK) deployment for this provider — a deployment whose
``model_info.team_id`` matches ``team_id``. This keeps team-scoped listings
on the team's own provider account/key instead of a shared global one.
2. Fallback: any deployment the team is granted access to for this provider,
expanding wildcard routes and the all-proxy-models sentinel.
Credential lookup is always scoped to the team's allowlist, so a team can
never resolve a provider key for a deployment it isn't authorized to use.
Returns None when the router is unavailable or no authorized deployment
matches, so the caller can fall back to default credential resolution.
"""
if llm_router is None:
return None
def _provider_credentials(model_id: str) -> Optional[dict]:
credentials = llm_router.get_deployment_credentials_with_provider(
model_id=model_id
)
if (
credentials is not None
and credentials.get("custom_llm_provider") == custom_llm_provider
):
return credentials
return None
# 1. Prefer the team's own BYOK deployment, matched by model_info.team_id.
if team_id is not None:
for deployment in llm_router.model_list or []:
model_info = deployment.get("model_info") or {}
if model_info.get("team_id") != team_id:
continue
deployment_id = model_info.get("id")
if deployment_id is None:
continue
credentials = _provider_credentials(deployment_id)
if credentials is not None:
return credentials
# 2. Fall back to deployments the team is allowed to access. The
# all-proxy-models sentinel isn't expanded by get_complete_model_list, so
# normalize it to an empty allowlist, which defers to the team-scoped
# proxy model list. A team with a restricted allowlist (e.g. anthropic
# only) therefore never resolves another provider's key.
from litellm.proxy._types import SpecialModelNames
from litellm.proxy.auth.model_checks import get_complete_model_list
grants_all_models = SpecialModelNames.all_proxy_models.value in team_models
effective_team_models = [] if grants_all_models else team_models
proxy_model_list = llm_router.get_model_names(team_id=team_id)
model_access_groups = llm_router.get_model_access_groups()
models_to_try = list(
dict.fromkeys(
get_complete_model_list(
key_models=[],
team_models=effective_team_models,
proxy_model_list=proxy_model_list,
user_model=None,
infer_model_from_keys=False,
return_wildcard_routes=True,
llm_router=llm_router,
model_access_groups=model_access_groups,
include_model_access_groups=True,
team_id=team_id,
)
)
)
for model_name in models_to_try:
credentials = _provider_credentials(model_name)
if credentials is not None:
return credentials
return None
def prepare_data_with_credentials(
data: dict,
credentials: dict,

View file

@ -43,6 +43,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import (
encode_file_id_with_model,
extract_file_creation_params,
get_credentials_for_model,
get_team_provider_credentials,
handle_model_based_routing,
prepare_data_with_credentials,
validate_managed_files_requirement,
@ -1351,14 +1352,20 @@ async def list_files(
status_code=400,
detail="target_model_names on list files must be a list of one model name. Example: ['gpt-4o']",
)
## Use router to list fine-tuning jobs for that model
if llm_router is None:
raise HTTPException(
status_code=500,
detail="LLM Router not initialized. Ensure models added to proxy.",
)
data["model"] = target_model_names_list[0]
response = await llm_router.afile_list(
credentials = get_credentials_for_model(
llm_router=llm_router,
model_id=target_model_names_list[0],
operation_context="file list",
)
prepare_data_with_credentials(data=data, credentials=credentials)
response = await litellm.afile_list(
custom_llm_provider=credentials["custom_llm_provider"],
purpose=purpose,
**data,
)
else:
@ -1370,6 +1377,18 @@ async def list_files(
or "openai"
)
# No model/target_model_names pinned: resolve upstream credentials from
# the team's deployment for this provider so the call is authenticated
# against the team's own account (e.g. the team's openai deployment).
team_credentials = get_team_provider_credentials(
llm_router=llm_router,
team_models=user_api_key_dict.team_models or [],
custom_llm_provider=custom_llm_provider,
team_id=user_api_key_dict.team_id,
)
if team_credentials is not None:
prepare_data_with_credentials(data=data, credentials=team_credentials)
response = await litellm.afile_list(
custom_llm_provider=custom_llm_provider, purpose=purpose, **data # type: ignore
)

View file

@ -15,6 +15,7 @@ import threading
import time
import traceback
import warnings
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from typing import (
TYPE_CHECKING,
@ -301,6 +302,7 @@ from litellm.proxy.common_utils.load_config_utils import (
get_config_file_contents_from_gcs,
get_file_contents_from_s3,
)
from litellm.proxy.common_utils.model_listing_utils import TeamModelNameTranslator
from litellm.proxy.common_utils.openai_endpoint_utils import (
remove_sensitive_info_from_deployment,
)
@ -894,7 +896,7 @@ async def proxy_startup_event(app: FastAPI):
if transaction_buffer_redis_cache is None:
transaction_buffer_redis_cache = (
ProxyStartupEvent._get_transaction_buffer_redis_cache(
general_settings=general_settings # any-ok: untyped stream
general_settings=general_settings
)
)
@ -7080,9 +7082,7 @@ async def async_data_generator(
# happened to ship a streaming-iterator override (the default).
needs_iterator_wrap = proxy_logging_obj.needs_iterator_wrap()
needs_per_chunk_hook = proxy_logging_obj.needs_per_chunk_streaming_hook()
is_raw_sse_stream = bool(
request_data.get("_litellm_raw_sse_stream") # any-ok: untyped stream
)
is_raw_sse_stream = bool(request_data.get("_litellm_raw_sse_stream"))
raw_sse_buffer = ""
if needs_iterator_wrap:
@ -7121,26 +7121,26 @@ async def async_data_generator(
frame, raw_sse_buffer = _pop_complete_sse_frame(raw_sse_buffer)
if frame is None:
break
yield frame # any-ok: untyped stream
yield frame
if len(raw_sse_buffer) > _MAX_RAW_SSE_BUFFER_CHARS:
raise ValueError(
"Raw SSE stream exceeded maximum buffered size without a frame delimiter"
)
continue
if chunk.startswith(("data:", "event:", ":")):
yield ( # any-ok: untyped stream
yield (
chunk
if chunk.endswith(_SSE_FRAME_DELIMITERS)
else chunk + "\n\n"
)
continue
elif isinstance(chunk, str) and is_raw_sse_stream: # any-ok: untyped stream
elif isinstance(chunk, str) and is_raw_sse_stream:
raw_sse_buffer += chunk
while True:
frame, raw_sse_buffer = _pop_complete_sse_frame(raw_sse_buffer)
if frame is None:
break
yield frame # any-ok: untyped stream
yield frame
if len(raw_sse_buffer) > _MAX_RAW_SSE_BUFFER_CHARS:
raise ValueError(
"Raw SSE stream exceeded maximum buffered size without a frame delimiter"
@ -7163,7 +7163,7 @@ async def async_data_generator(
ProxyLogging._fire_deferred_stream_logging(request_data)
if raw_sse_buffer:
yield ( # any-ok: untyped stream
yield (
raw_sse_buffer
if raw_sse_buffer.endswith(_SSE_FRAME_DELIMITERS)
else raw_sse_buffer + "\n\n"
@ -7229,8 +7229,8 @@ async def async_data_generator(
await ProxyBaseLLMRequestProcessing._finalize_streaming_generator_cleanup(
request=request,
request_data=request_data, # any-ok: untyped stream
response=response, # any-ok: untyped stream
request_data=request_data,
response=response,
stream_completed=stream_completed,
client_disconnected=client_disconnected,
)
@ -7351,10 +7351,8 @@ class ProxyStartupEvent:
from litellm._redis import _redis_kwargs_from_environment
from litellm.secret_managers.main import str_to_bool
_use_redis_transaction_buffer: bool | str | None = (
general_settings.get( # any-ok: untyped stream
"use_redis_transaction_buffer", False
)
_use_redis_transaction_buffer: bool | str | None = general_settings.get(
"use_redis_transaction_buffer", False
)
if isinstance(_use_redis_transaction_buffer, str):
_use_redis_transaction_buffer = str_to_bool(_use_redis_transaction_buffer)
@ -7362,14 +7360,11 @@ class ProxyStartupEvent:
if not _use_redis_transaction_buffer:
return None
redis_env_kwargs = _redis_kwargs_from_environment() # any-ok: untyped stream
if (
"host" not in redis_env_kwargs # any-ok: untyped stream
and "url" not in redis_env_kwargs # any-ok: untyped stream
):
redis_env_kwargs = _redis_kwargs_from_environment()
if "host" not in redis_env_kwargs and "url" not in redis_env_kwargs:
return None
return RedisCache(**redis_env_kwargs) # any-ok: untyped stream
return RedisCache(**redis_env_kwargs)
@classmethod
async def _initialize_semantic_tool_filter(
@ -8376,6 +8371,8 @@ async def model_list(
"""
global llm_model_list, general_settings, llm_router, prisma_client, user_api_key_cache, proxy_logging_obj
settings = cast(dict[str, object], general_settings) # any-ok: legacy settings
from litellm.proxy.management_endpoints.common_utils import (
_user_has_admin_privileges,
)
@ -8455,16 +8452,21 @@ async def model_list(
if hidden_names:
all_models = [m for m in all_models if m not in hidden_names]
# Build response data with all proxy models
# Surface the public team name by default; legacy internal keys via flag.
# The internal routing key drives the metadata/fallback lookup, while the
# public name is what the client sees as the model id.
model_data = []
for model in all_models:
for response_id, lookup_id in TeamModelNameTranslator.listing_entries(
all_models, llm_router, settings
):
model_info = create_model_info_response(
model_id=model,
model_id=lookup_id,
provider="openai",
include_metadata=include_metadata or False,
fallback_type=fallback_type,
llm_router=llm_router,
)
model_info["id"] = response_id
model_data.append(model_info)
return dict(
@ -8492,16 +8494,21 @@ async def model_list(
if hidden_names:
all_models = [m for m in all_models if m not in hidden_names]
# Build response data
# Surface the public team name by default; legacy internal keys via flag.
# The internal routing key drives the metadata/fallback lookup, while the
# public name is what the client sees as the model id.
model_data = []
for model in all_models:
for response_id, lookup_id in TeamModelNameTranslator.listing_entries(
all_models, llm_router, settings
):
model_info = create_model_info_response(
model_id=model,
model_id=lookup_id,
provider="openai",
include_metadata=include_metadata or False,
fallback_type=fallback_type,
llm_router=llm_router,
)
model_info["id"] = response_id
model_data.append(model_info)
return dict(
@ -8523,6 +8530,8 @@ async def model_list(
async def model_info(
model_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
team_id: Optional[str] = None,
healthy_only: Optional[bool] = False,
):
"""
Retrieve information about a specific model accessible to your API key.
@ -8532,16 +8541,21 @@ async def model_info(
Follows OpenAI API specification for individual model retrieval.
https://platform.openai.com/docs/api-reference/models/retrieve
Query parameters mirror `/v1/models` so the same caller context (team
scoping, health filtering, paused deployments) drives both endpoints; the
listing's public id must resolve to the same internal deployment here.
"""
global llm_model_list, general_settings, llm_router, prisma_client, user_api_key_cache, proxy_logging_obj
settings = cast(dict[str, object], general_settings) # any-ok: legacy settings
from litellm.proxy.utils import (
create_model_info_response,
get_available_models_for_user,
validate_model_access,
)
# Get available models for the user
all_models = await get_available_models_for_user(
user_api_key_dict=user_api_key_dict,
llm_router=llm_router,
@ -8549,21 +8563,43 @@ async def model_info(
user_model=user_model,
prisma_client=prisma_client,
proxy_logging_obj=proxy_logging_obj,
team_id=None,
team_id=team_id,
include_model_access_groups=False,
only_model_access_groups=False,
return_wildcard_routes=False,
user_api_key_cache=user_api_key_cache,
)
# Mirror /v1/models' visibility filter so first-occurrence resolution
# cannot land on a deployment the listing had hidden.
blocked_names = (
llm_router.get_fully_blocked_model_names() if llm_router is not None else set()
)
unhealthy_names: set[str] = set()
if healthy_only and llm_router is not None:
unhealthy_names = await llm_router.async_get_fully_unhealthy_model_names()
hidden_names = blocked_names | unhealthy_names
if hidden_names:
all_models = [m for m in all_models if m not in hidden_names]
internal_to_public = TeamModelNameTranslator.build_internal_to_public_map(
llm_router, settings
)
resolved_model_id = TeamModelNameTranslator.resolve_public_name(
model_id=model_id,
available_models=all_models,
llm_router=llm_router,
general_settings=settings,
)
# Validate that the requested model is accessible
validate_model_access(model_id=model_id, available_models=all_models)
validate_model_access(model_id=resolved_model_id, available_models=all_models)
# Get provider information from the router deployment
if llm_router is None:
raise HTTPException(status_code=500, detail="Router not initialized")
deployment = llm_router.get_deployment_by_model_group_name(model_id)
deployment = llm_router.get_deployment_by_model_group_name(resolved_model_id)
if deployment is None:
raise HTTPException(
status_code=404,
@ -8573,9 +8609,9 @@ async def model_info(
# Use the actual litellm model from the deployment to get provider info
_, provider, _, _ = litellm.get_llm_provider(model=deployment.litellm_params.model)
# Return the model information in the same format as the list endpoint
response_id = internal_to_public.get(resolved_model_id, model_id)
return create_model_info_response(
model_id=model_id,
model_id=response_id,
provider=provider,
include_metadata=False,
fallback_type=None,

View file

@ -46,6 +46,7 @@ from litellm.proxy._types import (
)
from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.proxy.model_listing import ModelInfoResponse
from litellm.types.utils import CallTypes, CallTypesLiteral
try:
@ -6311,56 +6312,39 @@ def create_model_info_response(
include_metadata: bool = False,
fallback_type: Optional[str] = None,
llm_router: Optional["Router"] = None,
) -> dict:
) -> ModelInfoResponse:
"""
Create a standardized model info response.
Create a standardized OpenAI-compatible model object.
Args:
model_id: The model ID
provider: The model provider
include_metadata: Whether to include metadata
fallback_type: Type of fallbacks to include
llm_router: LiteLLM router instance
Returns:
Dictionary containing model information
When include_metadata is true, attaches the model's configured fallbacks
(resolved via the router under fallback_type, defaulting to "general").
Raises HTTPException(400) for an unknown fallback_type.
"""
from litellm.proxy.auth.model_checks import get_all_fallbacks
model_info = {
base: ModelInfoResponse = {
"id": model_id,
"object": "model",
"created": DEFAULT_MODEL_CREATED_AT_TIME,
"owned_by": provider,
}
if not include_metadata:
return base
# Add metadata if requested
if include_metadata:
metadata = {}
# Default fallback_type to "general" if include_metadata is true
effective_fallback_type = (
fallback_type if fallback_type is not None else "general"
effective_fallback_type = fallback_type if fallback_type is not None else "general"
valid_fallback_types = ("general", "context_window", "content_policy")
if effective_fallback_type not in valid_fallback_types:
raise HTTPException(
status_code=400,
detail=f"Invalid fallback_type. Must be one of: {list(valid_fallback_types)}",
)
# Validate fallback_type
valid_fallback_types = ["general", "context_window", "content_policy"]
if effective_fallback_type not in valid_fallback_types:
raise HTTPException(
status_code=400,
detail=f"Invalid fallback_type. Must be one of: {valid_fallback_types}",
)
fallbacks = get_all_fallbacks(
model=model_id,
llm_router=llm_router,
fallback_type=effective_fallback_type,
)
metadata["fallbacks"] = fallbacks
model_info["metadata"] = metadata
return model_info
fallbacks = get_all_fallbacks(
model=model_id,
llm_router=llm_router,
fallback_type=effective_fallback_type,
)
return {**base, "metadata": {"fallbacks": fallbacks}}
def validate_model_access(

View file

@ -244,16 +244,12 @@ def _check_non_standard_fallback_format(fallbacks: Optional[List[Any]]) -> bool:
if all(isinstance(item, str) for item in fallbacks):
return True
elif all(isinstance(item, dict) for item in fallbacks):
for item in fallbacks: # any-ok: untyped config
for (
key
) in (
LiteLLMParamsTypedDict.__annotations__.keys() # any-ok: untyped config
):
if key in item: # any-ok: untyped config
for item in fallbacks:
for key in LiteLLMParamsTypedDict.__annotations__.keys():
if key in item:
# If the value is a list, it's likely a standard fallback model group mapping
# (e.g. {"model": ["backup"]}) rather than a parameter override.
if not isinstance(item[key], list): # any-ok: untyped config
if not isinstance(item[key], list):
return True
return False

View file

@ -321,13 +321,13 @@ class AWSSecretsManagerV2(BaseAWSLLM, BaseSecretManager):
)
try:
response = await async_client.post( # any-ok: untyped httpx
response = await async_client.post(
url=endpoint_url,
headers=headers, # any-ok: untyped httpx
data=body.decode("utf-8"), # any-ok: untyped httpx
headers=headers,
data=body.decode("utf-8"),
)
response.raise_for_status() # any-ok: untyped httpx
create_response = response.json() # any-ok: untyped httpx
response.raise_for_status()
create_response = response.json()
except httpx.HTTPStatusError as err:
raise ValueError(f"HTTP error occurred: {err.response.text}")
except httpx.TimeoutException:
@ -338,7 +338,7 @@ class AWSSecretsManagerV2(BaseAWSLLM, BaseSecretManager):
await self.async_replicate_secret(
secret_name=secret_name,
replica_regions=self.replica_regions,
optional_params=optional_params, # any-ok: untyped httpx
optional_params=optional_params,
timeout=timeout,
)
verbose_logger.debug(
@ -354,7 +354,7 @@ class AWSSecretsManagerV2(BaseAWSLLM, BaseSecretManager):
str(replication_err),
)
return create_response # any-ok: untyped httpx
return create_response
async def async_replicate_secret(
self,
@ -392,7 +392,7 @@ class AWSSecretsManagerV2(BaseAWSLLM, BaseSecretManager):
"AddReplicaRegions": [{"Region": r} for r in replica_regions],
}
endpoint_url, headers, body = self._prepare_request( # any-ok: untyped httpx
endpoint_url, headers, body = self._prepare_request(
action="ReplicateSecretToRegions",
secret_name=secret_name,
optional_params=optional_params,
@ -401,7 +401,7 @@ class AWSSecretsManagerV2(BaseAWSLLM, BaseSecretManager):
async_client = get_async_httpx_client(
llm_provider=httpxSpecialProvider.SecretManager,
params={"timeout": timeout}, # any-ok: untyped httpx
params={"timeout": timeout},
)
try:

View file

@ -0,0 +1,21 @@
"""Response types for the model listing/retrieve endpoints (/v1/models, /models)."""
from typing import Literal
from typing_extensions import NotRequired, TypedDict
class ModelInfoMetadata(TypedDict):
fallbacks: list[str]
class ModelInfoResponse(TypedDict):
"""OpenAI-compatible model object. `metadata` is present only when the
endpoint is called with include_metadata=true.
"""
id: str
object: Literal["model"]
created: int
owned_by: str
metadata: NotRequired[ModelInfoMetadata]

View file

@ -6043,13 +6043,13 @@ def _get_model_info_helper(
cache_read_input_token_cost_above_200k_tokens=_model_info.get(
"cache_read_input_token_cost_above_200k_tokens", None
),
cache_read_input_token_cost_above_200k_tokens_priority=_model_info.get( # any-ok: untyped cost map
cache_read_input_token_cost_above_200k_tokens_priority=_model_info.get(
"cache_read_input_token_cost_above_200k_tokens_priority", None
),
cache_read_input_token_cost_above_272k_tokens=_model_info.get(
"cache_read_input_token_cost_above_272k_tokens", None
),
cache_read_input_token_cost_above_272k_tokens_priority=_model_info.get( # any-ok: untyped cost map
cache_read_input_token_cost_above_272k_tokens_priority=_model_info.get(
"cache_read_input_token_cost_above_272k_tokens_priority", None
),
cache_read_input_token_cost_above_512k_tokens=_model_info.get(
@ -6073,13 +6073,13 @@ def _get_model_info_helper(
input_cost_per_token_above_200k_tokens=_model_info.get(
"input_cost_per_token_above_200k_tokens", None
),
input_cost_per_token_above_200k_tokens_priority=_model_info.get( # any-ok: untyped cost map
input_cost_per_token_above_200k_tokens_priority=_model_info.get(
"input_cost_per_token_above_200k_tokens_priority", None
),
input_cost_per_token_above_272k_tokens=_model_info.get(
"input_cost_per_token_above_272k_tokens", None
),
input_cost_per_token_above_272k_tokens_priority=_model_info.get( # any-ok: untyped cost map
input_cost_per_token_above_272k_tokens_priority=_model_info.get(
"input_cost_per_token_above_272k_tokens_priority", None
),
input_cost_per_token_above_512k_tokens=_model_info.get(
@ -6137,13 +6137,13 @@ def _get_model_info_helper(
output_cost_per_token_above_200k_tokens=_model_info.get(
"output_cost_per_token_above_200k_tokens", None
),
output_cost_per_token_above_200k_tokens_priority=_model_info.get( # any-ok: untyped cost map
output_cost_per_token_above_200k_tokens_priority=_model_info.get(
"output_cost_per_token_above_200k_tokens_priority", None
),
output_cost_per_token_above_272k_tokens=_model_info.get(
"output_cost_per_token_above_272k_tokens", None
),
output_cost_per_token_above_272k_tokens_priority=_model_info.get( # any-ok: untyped cost map
output_cost_per_token_above_272k_tokens_priority=_model_info.get(
"output_cost_per_token_above_272k_tokens_priority", None
),
output_cost_per_token_above_512k_tokens=_model_info.get(

View file

@ -1,18 +0,0 @@
{
"import-not-found": {
"baseline": 8,
"slack": 3
},
"no-any-return": {
"baseline": 902,
"slack": 10
},
"no-untyped-def": {
"baseline": 4888,
"slack": 10
},
"valid-type": {
"baseline": 1,
"slack": 3
}
}

View file

@ -148,7 +148,6 @@ dev = [
"diff-cover==9.7.2",
"flake8==7.3.0",
"black==26.3.1",
"mypy==1.19.0",
"basedpyright==1.39.7",
"pytest==9.0.3",
"pytest-mock==3.15.1",
@ -261,8 +260,6 @@ source-exclude = [
"litellm/proxy/enterprise",
"**/__pycache__",
"**/__pycache__/**",
"**/.mypy_cache",
"**/.mypy_cache/**",
"**/.pytest_cache",
"**/.pytest_cache/**",
"**/.ruff_cache",
@ -278,9 +275,6 @@ version_files = [
"pyproject.toml:^version",
]
[tool.mypy]
plugins = "pydantic.mypy"
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"

View file

@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""Non-gating ratchet guard: budget ceilings may only fall, never rise.
Every `*-budget.json` file (ruff-strict, type-discipline, mypy-code, basedpyright-code,
any-discipline) is a one-way ratchet: each rule's ceiling is `baseline + slack`, and the whole point is
Every `*-budget.json` file (ruff-strict, type-discipline, basedpyright-code) is a
one-way ratchet: each rule's ceiling is `baseline + slack`, and the whole point is
to drive that number DOWN over time. This check compares every budget file against
its own content at the merge-base with the target branch and fails (exits 1, red) if:
@ -12,12 +12,6 @@ its own content at the merge-base with the target branch and fails (exits 1, red
New rules and lowered/equal ceilings are fine.
The any-discipline budget is keyed by file rather than rule: its gate treats an
absent file as ceiling 0 (the file must be Any-free), so an entry vanishing means
that file was cleaned to zero -- a tightening, and exactly the cleanup this
ratchet exists to encourage. Such a budget is therefore exempt from the
dropped-entry rule (a raised ceiling is still caught).
This is deliberately NOT a gating check. It should turn the run red so that a
loosening is impossible to miss in review, but it must stay OUT of the
branch-protection required-checks list: a justified bump (e.g. banning a new API,
@ -44,17 +38,9 @@ DEFAULT_BASE = "origin/litellm_internal_staging"
DEFAULT_BUDGETS: tuple[str, ...] = (
"ruff-strict-budget.json",
"type-discipline-budget.json",
"mypy-code-budget.json",
"basedpyright-code-budget.json",
"any-discipline-budget.json",
)
# File-keyed budgets whose gate treats an absent entry as ceiling 0 (the file
# must stay clean). Dropping an entry there is a tightening, not the "untracked,
# now unbounded" loosening a vanished rule is for the rule-keyed budgets, so a
# dropped entry must not read as a regression.
ZERO_FLOOR_BUDGETS: frozenset[str] = frozenset({"any-discipline-budget.json"})
class Regression(NamedTuple):
budget: str
@ -112,15 +98,17 @@ def regressions_for(rel: str, base: dict | None, head: dict | None) -> list[Regr
base_caps = _caps(base)
head_caps = _caps(head)
drop_floors_to_zero = rel in ZERO_FLOOR_BUDGETS
out: list[Regression] = []
for rule, base_cap in sorted(base_caps.items()):
if rule not in head_caps:
if not drop_floors_to_zero:
out.append(Regression(rel, rule, f"rule dropped (ceiling {base_cap} -> removed)"))
elif head_caps[rule] > base_cap:
out.append(Regression(rel, rule, f"ceiling raised {base_cap} -> {head_caps[rule]}"))
return out
return [
Regression(
rel,
rule,
f"rule dropped (ceiling {base_cap} -> removed)"
if rule not in head_caps
else f"ceiling raised {base_cap} -> {head_caps[rule]}",
)
for rule, base_cap in sorted(base_caps.items())
if rule not in head_caps or head_caps[rule] > base_cap
]
def main() -> int:

View file

@ -1,778 +0,0 @@
#!/usr/bin/env python3
"""Any-discipline gate: fail when a changed file exceeds its `Any` budget.
Where ruff, `mypy --strict`, and even basedpyright's `reportAny` stop short, this
catches the case that actually bites: a *union* hiding an `Any`. For example
`re.Match.group()` -> `str | Any`, `json.loads()` -> `Any`, and bare `list`/`dict`
-> `list[Any]`/`dict[..., Any]`. Any value whose inferred type *contains* `Any`
(recursively, through unions / generics / tuples) is reported.
Scope: changed files, per-file budget
-------------------------------------
litellm carries a large amount of pre-existing `Any` (a single legacy file can
have >100 findings). Rather than force every touched line clean (the original
changed-lines rule, which tripped on merely *editing* a legacy `X | Any` line),
this gate grandfathers each file: `any-discipline-budget.json` records every
file's current count of Any-typed values, and a file fails only when its count
exceeds `baseline + slack`, where `slack` is 50% headroom (rounded up). New or
unbudgeted files have baseline 0, so they stay airtight.
Only *changed* files (vs the merge-base with `--base`) are re-type-checked -- an
unchanged file's count can't move from edits this branch didn't make -- so the
per-PR cost equals re-checking just those files, exactly like the original
changed-lines gate. The whole-tree scan needed to (re)capture the budget
(~2 min, ~3 GB) runs only under `--update`.
The budget is a one-way ratchet (the same `{baseline, slack}` shape as the
ruff / mypy / basedpyright budgets) guarded by `scripts/budget_ratchet_check.py`:
a file's ceiling may fall but never rise. Drive a file's count down and rerun
`--update` (`make lint-any-budget-update`) to lock in the lower ceiling.
How it works
------------
It loads `litellm/mypy.ini` (the same config `make lint-mypy` uses, so findings
match what developers already see), builds the changed files with mypy asking for
its exported expression->type map, and walks each file's AST applying a recursive
"contains Any" predicate -- the test `mypy --disallow-any-expr` uses internally
but applies inconsistently (python/mypy#12856).
mypy only re-exports types for modules it re-type-checks, so for each target we
invalidate just its cached hash (deps stay warm) to force a fast re-check against
a persisted incremental cache (.mypy_cache_any).
Rules
-----
Codes share the `LIT***` namespace with `scripts/check_type_discipline.py` (PR
#30500), which owns LIT001/002/003/004/006/007/008. This gate claims the rest:
LIT009 A value expression's inferred type is, or contains, `Any`. Budgeted
per file (a file fails when its count exceeds `baseline + slack`).
Suppress an individual line with `# any-ok: <reason>`.
LIT005 An `# any-ok` suppression without a reason (the shared
suppression-needs-a-reason code, same as `# cast-ok` / `# guard-ok`).
LIT000 Setup failure: mypy could not build, or a target file could not be read.
`Any`s produced purely by an already-reported error, and the special-form /
implementation-artifact internal `Any`s, are ignored. A bound method *reference*
whose signature mentions `Any` is not flagged -- only the value its call produces.
Usage
-----
# gate mode (CI / pre-push): per-file Any budget on changed files
uv run --no-sync python scripts/check_any_discipline.py --changed --base origin/litellm_internal_staging
# re-capture the per-file budget across the whole tree (ratchet)
uv run --no-sync python scripts/check_any_discipline.py --update
# whole-file spot-check (no budget, no line filter), paths relative to repo root
uv run --no-sync python scripts/check_any_discipline.py litellm/budget_manager.py
Exit code 1 if a file is over budget (or a hard rule trips), 2 on a setup error.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import tokenize
from collections.abc import Callable, Iterable, Sequence
from pathlib import Path
from typing import NamedTuple
try:
from mypy import build
from mypy.config_parser import parse_config_file
from mypy.find_sources import create_source_list
from mypy.fscache import FileSystemCache
from mypy.modulefinder import BuildSource
from mypy.nodes import AssignmentStmt, Expression, NameExpr, Node, TempNode
from mypy.options import Options
from mypy.types import (
AnyType,
CallableType,
Instance,
Overloaded,
TupleType,
Type,
TypeOfAny,
UnionType,
get_proper_type,
)
except ImportError: # pragma: no cover - environment guard
sys.stderr.write(
"check_any_discipline: mypy is not importable in this interpreter.\n"
"Run it through the project environment, e.g.\n"
" uv run --no-sync python scripts/check_any_discipline.py --changed\n"
)
raise SystemExit(2)
REPO_ROOT = Path(__file__).resolve().parent.parent
LITELLM_DIR = REPO_ROOT / "litellm"
MYPY_INI = LITELLM_DIR / "mypy.ini"
CACHE_DIR = REPO_ROOT / ".mypy_cache_any"
PY_TAG = f"{sys.version_info.major}.{sys.version_info.minor}"
DEFAULT_BASE = "origin/litellm_internal_staging"
BUDGET_PATH = REPO_ROOT / "any-discipline-budget.json"
MIN_REASON_LEN = 3
ANY_OK_RE = re.compile(r"#\s*any-ok(?::\s*(?P<reason>.*))?")
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
# Files allowed to surface `Any` (the typed/untyped boundary). A finding is
# skipped if any fragment below is a substring of the file's posix path. Keep
# this tight -- prefer a line-level `# any-ok: <reason>` over a blanket exemption.
BOUNDARY_PATHS: frozenset[str] = frozenset()
# `Any` kinds that are not actionable: produced by an already-reported error, or
# an internal placeholder that never corresponds to a concrete runtime value.
# NOTE: `special_form` is deliberately NOT here. In mypy 1.19 the `Any` in
# typeshed unions like `re.Match.group() -> str | Any` is tagged `special_form`,
# and that union is the headline case this gate exists to catch.
_HARMLESS_ANY = frozenset(
kind
for kind in (
TypeOfAny.from_error,
getattr(TypeOfAny, "implementation_artifact", None),
)
if kind is not None
)
# AST attributes that point OUTSIDE the syntactic subtree (a RefExpr's resolved
# definition, a node's TypeInfo). Skipping exactly these two makes a generic
# child-walk equivalent to mypy's TraverserVisitor -- validated to the node
# against ExtendedTraverserVisitor across the full grammar (see commit notes).
_NON_SYNTACTIC_ATTRS = frozenset({"node", "info"})
# Awaitable / coroutine / generator instances carry synthetic `Any` in their
# send (and, for coroutines, yield) protocol slots: `async def f() -> float`
# produces `Coroutine[Any, Any, float]`, so the bare call expression `f()` would
# be flagged even though the awaited value is a clean `float`. Only the args that
# hold a value the caller observes (the awaited result, the yielded item) are
# meaningful; a real `Any` there -- e.g. a coroutine that returns `Any` -- is
# still caught because that index is still checked.
_SYNTHETIC_SEND_YIELD_VALUE_ARGS: dict[str, tuple[int, ...]] = {
"typing.Coroutine": (2,),
"typing.Generator": (0, 2),
"typing.AsyncGenerator": (0,),
}
class Violation(NamedTuple):
path: Path
line: int
col: int
code: str
message: str
def render(self) -> str:
return f"{self.path}:{self.line}:{self.col}: {self.code} {self.message}"
# --------------------------------------------------------------------------- #
# The "contains Any" predicate
# --------------------------------------------------------------------------- #
# Recursive type aliases (e.g. a JSON-like `T = Union[..., list[T], dict[str, T]]`)
# make `get_proper_type` yield a fresh object at every unfold, so an id()-based
# cycle guard never trips and a naive recursion overflows the stack. We walk
# iteratively and cap the depth: a real `Any` lives at shallow depth in the
# alias's definition, so a deep alias that has not produced one by `_MAX_DEPTH`
# never will. (The changed-lines gate never hit this; a whole-tree scan does.)
_MAX_DEPTH = 100
def contains_any(t: Type) -> bool:
"""True if a *value* of type ``t`` carries `Any` anywhere meaningful."""
seen: set[int] = set()
stack: list[tuple[Type, int]] = [(t, 0)]
while stack:
cur, depth = stack.pop()
if depth > _MAX_DEPTH:
continue
p = get_proper_type(cur)
if id(p) in seen:
continue
seen.add(id(p))
# A function/method *reference* whose signature mentions Any is not itself
# an unsafe value -- only its eventual call result is. Don't recurse in.
if isinstance(p, (CallableType, Overloaded)):
continue
if isinstance(p, AnyType):
if p.type_of_any not in _HARMLESS_ANY:
return True
continue
if isinstance(p, UnionType):
stack.extend((item, depth + 1) for item in p.items)
elif isinstance(p, Instance):
value_arg_indices = _SYNTHETIC_SEND_YIELD_VALUE_ARGS.get(p.type.fullname)
if value_arg_indices is None:
stack.extend((arg, depth + 1) for arg in p.args)
else:
stack.extend(
(p.args[index], depth + 1)
for index in value_arg_indices
if index < len(p.args)
)
elif isinstance(p, TupleType):
stack.extend((item, depth + 1) for item in p.items)
return False
# --------------------------------------------------------------------------- #
# Generic, leak-free AST walk (works under a mypyc-compiled mypy, which forbids
# subclassing TraverserVisitor)
# --------------------------------------------------------------------------- #
def _walk_file(tree: Node) -> tuple[list[Expression], set[int]]:
"""Return (every Expression in `tree`, ids of simple assignment-target names).
The walk follows only syntactic children (every attribute except the two
non-syntactic back-references), so it never escapes the module. Simple
``x = <expr>`` name targets are collected separately so we don't double-report
the assigned name as an echo of an Any rvalue.
"""
exprs: list[Expression] = []
skip_lvalues: set[int] = set()
stack: list[object] = [tree]
seen: set[int] = set()
while stack:
n = stack.pop()
if isinstance(n, Node):
if id(n) in seen:
continue
seen.add(id(n))
if isinstance(n, Expression):
exprs.append(n)
if isinstance(n, AssignmentStmt):
for lvalue in n.lvalues:
if isinstance(lvalue, NameExpr):
skip_lvalues.add(id(lvalue))
for name in dir(n):
if name.startswith("__") or name in _NON_SYNTACTIC_ATTRS:
continue
try:
val = getattr(n, name)
except Exception:
continue
if callable(val):
continue
if isinstance(val, (Node, list, tuple)):
stack.append(val)
elif isinstance(n, (list, tuple)):
stack.extend(n)
return exprs, skip_lvalues
def find_any_in_tree(tree: Node, idmap: dict[int, Type]) -> list[tuple[int, int, str]]:
exprs, skip_lvalues = _walk_file(tree)
findings: list[tuple[int, int, str]] = []
for expr in exprs:
# A TempNode is mypy's synthetic placeholder for a position with no real
# expression -- e.g. the rvalue of an annotation-only `field: T` in a
# TypedDict / class body, whose `special_form` `Any` is not a value the
# author wrote. It never corresponds to a runtime value, so skip it.
if id(expr) in skip_lvalues or isinstance(expr, TempNode):
continue
t = idmap.get(id(expr))
if t is not None and contains_any(t):
findings.append((expr.line, expr.column, str(get_proper_type(t))))
out: list[tuple[int, int, str]] = []
seen_pos: set[tuple[int, int]] = set()
for line, col, typ in sorted(findings):
if line < 1 or (line, col) in seen_pos:
continue
seen_pos.add((line, col))
out.append((line, col, typ))
return out
# --------------------------------------------------------------------------- #
# Comment scanning (LIT005 + any-ok suppression)
# --------------------------------------------------------------------------- #
def _reason_ok(reason: str | None) -> bool:
return reason is not None and len(reason.strip()) >= MIN_REASON_LEN
def scan_any_ok(
path: Path, source: str
) -> tuple[frozenset[int], tuple[Violation, ...]]:
"""Return (lines with a valid any-ok suppression, LIT005 violations)."""
try:
tokens = tokenize.generate_tokens(
iter(source.splitlines(keepends=True)).__next__
)
comments = tuple(
(t.start[0], t.string) for t in tokens if t.type == tokenize.COMMENT
)
except tokenize.TokenError:
return frozenset(), ()
ok_lines: set[int] = set()
violations: list[Violation] = []
for line, text in comments:
m = ANY_OK_RE.search(text)
if m is None:
continue
if _reason_ok(m.group("reason")):
ok_lines.add(line)
else:
violations.append(
Violation(
path,
line,
0,
"LIT005",
"any-ok requires a reason: `# any-ok: <reason>`",
)
)
return frozenset(ok_lines), tuple(violations)
# --------------------------------------------------------------------------- #
# mypy build (parity with `make lint-mypy`) + forced target re-check
# --------------------------------------------------------------------------- #
def _build_options() -> Options:
opts = Options()
if MYPY_INI.exists():
parse_config_file(opts, lambda: None, str(MYPY_INI), sys.stdout, sys.stderr)
opts.export_types = True
opts.preserve_asts = True
opts.incremental = True
opts.cache_dir = str(CACHE_DIR)
opts.show_traceback = False
return opts
def _meta_path(module: str) -> Path:
return CACHE_DIR / PY_TAG / (module.replace(".", os.sep) + ".meta.json")
def _force_recheck(sources: Sequence[BuildSource]) -> None:
"""Invalidate each target's cached entry so mypy re-type-checks (and thus
re-exports types + preserves the AST for) exactly these modules, while their
dependencies stay warm. A missing entry is a cold build for that module.
mypy trusts a cache entry whenever the source mtime matches the cached one
(it never re-hashes on that fast path), so we must break BOTH: zero the
cached mtime to force a re-hash, and corrupt the cached hash so the re-hash
mismatches and the module is treated as changed."""
for src in sources:
if not src.module:
continue
meta = _meta_path(src.module)
if not meta.exists():
continue
try:
data = json.loads(meta.read_text())
data["hash"] = "0" * 40
data["mtime"] = 0
meta.write_text(json.dumps(data))
except (OSError, ValueError):
continue
def check_files(rel_paths: Sequence[str]) -> tuple[Violation, ...]:
"""`rel_paths` are relative to the litellm package dir (the build cwd)."""
prev_cwd = Path.cwd()
os.chdir(LITELLM_DIR)
try:
opts = _build_options()
fscache = FileSystemCache()
sources = create_source_list(list(rel_paths), opts, fscache)
_force_recheck(sources)
try:
res = build.build(sources, options=opts, fscache=fscache)
except build.CompileError as exc:
joined = "; ".join(exc.messages[:3]) or "blocking error"
return (
Violation(
Path(rel_paths[0]),
0,
0,
"LIT000",
f"mypy could not build: {joined}",
),
)
idmap = {id(expr): t for expr, t in res.types.items()}
# Resolve trees to absolute source paths while cwd is the build dir, since
# mypy stores the paths it was given (relative to this cwd).
trees: dict[str, Node] = {}
for state in res.graph.values():
if state.path and state.tree is not None:
trees[os.path.realpath(state.path)] = state.tree
finally:
os.chdir(prev_cwd)
out: list[Violation] = []
for rel in rel_paths:
abs_path = (LITELLM_DIR / rel).resolve()
report_path = abs_path.relative_to(REPO_ROOT)
if _is_boundary(report_path):
continue
try:
source = abs_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
out.append(
Violation(report_path, 0, 0, "LIT000", f"could not read file: {exc}")
)
continue
ok_lines, ok_violations = scan_any_ok(report_path, source)
out.extend(ok_violations)
tree = trees.get(os.path.realpath(abs_path))
if tree is None:
continue
for line, col, typ in find_any_in_tree(tree, idmap):
if line in ok_lines:
continue
out.append(
Violation(
report_path,
line,
col,
"LIT009",
f"value type contains Any -> {typ}",
)
)
return tuple(out)
# --------------------------------------------------------------------------- #
# File selection (changed-only, changed-lines) + driver
# --------------------------------------------------------------------------- #
class _AllLines:
"""Sentinel: a wholly new / untracked file -- every line is in scope.
A distinct object, not None, so that `line_map.get(path)` returning None for
a path absent from the map is never mistaken for "whole file in scope"."""
# A changed file's in-scope lines: a specific set, or every line.
LineScope = set[int] | _AllLines
ALL_LINES = _AllLines()
def _is_boundary(path: Path) -> bool:
posix = path.as_posix()
return any(frag in posix for frag in BOUNDARY_PATHS)
def _git(*args: str) -> list[str]:
result = subprocess.run(
["git", "-C", str(REPO_ROOT), *args],
capture_output=True,
text=True,
check=True,
)
return result.stdout.splitlines()
def _parse_added_lines(diff_text: str) -> dict[str, set[int]]:
"""Map repo-relative path -> set of new-file line numbers the diff adds/edits."""
changed: dict[str, set[int]] = {}
path: str | None = None
for line in diff_text.splitlines():
if line.startswith("+++ b/"):
path = line[6:]
elif path and (m := _HUNK_RE.match(line)):
start = int(m.group(1))
count = int(m.group(2)) if m.group(2) is not None else 1
if count:
changed.setdefault(path, set()).update(range(start, start + count))
return changed
def changed_line_map(base: str) -> dict[str, LineScope] | None:
"""Repo-relative `.py` path under litellm/ -> changed line numbers (or
ALL_LINES for untracked files). Compares the working tree to the merge-base
with `base`, so it covers committed-on-branch + unstaged edits. None if git
is unavailable / not a repo."""
try:
merge_base = _git("merge-base", base, "HEAD")
point = merge_base[0].strip() if merge_base else base
diff = "\n".join(
_git(
"diff",
"--unified=0",
"--no-color",
"--diff-filter=d",
point,
"--",
"litellm",
)
)
untracked = _git("ls-files", "--others", "--exclude-standard", "--", "litellm")
except (subprocess.CalledProcessError, FileNotFoundError):
return None
out: dict[str, LineScope] = {}
for name, lines in _parse_added_lines(diff).items():
if name.endswith(".py") and (REPO_ROOT / name).exists():
out[name] = lines
for name in untracked:
if name.endswith(".py") and (REPO_ROOT / name).exists():
out[name] = ALL_LINES
return out
def _to_litellm_relative(paths: Iterable[Path]) -> list[str]:
rels: list[str] = []
for p in sorted(paths):
try:
rels.append(p.resolve().relative_to(LITELLM_DIR).as_posix())
except ValueError:
continue
return rels
def _in_scope(v: Violation, line_map: dict[str, LineScope] | None) -> bool:
"""A finding survives if line filtering is off (explicit paths), it's a build
error, or its line is one the diff added/edited."""
if line_map is None or v.code == "LIT000":
return True
lines = line_map.get(v.path.as_posix())
return lines is ALL_LINES or (isinstance(lines, set) and v.line in lines)
# --------------------------------------------------------------------------- #
# Per-file Any budget (one-way ratchet, 50% headroom; ratchet-checked)
# --------------------------------------------------------------------------- #
def _slack_for(baseline: int) -> int:
"""50% headroom, rounded up so even a 1-Any file gets a little room."""
return (baseline + 1) // 2
def _ceiling(spec: dict[str, int]) -> int:
"""A file's ceiling: ``baseline + slack`` (0 for an absent/empty entry)."""
return int(spec.get("baseline", 0)) + int(spec.get("slack", 0))
def load_budget() -> dict[str, dict[str, int]]:
"""Read ``any-discipline-budget.json`` ({path: {baseline, slack}}); {} if absent."""
if not BUDGET_PATH.exists():
return {}
try:
data = json.loads(BUDGET_PATH.read_text())
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def save_budget(counts: dict[str, int]) -> None:
"""Write a fresh budget from per-file counts, with 50% headroom each.
Files with zero Any are omitted: an absent entry means baseline 0, so a
file's first Any always trips the gate until it is deliberately baselined."""
budget = {
path: {"baseline": n, "slack": _slack_for(n)}
for path, n in counts.items()
if n > 0
}
BUDGET_PATH.write_text(json.dumps(budget, indent=2, sort_keys=True) + "\n")
def lit009_counts(violations: Iterable[Violation]) -> dict[str, int]:
"""Count LIT009 (Any-typed value) findings per repo-relative file path."""
counts: dict[str, int] = {}
for v in violations:
if v.code == "LIT009":
key = v.path.as_posix()
counts[key] = counts.get(key, 0) + 1
return counts
def all_litellm_py_files() -> list[str] | None:
"""Every tracked ``.py`` under litellm/, as litellm-package-relative paths;
None if git is unavailable / not a repo (mirrors ``changed_line_map``)."""
try:
tracked = _git("ls-files", "--", "litellm")
except (subprocess.CalledProcessError, FileNotFoundError):
return None
return _to_litellm_relative(
REPO_ROOT / name for name in tracked if name.endswith(".py")
)
def update_budget(
list_files: Callable[[], list[str] | None] = all_litellm_py_files,
) -> int:
"""Whole-tree scan: recapture every file's Any count into the budget."""
rel_paths = list_files()
if rel_paths is None:
print(
"check_any_discipline: not a git repository; cannot capture the budget",
file=sys.stderr,
)
return 2
if not rel_paths:
print("check_any_discipline: no litellm/*.py files found", file=sys.stderr)
return 2
violations = check_files(rel_paths)
build_errors = [v for v in violations if v.code == "LIT000"]
if build_errors:
for v in build_errors:
print(v.render(), file=sys.stderr)
print(
"FAIL: mypy could not build the tree; budget left unchanged.",
file=sys.stderr,
)
return 2
counts = lit009_counts(violations)
save_budget(counts)
print(
f"Wrote {BUDGET_PATH.name}: "
f"{sum(1 for n in counts.values() if n > 0)} file(s), "
f"{sum(counts.values())} Any-typed value(s) baselined (50% headroom each)."
)
return 0
def _report_over_budget(
path: str,
count: int,
spec: dict[str, int] | None,
lit009: list[Violation],
line_map: dict[str, LineScope],
) -> None:
"""Print one over-budget file plus the Any findings on its changed lines."""
ceiling = _ceiling(spec or {})
if spec:
why = f"baseline {spec['baseline']} + 50% slack {spec['slack']} = ceiling {ceiling}"
else:
why = "no budget entry -> baseline 0 (a new/unbudgeted file must be Any-free)"
print(f"{path}: {count} Any-typed value(s) total, over budget ({why})")
# Surface the findings on changed lines first: the ones this branch most
# likely just added, and the cheapest path back under the ceiling.
scope = line_map.get(path)
for v in sorted(lit009):
if scope is ALL_LINES or (isinstance(scope, set) and v.line in scope):
print(f" changed-line Any {v.line}:{v.col} {v.message}")
def run_gate(base: str) -> int:
"""Gate changed files under litellm/ against the committed per-file budget."""
line_map = changed_line_map(base)
if line_map is None:
print(
"check_any_discipline: not a git repository; nothing to check",
file=sys.stderr,
)
return 0
rel_paths = _to_litellm_relative((REPO_ROOT / name).resolve() for name in line_map)
if not rel_paths:
print("OK: no changed Python files under litellm/ to check")
return 0
violations = check_files(rel_paths)
budget = load_budget()
# Hard rules, independent of the budget: a build/read failure (always), and a
# reasonless `# any-ok` on a line this branch touched.
hard = sorted(
v
for v in violations
if v.code == "LIT000" or (v.code == "LIT005" and _in_scope(v, line_map))
)
# Per-file Any budget: a changed file fails when its total Any count exceeds
# its ceiling. Unchanged files keep their committed baseline (never re-scanned).
counts = lit009_counts(violations)
lit009_by_file: dict[str, list[Violation]] = {}
for v in violations:
if v.code == "LIT009":
lit009_by_file.setdefault(v.path.as_posix(), []).append(v)
over_budget = [
(path, count)
for path, count in sorted(counts.items())
if count > _ceiling(budget.get(path, {}))
]
if not hard and not over_budget:
print(
f"OK: {len(rel_paths)} changed file(s) under litellm/ are within their Any budget"
)
return 0
for v in hard:
print(v.render())
for path, count in over_budget:
_report_over_budget(
path, count, budget.get(path), lit009_by_file.get(path, []), line_map
)
print(
f"\nFAIL: {len(hard)} hard violation(s), {len(over_budget)} file(s) over their Any budget.\n"
"Give the new values concrete types (validate untyped input with Pydantic) to get back\n"
"under the file's ceiling, or annotate a genuine boundary line `# any-ok: <reason>`.\n"
"Re-baseline with `make lint-any-budget-update` only to lock in a reduction.",
file=sys.stderr,
)
return 1
def spot_check(rel_paths: Sequence[str]) -> int:
"""Explicit-paths mode: report every finding in the files (no budget)."""
violations = sorted(check_files(rel_paths))
for v in violations:
print(v.render())
if violations:
print(f"\nFAIL: {len(violations)} Any-discipline finding(s).", file=sys.stderr)
return 1
print(f"OK: {len(rel_paths)} file(s) have no Any-typed values")
return 0
def main(argv: Sequence[str]) -> int:
parser = argparse.ArgumentParser(
description="Any-discipline gate (changed files, per-file Any budget)."
)
parser.add_argument(
"paths",
nargs="*",
help="explicit files (repo-root relative); whole-file spot-check, no budget",
)
parser.add_argument(
"--changed",
action="store_true",
help="gate changed files under litellm/ vs --base against the per-file budget",
)
parser.add_argument(
"--update",
action="store_true",
help="recapture the whole-tree per-file budget (any-discipline-budget.json)",
)
parser.add_argument("--base", default=os.environ.get("ANY_GATE_BASE", DEFAULT_BASE))
args = parser.parse_args(list(argv))
if args.update:
return update_budget()
if args.changed:
return run_gate(args.base)
if args.paths:
rel_paths = _to_litellm_relative((REPO_ROOT / p).resolve() for p in args.paths)
if not rel_paths:
print("check_any_discipline: no litellm/*.py paths given", file=sys.stderr)
return 2
return spot_check(rel_paths)
parser.error("pass --changed, --update, or explicit file paths")
return 2
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -25,10 +25,8 @@ LIT003 noqa suppression without rule codes or without a reason.
Required shape: `# noqa: TID251 # <reason>`
LIT004 type/pyright/mypy ignore without bracketed codes or without a reason.
Required shape: `# pyright: ignore[reportArgumentType] # <reason>`
LIT005 A `# mutable-ok` / `# cast-ok` / `# guard-ok` / `# kwargs-ok` / `# any-ok`
suppression without a reason. (`any-ok` belongs to check_any_discipline.py;
it is enumerated here so the reason requirement holds even when only this
stdlib checker runs.)
LIT005 A `# mutable-ok` / `# cast-ok` / `# guard-ok` / `# kwargs-ok`
suppression without a reason.
LIT006 `cast(...)` call. typing.cast is an unchecked assertion (the moral equivalent
of TypeScript's `as`); it lies to the type checker with zero runtime guarantee.
Validate into a concrete frozen type at the boundary instead.
@ -41,9 +39,8 @@ LIT008 `**kwargs` parameter. The keyword contract is erased and everything it c
syntax. Declare explicit keyword params, or accept one frozen payload. `*args`,
by contrast, is fine when typed (it's just a tuple). Suppress: `# kwargs-ok: <reason>`.
LIT000 and LIT009 are the sibling Any gate's (check_any_discipline.py, #30379): a mypy
build/read failure and an Any-typed value. They share this LIT namespace but are emitted
by that checker, not this one.
LIT000 Setup failure: a target file could not be read, or contains a syntax error.
Reported as a violation rather than crashing the run.
Usage
-----
@ -105,17 +102,13 @@ MUTABLE_OK_RE = re.compile(r"#\s*mutable-ok(?::\s*(?P<reason>.*))?")
CAST_OK_RE = re.compile(r"#\s*cast-ok(?::\s*(?P<reason>.*))?")
GUARD_OK_RE = re.compile(r"#\s*guard-ok(?::\s*(?P<reason>.*))?")
KWARGS_OK_RE = re.compile(r"#\s*kwargs-ok(?::\s*(?P<reason>.*))?")
ANY_OK_RE = re.compile(r"#\s*any-ok(?::\s*(?P<reason>.*))?")
# Suppression tokens that must each carry a reason (LIT005). `any-ok` is owned by
# check_any_discipline.py but listed here so the reason requirement is enforced even
# when only this stdlib checker runs.
# Suppression tokens that must each carry a reason (LIT005).
OK_SUPPRESSIONS: tuple[tuple[str, re.Pattern[str]], ...] = (
("mutable-ok", MUTABLE_OK_RE),
("cast-ok", CAST_OK_RE),
("guard-ok", GUARD_OK_RE),
("kwargs-ok", KWARGS_OK_RE),
("any-ok", ANY_OK_RE),
)

View file

@ -1,47 +1,38 @@
#!/usr/bin/env python3
"""Per-rule count gate for mypy and basedpyright.
"""Per-rule count gate for basedpyright.
Each tool's output is reduced to a count of errors per *rule* (mypy error codes
like ``arg-type``, basedpyright rules like ``reportAny``) and checked against a
committed budget of the form ``{rule: {baseline, slack}}``, the same shape as
basedpyright's ``--outputjson`` is reduced to a count of errors per *rule*
(``reportAny``, ``reportArgumentType``, ...) and checked against a committed
budget of the form ``{rule: {baseline, slack}}``, the same shape as
``ruff-strict-budget.json``. A rule fails when its codebase-wide total exceeds
``baseline + slack``. Counts ignore file, line, and column, so a violation
moving anywhere in the tree is invisible; only the per-rule total moves the
needle.
Unlike ``ruff_strict_gate.py`` this does *not* re-run the tool on the merge base
to compute a delta: a second mypy/basedpyright pass is minutes and gigabytes,
whereas ruff is milliseconds. The committed budget is the baseline instead --
exactly how the previous per-file gate worked -- so keep it fresh with
``--update`` (ratchet), which re-captures every rule's count from the current
tree while preserving each rule's slack. Tool output is read from stdin, so the
caller decides how to invoke the tool (and from which cwd).
to compute a delta: a second basedpyright pass is minutes and gigabytes, whereas
ruff is milliseconds. The committed budget is the baseline instead -- exactly
how the previous per-file gate worked -- so keep it fresh with ``--update``
(ratchet), which re-captures every rule's count from the current tree while
preserving each rule's slack. Tool output is read from stdin, so the caller
decides how to invoke basedpyright (and from which cwd).
mypy is parsed from its text output (one error per line, the rule code in a
trailing ``[bracket]``). basedpyright is parsed from ``--outputjson``: its text
diagnostics routinely wrap across lines, leaving the ``(reportRule)`` on a
continuation line away from the ``- error:`` marker, so line parsing
mis-attributes ~60% of errors -- the JSON carries an unambiguous ``rule`` field.
``--outputjson`` is used rather than text diagnostics because the latter wrap
across lines, leaving the ``(reportRule)`` on a continuation line away from the
``- error:`` marker, so line parsing mis-attributes ~60% of errors -- the JSON
carries an unambiguous ``rule`` field.
"""
import argparse
import json
import re
import sys
from collections import Counter
from pathlib import Path
from typing import Iterable, Mapping, NamedTuple
from typing import Mapping, NamedTuple
REPO_ROOT = Path(__file__).resolve().parent.parent
# mypy: one error per line, e.g. `path:12: error: msg [arg-type]`. ERROR_LINE
# recognizes the line; MYPY_CODE pulls the trailing [code]. Kept separate so an
# error emitted without a code is still counted (under UNCODED), never dropped.
MYPY_ERROR = re.compile(r"^(?P<file>.+?):\d+: error:")
MYPY_CODE = re.compile(r"\[(?P<code>[a-z][a-z0-9-]*)\]\s*$")
# Bucket for an error whose rule code we couldn't read (a mypy error with no
# code, or a basedpyright diagnostic with no `rule`). Counted so it's gated.
# Bucket for a basedpyright diagnostic with no `rule`. Counted so it's gated.
UNCODED = "<uncoded>"
# Ceiling for a rule that shows up at HEAD but isn't in the budget at all -- a
@ -72,20 +63,6 @@ def _to_repo_relative(raw: str) -> str | None:
return None
def count_mypy(lines: Iterable[str]) -> dict[str, int]:
"""Count in-repo mypy errors per rule code from text output. Errors for
files outside the repo (third-party stubs) are ignored, as before."""
counts: Counter[str] = Counter()
for raw in lines:
line = raw.rstrip("\n")
match = MYPY_ERROR.match(line)
if match is None or _to_repo_relative(match.group("file")) is None:
continue
code = MYPY_CODE.search(line)
counts[code.group("code") if code else UNCODED] += 1
return dict(counts)
def count_basedpyright(payload: str) -> dict[str, int]:
"""Count in-repo basedpyright errors per rule from `--outputjson`. Warnings
and information are ignored; only `severity == "error"` is gated."""
@ -108,12 +85,6 @@ def count_basedpyright(payload: str) -> dict[str, int]:
return dict(counts)
def count_errors(stdin_text: str, tool: str) -> dict[str, int]:
if tool == "basedpyright":
return count_basedpyright(stdin_text)
return count_mypy(stdin_text.splitlines())
def evaluate(
counts: Mapping[str, int], budget: Mapping[str, Mapping[str, int]]
) -> list[Breach]:
@ -136,13 +107,11 @@ def is_vacuous_run(
return not counts and any(spec["baseline"] for spec in budget.values())
def budget_path(tool: str) -> Path:
return REPO_ROOT / f"{tool}-code-budget.json"
BUDGET_PATH = REPO_ROOT / "basedpyright-code-budget.json"
def cmd_update(tool: str, counts: Mapping[str, int]) -> None:
path = budget_path(tool)
existing = json.loads(path.read_text()) if path.exists() else {}
def cmd_update(counts: Mapping[str, int]) -> None:
existing = json.loads(BUDGET_PATH.read_text()) if BUDGET_PATH.exists() else {}
budget = {
code: {
"baseline": count,
@ -152,18 +121,18 @@ def cmd_update(tool: str, counts: Mapping[str, int]) -> None:
}
for code, count in sorted(counts.items())
}
path.write_text(json.dumps(budget, indent=2, sort_keys=True) + "\n")
BUDGET_PATH.write_text(json.dumps(budget, indent=2, sort_keys=True) + "\n")
print(
f"Re-captured {tool} per-rule budget: {len(budget)} rules, {sum(counts.values())} errors total"
f"Re-captured basedpyright per-rule budget: {len(budget)} rules, {sum(counts.values())} errors total"
)
def cmd_check(tool: str, counts: Mapping[str, int]) -> None:
budget = json.loads(budget_path(tool).read_text())
def cmd_check(counts: Mapping[str, int]) -> None:
budget = json.loads(BUDGET_PATH.read_text())
if is_vacuous_run(counts, budget):
expected = sum(spec["baseline"] for spec in budget.values())
print(
f"FAIL: {tool} produced no errors, but {budget_path(tool).name} expects "
f"FAIL: basedpyright produced no errors, but {BUDGET_PATH.name} expects "
f"~{expected}. The type checker almost certainly crashed or emitted "
f"nothing; refusing to certify a vacuous run."
)
@ -171,25 +140,24 @@ def cmd_check(tool: str, counts: Mapping[str, int]) -> None:
breaches = evaluate(counts, budget)
if not breaches:
print(
f"OK: every rule is within its {tool} ceiling ({sum(counts.values())} errors total)"
f"OK: every rule is within its basedpyright ceiling ({sum(counts.values())} errors total)"
)
return
print(f"FAIL: {tool} errors exceed the per-rule ceiling:")
print("FAIL: basedpyright errors exceed the per-rule ceiling:")
for breach in breaches:
print(f" {breach.code}: {breach.total} errors over cap {breach.cap}")
print(
f"Resolve the new errors, or run 'make lint-{tool}-budget-update' if the ceiling should move."
"Resolve the new errors, or run 'make lint-basedpyright-budget-update' if the ceiling should move."
)
raise SystemExit(1)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--tool", choices=("mypy", "basedpyright"), required=True)
parser.add_argument("--update", action="store_true")
args = parser.parse_args()
counts = count_errors(sys.stdin.read(), args.tool)
cmd_update(args.tool, counts) if args.update else cmd_check(args.tool, counts)
counts = count_basedpyright(sys.stdin.read())
cmd_update(counts) if args.update else cmd_check(counts)
if __name__ == "__main__":

View file

@ -906,7 +906,10 @@ class BaseLLMChatTest(ABC):
{
"type": "image_url",
"image_url": {
"url": "https://www.gstatic.com/webp/gallery/1.webp",
# sha-pinned in-repo logo via jsdelivr; gstatic's
# robots.txt blocks server-side fetchers (e.g.
# Anthropic), which 400s the request.
"url": "https://cdn.jsdelivr.net/gh/BerriAI/litellm@d769e81c90d453240c61fc572cdb27fae06a89d0/ui/litellm-dashboard/public/assets/logos/litellm_logo.jpg",
"detail": detail,
},
},

View file

@ -2,6 +2,7 @@
"""
Test OpenAI Moderation Guardrail
"""
import os
import sys
@ -822,6 +823,108 @@ def test_openai_moderation_process_error_metadata_none_edge_case():
assert "_openai_moderation_response" not in request_data["metadata"]
@pytest.mark.asyncio
async def test_openai_moderation_logs_violation_categories_harmful_content():
"""Flagged content surfaces only the violated category names in
StandardLoggingGuardrailInformation.violation_categories, so OTEL can index
a short ``guardrail_violation_categories`` attribute instead of the full
response blob (LIT-3801)."""
from fastapi import HTTPException
from litellm.types.utils import GenericGuardrailAPIInputs
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
guardrail = OpenAIModerationGuardrail(guardrail_name="test-openai-moderation")
mock_response = OpenAIModerationResponse(
id="modr-violations",
model="omni-moderation-latest",
results=[
OpenAIModerationResult(
flagged=True,
categories={
"sexual": False,
"hate": False,
"self-harm": True,
"self-harm/intent": True,
"violence": True,
},
category_scores={
"sexual": 0.0001,
"hate": 0.0001,
"self-harm": 0.97,
"self-harm/intent": 0.98,
"violence": 0.35,
},
category_applied_input_types={},
)
],
)
with patch.object(guardrail, "async_make_request", return_value=mock_response):
request_data = {"metadata": {}}
with pytest.raises(HTTPException):
await guardrail.apply_guardrail(
inputs=GenericGuardrailAPIInputs(
structured_messages=[{"role": "user", "content": "harmful"}]
),
request_data=request_data,
input_type="request",
)
info = request_data["metadata"]["standard_logging_guardrail_information"][0]
# Only the flagged categories, never the unflagged ones or the scores
assert info["violation_categories"] == [
"self-harm",
"self-harm/intent",
"violence",
]
@pytest.mark.asyncio
async def test_openai_moderation_no_violation_categories_safe_content():
"""Safe content carries no violation_categories key, so the short attribute
is absent rather than empty on allowed requests (LIT-3801)."""
from litellm.types.utils import GenericGuardrailAPIInputs
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
guardrail = OpenAIModerationGuardrail(guardrail_name="test-openai-moderation")
mock_response = OpenAIModerationResponse(
id="modr-safe",
model="omni-moderation-latest",
results=[
OpenAIModerationResult(
flagged=False,
categories={"hate": False, "violence": False},
category_scores={"hate": 0.001, "violence": 0.002},
category_applied_input_types={},
)
],
)
with patch.object(guardrail, "async_make_request", return_value=mock_response):
request_data = {"metadata": {}}
await guardrail.apply_guardrail(
inputs=GenericGuardrailAPIInputs(
structured_messages=[{"role": "user", "content": "hi"}]
),
request_data=request_data,
input_type="request",
)
info = request_data["metadata"]["standard_logging_guardrail_information"][0]
assert "violation_categories" not in info
def test_openai_moderation_build_tracing_detail_non_dict_responses():
"""Non-dict guardrail responses (the "allow" sentinel, a raw Exception) yield
no tracing detail so logging never crashes when no moderation call ran."""
assert OpenAIModerationGuardrail._build_tracing_detail("allow") is None
assert OpenAIModerationGuardrail._build_tracing_detail(ValueError("boom")) is None
@pytest.mark.asyncio
async def test_openai_moderation_guardrail_streaming_defaults():
"""Defaults match the unified dispatcher: sampled in-stream, every 5th chunk."""

View file

@ -2188,3 +2188,329 @@ def test_require_managed_files_accepts_repeated_target_model_names_bracket_form(
assert response.status_code == 200, response.text
assert response.json()["id"] == "litellm_managed_file_repeated"
assert received_target_model_names == ["azure-gpt-3-5-turbo", "gpt-3.5-turbo"]
def test_list_files_resolves_wildcard_deployment_credentials(
mocker: MockerFixture, monkeypatch
):
"""
GET /v1/files?target_model_names=<model> must resolve the upstream api_key
from the matching (wildcard) deployment. Regression for the path routing
through llm_router.afile_list(model=...), which reached OpenAI without an
api_key and failed with "api_key client option must be set".
"""
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
wildcard_router = Router(
model_list=[
{
"model_name": "*",
"litellm_params": {
"model": "openai/*",
"api_key": "wildcard-openai-key",
},
},
]
)
proxy_logging_obj = setup_proxy_logging_object(monkeypatch, wildcard_router)
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", wildcard_router)
proxy_logging_obj.update_request_status = mocker.AsyncMock()
proxy_logging_obj.post_call_success_hook = mocker.AsyncMock(return_value=[])
proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock()
captured_kwargs: dict = {}
async def _mock_afile_list(**kwargs):
captured_kwargs.update(kwargs)
return []
monkeypatch.setattr(litellm, "afile_list", _mock_afile_list)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
api_key="test-key",
user_role=LitellmUserRoles.PROXY_ADMIN,
user_id="test-user",
)
try:
response = client.get(
"/v1/files?target_model_names=gpt-4o",
headers={"Authorization": "Bearer test-key"},
)
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
assert response.status_code == 200, response.text
assert captured_kwargs.get("api_key") == "wildcard-openai-key"
assert captured_kwargs.get("custom_llm_provider") == "openai"
proxy_logging_obj.post_call_failure_hook.assert_not_called()
def test_list_files_without_target_model_names_uses_team_openai_deployment(
mocker: MockerFixture, monkeypatch
):
"""
Plain GET /v1/files (no target_model_names) must resolve the upstream openai
api_key from the team's openai deployment instead of falling through to a
keyless OpenAI client. Regression for "api_key client option must be set".
"""
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
wildcard_router = Router(
model_list=[
{
"model_name": "openai/*",
"litellm_params": {
"model": "openai/*",
"api_key": "team-openai-key",
},
},
]
)
proxy_logging_obj = setup_proxy_logging_object(monkeypatch, wildcard_router)
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", wildcard_router)
proxy_logging_obj.update_request_status = mocker.AsyncMock()
proxy_logging_obj.post_call_success_hook = mocker.AsyncMock(return_value=[])
proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock()
captured_kwargs: dict = {}
async def _mock_afile_list(**kwargs):
captured_kwargs.update(kwargs)
return []
monkeypatch.setattr(litellm, "afile_list", _mock_afile_list)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
api_key="test-key",
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="test-user",
team_id="test-team",
team_models=["openai/*"],
)
try:
response = client.get(
"/v1/files",
headers={"Authorization": "Bearer test-key"},
)
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
assert response.status_code == 200, response.text
assert captured_kwargs.get("api_key") == "team-openai-key"
assert captured_kwargs.get("custom_llm_provider") == "openai"
proxy_logging_obj.post_call_failure_hook.assert_not_called()
def test_list_files_restricted_team_does_not_leak_global_openai_credentials(
mocker: MockerFixture, monkeypatch
):
"""
A team whose allowlist only grants anthropic must NOT resolve a global
openai deployment's api_key for plain GET /v1/files. Regression for the
last-resort scan that ignored team access control.
"""
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
router = Router(
model_list=[
{
"model_name": "openai/*",
"litellm_params": {
"model": "openai/*",
"api_key": "global-openai-key",
},
},
{
"model_name": "claude-opus-4-6",
"litellm_params": {
"model": "anthropic/claude-opus-4-6",
"api_key": "anthropic-key",
},
},
]
)
proxy_logging_obj = setup_proxy_logging_object(monkeypatch, router)
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", router)
proxy_logging_obj.update_request_status = mocker.AsyncMock()
proxy_logging_obj.post_call_success_hook = mocker.AsyncMock(return_value=[])
proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock()
captured_kwargs: dict = {}
async def _mock_afile_list(**kwargs):
captured_kwargs.update(kwargs)
return []
monkeypatch.setattr(litellm, "afile_list", _mock_afile_list)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
api_key="test-key",
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="test-user",
team_id="anthropic-only-team",
team_models=["claude-opus-4-6"],
)
try:
response = client.get(
"/v1/files",
headers={"Authorization": "Bearer test-key"},
)
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
assert response.status_code == 200, response.text
assert captured_kwargs.get("api_key") != "global-openai-key"
def test_list_files_prefers_team_byok_over_global_openai_deployment(
mocker: MockerFixture, monkeypatch
):
"""
When a team has its own BYOK openai deployment (model_info.team_id set), plain
GET /v1/files must use the team's key, not a shared/global openai deployment.
"""
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
router = Router(
model_list=[
{
"model_name": "openai/*",
"litellm_params": {
"model": "openai/*",
"api_key": "global-openai-key",
},
},
{
"model_name": "team-gpt-4o",
"litellm_params": {
"model": "openai/gpt-4o",
"api_key": "team-byok-openai-key",
},
"model_info": {
"id": "team-byok-deployment-id",
"team_id": "test-team",
"team_public_model_name": "team-gpt-4o",
},
},
]
)
proxy_logging_obj = setup_proxy_logging_object(monkeypatch, router)
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", router)
proxy_logging_obj.update_request_status = mocker.AsyncMock()
proxy_logging_obj.post_call_success_hook = mocker.AsyncMock(return_value=[])
proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock()
captured_kwargs: dict = {}
async def _mock_afile_list(**kwargs):
captured_kwargs.update(kwargs)
return []
monkeypatch.setattr(litellm, "afile_list", _mock_afile_list)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
api_key="test-key",
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="test-user",
team_id="test-team",
team_models=["team-gpt-4o"],
)
try:
response = client.get(
"/v1/files",
headers={"Authorization": "Bearer test-key"},
)
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
assert response.status_code == 200, response.text
assert captured_kwargs.get("api_key") == "team-byok-openai-key"
assert captured_kwargs.get("custom_llm_provider") == "openai"
proxy_logging_obj.post_call_failure_hook.assert_not_called()
def test_list_files_with_all_proxy_models_team_uses_openai_deployment(
mocker: MockerFixture, monkeypatch
):
"""
Teams with all-proxy-models (or empty models) must still resolve openai
credentials for plain GET /v1/files.
"""
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import LitellmUserRoles, SpecialModelNames
wildcard_router = Router(
model_list=[
{
"model_name": "openai/*",
"litellm_params": {
"model": "openai/*",
"api_key": "team-openai-key",
},
},
{
"model_name": "claude-opus-4-6",
"litellm_params": {
"model": "anthropic/claude-opus-4-6",
"api_key": "anthropic-key",
},
},
]
)
proxy_logging_obj = setup_proxy_logging_object(monkeypatch, wildcard_router)
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", wildcard_router)
proxy_logging_obj.update_request_status = mocker.AsyncMock()
proxy_logging_obj.post_call_success_hook = mocker.AsyncMock(return_value=[])
proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock()
captured_kwargs: dict = {}
async def _mock_afile_list(**kwargs):
captured_kwargs.update(kwargs)
return []
monkeypatch.setattr(litellm, "afile_list", _mock_afile_list)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
api_key="test-key",
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="test-user",
team_id="test-team",
team_models=[SpecialModelNames.all_proxy_models.value],
)
try:
response = client.get(
"/v1/files",
headers={"Authorization": "Bearer test-key"},
)
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
assert response.status_code == 200, response.text
assert captured_kwargs.get("api_key") == "team-openai-key"
assert captured_kwargs.get("custom_llm_provider") == "openai"
proxy_logging_obj.post_call_failure_hook.assert_not_called()

View file

@ -15,6 +15,7 @@ import pytest
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.common_utils.model_listing_utils import TeamModelNameTranslator
from litellm.proxy.proxy_server import (
_get_proxy_model_info,
_translate_model_name_for_response,
@ -593,3 +594,664 @@ async def test_model_info_v1_litellm_model_id_team_id_applies_team_filter(monkey
team_filter.assert_awaited_once()
assert team_filter.await_args.kwargs["team_id"] == "other-team"
assert team_filter.await_args.kwargs["all_models"] == [team_row]
@pytest.mark.asyncio
async def test_v1_models_translates_team_model_for_access_group_key(monkeypatch):
"""Regression (#28382 sibling leak): a virtual key whose model access group
resolves to a team BYOK deployment must list the PUBLIC name in /v1/models,
not the internal routing key model_name_{team_id}_{uuid}.
The /model/info read-path fix did not cover /v1/models, which builds from
bare model-name strings via access-group expansion.
"""
team_dep = {
"model_name": "model_name_teamX_uuid9",
"litellm_params": {"model": "azure/gpt-4.1"},
"model_info": {
"id": "id1",
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
"access_groups": ["grp-a"],
},
}
router = MagicMock()
router.get_model_names.return_value = ["model_name_teamX_uuid9"]
router.get_model_access_groups.return_value = {"grp-a": ["model_name_teamX_uuid9"]}
router.get_fully_blocked_model_names.return_value = set()
router.model_list = [team_dep]
router.get_model_list.return_value = [team_dep]
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "user_model", None)
# Default behavior: listing surfaces public names.
monkeypatch.setattr(ps, "general_settings", {})
# virtual key granted access via the access group (no team membership)
key = UserAPIKeyAuth(
user_id="u", api_key="sk-test", models=["grp-a"], team_models=[]
)
resp = await ps.model_list(user_api_key_dict=key)
ids = [d["id"] for d in resp["data"]]
assert "tushar-gpt-4.1" in ids
assert "model_name_teamX_uuid9" not in ids
@pytest.mark.asyncio
async def test_v1_models_keeps_internal_names_when_public_name_flag_disabled(
monkeypatch,
):
"""Compatibility override: /v1/models can still list the internal routing
name for consumers that scripted against those ids. Translation is enabled
by default and disabled via general_settings['use_team_public_model_name'].
"""
team_dep = {
"model_name": "model_name_teamX_uuid9",
"litellm_params": {"model": "azure/gpt-4.1"},
"model_info": {
"id": "id1",
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
"access_groups": ["grp-a"],
},
}
router = MagicMock()
router.get_model_names.return_value = ["model_name_teamX_uuid9"]
router.get_model_access_groups.return_value = {"grp-a": ["model_name_teamX_uuid9"]}
router.get_fully_blocked_model_names.return_value = set()
router.model_list = [team_dep]
router.get_model_list.return_value = [team_dep]
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "general_settings", {"use_team_public_model_name": False})
key = UserAPIKeyAuth(
user_id="u", api_key="sk-test", models=["grp-a"], team_models=[]
)
resp = await ps.model_list(user_api_key_dict=key)
ids = [d["id"] for d in resp["data"]]
assert "model_name_teamX_uuid9" in ids # internal id preserved (backward-compat)
assert "tushar-gpt-4.1" not in ids
@pytest.mark.asyncio
async def test_v1_models_translates_team_model_with_metadata(monkeypatch):
"""include_metadata=true must build metadata for the public model id."""
team_dep = {
"model_name": "model_name_teamX_uuid9",
"litellm_params": {"model": "azure/gpt-4.1"},
"model_info": {
"id": "id1",
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
"access_groups": ["grp-a"],
},
}
router = MagicMock()
router.get_model_names.return_value = ["model_name_teamX_uuid9"]
router.get_model_access_groups.return_value = {"grp-a": ["model_name_teamX_uuid9"]}
router.get_fully_blocked_model_names.return_value = set()
router.model_list = [team_dep]
router.get_model_list.return_value = [team_dep]
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "general_settings", {})
key = UserAPIKeyAuth(
user_id="u", api_key="sk-test", models=["grp-a"], team_models=[]
)
resp = await ps.model_list(user_api_key_dict=key, include_metadata=True)
assert resp["data"] == [
{
"id": "tushar-gpt-4.1",
"object": "model",
"created": 1677610602,
"owned_by": "openai",
"metadata": {"fallbacks": []},
}
]
@pytest.mark.asyncio
async def test_v1_models_metadata_fallbacks_use_internal_routing_key(monkeypatch):
"""Regression: with include_metadata=true, fallbacks configured for a team
model under its internal routing key must still surface. The metadata lookup
has to run against the internal name, not the translated public name (which
the router's fallback config never keys on) -- otherwise fallbacks silently
drop to []."""
team_dep = {
"model_name": "model_name_teamX_uuid9",
"litellm_params": {"model": "azure/gpt-4.1"},
"model_info": {
"id": "id1",
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
"access_groups": ["grp-a"],
},
}
router = MagicMock()
router.get_model_names.return_value = ["model_name_teamX_uuid9"]
router.get_model_access_groups.return_value = {"grp-a": ["model_name_teamX_uuid9"]}
router.get_fully_blocked_model_names.return_value = set()
router.model_list = [team_dep]
router.get_model_list.return_value = [team_dep]
# Fallbacks are keyed on the internal routing name, as the router stores them.
router.fallbacks = [{"model_name_teamX_uuid9": ["gpt-4o-backup"]}]
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "general_settings", {})
key = UserAPIKeyAuth(
user_id="u", api_key="sk-test", models=["grp-a"], team_models=[]
)
resp = await ps.model_list(user_api_key_dict=key, include_metadata=True)
assert resp["data"] == [
{
"id": "tushar-gpt-4.1",
"object": "model",
"created": 1677610602,
"owned_by": "openai",
"metadata": {"fallbacks": ["gpt-4o-backup"]},
}
]
@pytest.mark.asyncio
async def test_v1_models_metadata_does_not_leak_other_team_fallbacks(monkeypatch):
"""Regression: two teams can publish the same team_public_model_name. With
include_metadata=true a caller scoped to teamX must see teamX's fallbacks for
the shared public name, never teamY's. The metadata lookup has to stay within
the caller's accessible models; resolving the public name through a router-wide
reverse map could point it at another team's internal routing key."""
team_x = {
"model_name": "model_name_teamX_uuid9",
"litellm_params": {"model": "azure/gpt-4.1"},
"model_info": {
"id": "idX",
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
"access_groups": ["grp-a"],
},
}
team_y = {
"model_name": "model_name_teamY_uuidZ",
"litellm_params": {"model": "azure/gpt-4.1"},
"model_info": {
"id": "idY",
"team_id": "teamY",
"team_public_model_name": "tushar-gpt-4.1", # same public name, other team
},
}
router = MagicMock()
router.get_model_names.return_value = ["model_name_teamX_uuid9"]
router.get_model_access_groups.return_value = {"grp-a": ["model_name_teamX_uuid9"]}
router.get_fully_blocked_model_names.return_value = set()
router.model_list = [team_x, team_y]
router.get_model_list.return_value = [team_x, team_y]
router.fallbacks = [
{"model_name_teamX_uuid9": ["teamX-backup"]},
{"model_name_teamY_uuidZ": ["teamY-backup"]},
]
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "general_settings", {})
key = UserAPIKeyAuth(
user_id="u", api_key="sk-test", models=["grp-a"], team_models=[]
)
resp = await ps.model_list(user_api_key_dict=key, include_metadata=True)
assert resp["data"] == [
{
"id": "tushar-gpt-4.1",
"object": "model",
"created": 1677610602,
"owned_by": "openai",
"metadata": {"fallbacks": ["teamX-backup"]},
}
]
def test_translate_team_model_names_for_listing_swaps_and_dedupes():
"""Internal team routing keys -> public name; sibling deployments sharing a
public name collapse to one entry (order preserved); globals untouched."""
router = MagicMock()
router.get_model_list.return_value = [
{
"model_name": "model_name_teamX_uuidA",
"model_info": {
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
},
},
{
"model_name": "model_name_teamX_uuidB", # sibling: same public name
"model_info": {
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
},
},
{"model_name": "gpt-4o", "model_info": {"db_model": False}},
]
out = TeamModelNameTranslator.translate_listing(
["model_name_teamX_uuidA", "model_name_teamX_uuidB", "gpt-4o"],
router,
{},
)
assert out == ["tushar-gpt-4.1", "gpt-4o"]
def test_listing_entries_keep_internal_lookup_id_for_team_rows():
"""`listing_entries` returns (public response id, internal lookup id) so the
response shows the public name while metadata lookups keep the routing key.
Sibling deployments collapse to one entry; globals map to themselves."""
router = MagicMock()
router.get_model_list.return_value = [
{
"model_name": "model_name_teamX_uuidA",
"model_info": {
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
},
},
{
"model_name": "model_name_teamX_uuidB", # sibling: same public name
"model_info": {
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
},
},
{"model_name": "gpt-4o", "model_info": {"db_model": False}},
]
entries = TeamModelNameTranslator.listing_entries(
["model_name_teamX_uuidA", "model_name_teamX_uuidB", "gpt-4o"],
router,
{},
)
# public id for the client; an internal routing key for the metadata lookup
assert entries[0][0] == "tushar-gpt-4.1"
assert entries[0][1].startswith("model_name_teamX_uuid")
assert entries[1] == ("gpt-4o", "gpt-4o")
assert len(entries) == 2
def test_listing_entries_lookup_id_never_crosses_team_boundary():
"""Regression: when two teams share a team_public_model_name, the lookup id for
the shared public name must stay within the caller's accessible model_names and
never resolve to the other team's internal routing key (which would leak that
team's fallback metadata under include_metadata=true)."""
router = MagicMock()
router.get_model_list.return_value = [
{
"model_name": "model_name_teamX_uuidA",
"model_info": {
"team_id": "teamX",
"team_public_model_name": "shared-name",
},
},
{
"model_name": "model_name_teamY_uuidB", # different team, same public name
"model_info": {
"team_id": "teamY",
"team_public_model_name": "shared-name",
},
},
]
# caller can only access teamX's internal key
entries = TeamModelNameTranslator.listing_entries(
["model_name_teamX_uuidA"], router, {}
)
assert entries == [("shared-name", "model_name_teamX_uuidA")]
def test_listing_entries_global_wins_when_team_alias_collides_with_global():
"""Regression: when an accessible global model shares its name with a team
deployment's `team_public_model_name`, the listing must keep the global
entry rather than overwriting its lookup id with the colliding team's
internal routing key (which would surface the team's metadata under the
global id)."""
router = MagicMock()
router.get_model_list.return_value = [
{
"model_name": "model_name_teamX_uuidA",
"model_info": {
"team_id": "teamX",
"team_public_model_name": "gpt-4o",
},
},
{"model_name": "gpt-4o", "model_info": {"db_model": False}},
]
entries = TeamModelNameTranslator.listing_entries(
["gpt-4o", "model_name_teamX_uuidA"], router, {}
)
assert entries == [("gpt-4o", "gpt-4o")]
def test_listing_and_resolve_agree_on_sibling_internal_key():
"""Regression: when two team deployments share a public name, listing and
retrieve must pick the same internal routing key, otherwise `/v1/models/{id}`
describes a different deployment than what the listing's metadata was built
from."""
router = MagicMock()
router.get_model_list.return_value = [
{
"model_name": "model_name_teamX_uuidA",
"model_info": {
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
},
},
{
"model_name": "model_name_teamX_uuidB",
"model_info": {
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
},
},
]
available = ["model_name_teamX_uuidA", "model_name_teamX_uuidB"]
[(_, listing_lookup)] = TeamModelNameTranslator.listing_entries(
available, router, {}
)
resolve_lookup = TeamModelNameTranslator.resolve_public_name(
model_id="tushar-gpt-4.1",
available_models=available,
llm_router=router,
general_settings={},
)
assert listing_lookup == resolve_lookup
def test_listing_entries_skips_empty_team_public_model_name():
"""Regression: a misconfigured row with `team_public_model_name: ""` must not
produce a listing entry with an empty `id`; the internal routing key should
pass through unchanged, matching `/v1/model/info`'s falsy-check behavior."""
router = MagicMock()
router.get_model_list.return_value = [
{
"model_name": "model_name_teamX_uuidA",
"model_info": {
"team_id": "teamX",
"team_public_model_name": "",
},
},
]
entries = TeamModelNameTranslator.listing_entries(
["model_name_teamX_uuidA"], router, {}
)
assert entries == [("model_name_teamX_uuidA", "model_name_teamX_uuidA")]
def test_listing_entries_passthrough_when_disabled():
"""Legacy flag / no router -> response id equals lookup id (no translation)."""
assert TeamModelNameTranslator.listing_entries(["a", "b"], None, {}) == [
("a", "a"),
("b", "b"),
]
def test_translate_team_model_names_for_listing_leaves_unmapped_names():
"""Names with no team mapping (globals, access-group keys) pass through."""
router = MagicMock()
router.get_model_list.return_value = [
{"model_name": "gpt-4o", "model_info": {"db_model": False}}
]
assert TeamModelNameTranslator.translate_listing(
["gpt-4o", "beta-group"], router, {}
) == ["gpt-4o", "beta-group"]
def test_translate_team_model_names_for_listing_none_router():
"""No router -> return the input list unchanged."""
assert TeamModelNameTranslator.translate_listing(["a", "b"], None, {}) == ["a", "b"]
def test_translate_team_model_names_for_listing_respects_legacy_flag():
"""Operators can keep returning the legacy internal routing key."""
router = MagicMock()
router.get_model_list.return_value = [
{
"model_name": "model_name_teamX_uuidA",
"model_info": {
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
},
}
]
assert TeamModelNameTranslator.translate_listing(
["model_name_teamX_uuidA"], router, {"use_team_public_model_name": False}
) == ["model_name_teamX_uuidA"]
def _public_named_router(*team_rows: dict) -> MagicMock:
router = MagicMock()
router.get_model_list.return_value = list(team_rows)
return router
def test_resolve_public_name_to_internal_routing_key():
"""A public team name resolves back to the internal routing key the router
indexes by, so `GET /v1/models/{public_name}` can find the deployment."""
router = _public_named_router(_team_row())
assert (
TeamModelNameTranslator.resolve_public_name(
model_id="team-claude-sonnet",
available_models=["model_name_team-abc-123_4a6b8"],
llm_router=router,
general_settings={},
)
== "model_name_team-abc-123_4a6b8"
)
def test_resolve_public_name_is_access_scoped_across_teams():
"""Two teams can publish the SAME public name. A caller's query must resolve
to the internal key they can actually access, never another team's."""
# both rows share public name "team-claude-sonnet"
router = _public_named_router(_team_row(), _other_team_row())
# caller only has access to their own team's internal key
resolved = TeamModelNameTranslator.resolve_public_name(
model_id="team-claude-sonnet",
available_models=["model_name_team-abc-123_4a6b8"],
llm_router=router,
general_settings={},
)
assert resolved == "model_name_team-abc-123_4a6b8"
assert resolved != "model_name_team-other_9f2c1"
def test_resolve_public_name_unmapped_passes_through():
"""A public name with no accessible internal mapping is returned unchanged so
the caller hits the normal 404/access path; internal names pass through too."""
router = _public_named_router(_team_row())
# not accessible -> unchanged (downstream validate_model_access will 404)
assert (
TeamModelNameTranslator.resolve_public_name(
model_id="team-claude-sonnet",
available_models=[],
llm_router=router,
general_settings={},
)
== "team-claude-sonnet"
)
# already an internal routing key -> unchanged
assert (
TeamModelNameTranslator.resolve_public_name(
model_id="model_name_team-abc-123_4a6b8",
available_models=["model_name_team-abc-123_4a6b8"],
llm_router=router,
general_settings={},
)
== "model_name_team-abc-123_4a6b8"
)
def test_resolve_public_name_respects_legacy_flag():
"""With the legacy flag set, no public-name resolution happens."""
router = _public_named_router(_team_row())
assert (
TeamModelNameTranslator.resolve_public_name(
model_id="team-claude-sonnet",
available_models=["model_name_team-abc-123_4a6b8"],
llm_router=router,
general_settings={"use_team_public_model_name": False},
)
== "team-claude-sonnet"
)
@pytest.mark.asyncio
async def test_retrieve_model_by_public_name_returns_200(monkeypatch):
"""Regression: `GET /v1/models/{public_name}` must NOT 404. The listing
advertises the public team name, so retrieve must accept the same name,
resolve it to the internal routing key for lookup, and echo the public name
back as the model id."""
import litellm
import litellm.proxy.utils as proxy_utils
team_row = _team_row()
router = _public_named_router(team_row)
deployment = MagicMock()
deployment.litellm_params.model = "azure/gpt-5.2-low-rpm-testing"
router.get_deployment_by_model_group_name.return_value = deployment
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "general_settings", {})
monkeypatch.setattr(
proxy_utils,
"get_available_models_for_user",
AsyncMock(return_value=["model_name_team-abc-123_4a6b8"]),
)
monkeypatch.setattr(
litellm, "get_llm_provider", lambda model: (model, "openai", None, None)
)
key = UserAPIKeyAuth(user_id="u", api_key="sk-test", team_models=[])
resp = await ps.model_info(model_id="team-claude-sonnet", user_api_key_dict=key)
assert resp["id"] == "team-claude-sonnet"
# lookup happened by the internal routing key, not the public name
router.get_deployment_by_model_group_name.assert_called_once_with(
"model_name_team-abc-123_4a6b8"
)
@pytest.mark.asyncio
async def test_retrieve_model_by_internal_name_returns_public_id(monkeypatch):
"""Regression: retrieving by the internal routing key must echo the SAME
public id `/v1/models` advertises for that deployment, not the path. Otherwise
a client iterating the listing's id and then retrieving each one would observe
a different id depending on which alias they queried by."""
import litellm
import litellm.proxy.utils as proxy_utils
router = _public_named_router(_team_row())
deployment = MagicMock()
deployment.litellm_params.model = "azure/gpt-5.2-low-rpm-testing"
router.get_deployment_by_model_group_name.return_value = deployment
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "general_settings", {})
monkeypatch.setattr(
proxy_utils,
"get_available_models_for_user",
AsyncMock(return_value=["model_name_team-abc-123_4a6b8"]),
)
monkeypatch.setattr(
litellm, "get_llm_provider", lambda model: (model, "openai", None, None)
)
key = UserAPIKeyAuth(user_id="u", api_key="sk-test", team_models=[])
resp = await ps.model_info(
model_id="model_name_team-abc-123_4a6b8", user_api_key_dict=key
)
assert resp["id"] == "team-claude-sonnet"
@pytest.mark.asyncio
async def test_retrieve_model_by_internal_name_keeps_internal_id_when_flag_disabled(
monkeypatch,
):
"""With `use_team_public_model_name=false`, retrieve must keep the internal
routing key as the response id, mirroring `/v1/models`' legacy output."""
import litellm
import litellm.proxy.utils as proxy_utils
router = _public_named_router(_team_row())
deployment = MagicMock()
deployment.litellm_params.model = "azure/gpt-5.2-low-rpm-testing"
router.get_deployment_by_model_group_name.return_value = deployment
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "general_settings", {"use_team_public_model_name": False})
monkeypatch.setattr(
proxy_utils,
"get_available_models_for_user",
AsyncMock(return_value=["model_name_team-abc-123_4a6b8"]),
)
monkeypatch.setattr(
litellm, "get_llm_provider", lambda model: (model, "openai", None, None)
)
key = UserAPIKeyAuth(user_id="u", api_key="sk-test", team_models=[])
resp = await ps.model_info(
model_id="model_name_team-abc-123_4a6b8", user_api_key_dict=key
)
assert resp["id"] == "model_name_team-abc-123_4a6b8"
@pytest.mark.asyncio
async def test_retrieve_model_by_inaccessible_public_name_404s(monkeypatch):
"""A caller without access to a team model still gets 404 when retrieving by
its public name; resolution never crosses the access boundary."""
import litellm
import litellm.proxy.utils as proxy_utils
router = _public_named_router(_team_row())
deployment = MagicMock()
deployment.litellm_params.model = "azure/gpt-5.2-low-rpm-testing"
router.get_deployment_by_model_group_name.return_value = deployment
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "general_settings", {})
monkeypatch.setattr(
proxy_utils,
"get_available_models_for_user",
AsyncMock(return_value=[]), # caller has no access
)
monkeypatch.setattr(
litellm, "get_llm_provider", lambda model: (model, "openai", None, None)
)
key = UserAPIKeyAuth(user_id="u", api_key="sk-test", team_models=[])
with pytest.raises(ps.HTTPException) as exc_info:
await ps.model_info(model_id="team-claude-sonnet", user_api_key_dict=key)
assert exc_info.value.status_code == 404
router.get_deployment_by_model_group_name.assert_not_called()

View file

@ -48,24 +48,7 @@ def test_dropped_rule_is_a_regression():
def test_new_rule_in_head_is_clean():
assert ratchet.regressions_for("b.json", {}, {"LIT009": _spec_of(5, 0)}) == []
def test_dropped_file_in_the_any_budget_is_not_a_regression():
# any-discipline is file-keyed: an absent file means ceiling 0, so cleaning a
# file to zero (which drops its entry on --update) is a tightening, never the
# loosening a dropped rule is for the rule-keyed budgets.
base = {"litellm/x.py": _spec_of(10, 5)}
assert ratchet.regressions_for("any-discipline-budget.json", base, {}) == []
def test_raised_ceiling_in_the_any_budget_is_still_a_regression():
base = {"litellm/x.py": _spec_of(10, 5)} # ceiling 15
regs = ratchet.regressions_for(
"any-discipline-budget.json", base, {"litellm/x.py": _spec_of(20, 10)} # ceiling 30
)
assert [r.rule for r in regs] == ["litellm/x.py"]
assert "15 -> 30" in regs[0].detail
assert ratchet.regressions_for("b.json", {}, {"new-rule": _spec_of(5, 0)}) == []
def test_deleted_budget_file_is_a_regression():

View file

@ -1,90 +0,0 @@
import importlib.util
from pathlib import Path
_MODULE_PATH = (
Path(__file__).resolve().parents[2] / "scripts" / "check_any_discipline.py"
)
_spec = importlib.util.spec_from_file_location("check_any_discipline", _MODULE_PATH)
mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(mod)
Violation = mod.Violation
def _v(path="litellm/x.py", line=10, code="LIT009"):
return Violation(Path(path), line, 0, code, "Any-typed value")
def test_violation_on_a_changed_line_is_in_scope():
assert mod._in_scope(_v(line=10), {"litellm/x.py": {10, 11}}) is True
def test_violation_on_an_unchanged_line_of_a_changed_file_is_out_of_scope():
assert mod._in_scope(_v(line=99), {"litellm/x.py": {10, 11}}) is False
def test_whole_new_file_puts_every_line_in_scope():
assert mod._in_scope(_v(line=99999), {"litellm/x.py": mod.ALL_LINES}) is True
def test_file_absent_from_line_map_is_out_of_scope():
# Regression: ALL_LINES is a distinct sentinel, so a path missing from the map
# (line_map.get -> None) is NOT mistaken for "whole file in scope".
assert mod._in_scope(_v(path="litellm/other.py"), {"litellm/x.py": {1}}) is False
def test_no_line_map_means_no_line_filtering():
assert mod._in_scope(_v(line=12345), None) is True
def test_build_error_is_always_in_scope():
assert mod._in_scope(_v(code="LIT000", line=1), {"litellm/x.py": {2}}) is True
# --- per-file Any budget ------------------------------------------------------
def test_slack_is_50_percent_rounded_up():
assert mod._slack_for(0) == 0
assert mod._slack_for(1) == 1 # ceil(0.5): even a 1-Any file gets a little room
assert mod._slack_for(3) == 2 # ceil(1.5)
assert mod._slack_for(20) == 10
assert mod._slack_for(5145) == 2573
def test_ceiling_is_baseline_plus_slack():
assert mod._ceiling({"baseline": 20, "slack": 10}) == 30
assert mod._ceiling({}) == 0 # an absent/empty entry means a zero ceiling
def test_lit009_counts_groups_by_file_and_ignores_other_codes():
violations = [
_v(path="litellm/a.py", line=1, code="LIT009"),
_v(path="litellm/a.py", line=2, code="LIT009"),
_v(path="litellm/a.py", line=3, code="LIT005"), # suppression hygiene, not an Any
_v(path="litellm/b.py", line=1, code="LIT009"),
_v(path="litellm/c.py", line=0, code="LIT000"), # build error, not an Any
]
assert mod.lit009_counts(violations) == {"litellm/a.py": 2, "litellm/b.py": 1}
def test_save_budget_omits_zero_count_files_and_round_trips(monkeypatch, tmp_path):
monkeypatch.setattr(mod, "BUDGET_PATH", tmp_path / "any-discipline-budget.json")
mod.save_budget({"litellm/a.py": 20, "litellm/b.py": 0, "litellm/c.py": 1})
loaded = mod.load_budget()
assert loaded == {
"litellm/a.py": {"baseline": 20, "slack": 10},
"litellm/c.py": {"baseline": 1, "slack": 1},
}
assert "litellm/b.py" not in loaded # zero-Any files are never baselined
def test_load_budget_missing_file_is_empty(monkeypatch, tmp_path):
monkeypatch.setattr(mod, "BUDGET_PATH", tmp_path / "nope.json")
assert mod.load_budget() == {}
def test_update_budget_reports_setup_error_when_git_is_unavailable():
# all_litellm_py_files returns None when git can't list files; --update must
# surface a clean setup error (exit 2), not crash with a raw traceback.
assert mod.update_budget(list_files=lambda: None) == 2

View file

@ -10,22 +10,6 @@ _spec.loader.exec_module(gate)
ROOT = gate.REPO_ROOT
def test_mypy_counts_per_code_ignoring_lines_notes_and_summary():
text = "\n".join(
[
f"{ROOT}/litellm/utils.py:10: error: missing annotation [no-untyped-def]",
f"{ROOT}/litellm/utils.py:9999: error: missing annotation [no-untyped-def]",
f"{ROOT}/litellm/main.py:5: error: Returning Any [no-any-return]",
f"{ROOT}/litellm/main.py:5: note: see here",
"Found 3 errors in 2 files (checked 100 source files)",
]
)
assert gate.count_errors(text, "mypy") == {
"no-untyped-def": 2,
"no-any-return": 1,
}
def _bpr(file, severity, rule):
diag = {"file": str(file), "severity": severity, "message": "msg"}
if rule is not None:
@ -46,7 +30,7 @@ def test_basedpyright_counts_per_rule_from_json_not_warnings():
]
}
)
assert gate.count_errors(payload, "basedpyright") == {
assert gate.count_basedpyright(payload) == {
"reportUnknownVariableType": 2,
"reportArgumentType": 1,
}
@ -56,17 +40,10 @@ def test_basedpyright_error_without_a_rule_is_bucketed():
payload = json.dumps(
{"generalDiagnostics": [_bpr(f"{ROOT}/litellm/x.py", "error", None)]}
)
assert gate.count_errors(payload, "basedpyright") == {gate.UNCODED: 1}
def test_mypy_error_without_a_code_is_bucketed_so_it_is_still_gated():
text = f"{ROOT}/litellm/x.py:1: error: something broke with no code"
assert gate.count_errors(text, "mypy") == {gate.UNCODED: 1}
assert gate.count_basedpyright(payload) == {gate.UNCODED: 1}
def test_paths_outside_repo_are_skipped():
text = "/tmp/elsewhere.py:1: error: missing annotation [no-untyped-def]"
assert gate.count_errors(text, "mypy") == {}
payload = json.dumps(
{
"generalDiagnostics": [
@ -74,7 +51,7 @@ def test_paths_outside_repo_are_skipped():
]
}
)
assert gate.count_errors(payload, "basedpyright") == {}
assert gate.count_basedpyright(payload) == {}
def test_at_or_under_ceiling_passes():
@ -124,10 +101,10 @@ def test_malformed_basedpyright_json_exits_loudly_not_as_zero_errors():
import pytest
with pytest.raises(SystemExit):
gate.count_errors("startup warning\n{not json", "basedpyright")
gate.count_basedpyright("startup warning\n{not json")
def test_empty_basedpyright_payload_counts_zero():
# Empty (not malformed) output parses to zero; the vacuous-run guard, not the
# parser, is what rejects an empty run.
assert gate.count_errors("", "basedpyright") == {}
assert gate.count_basedpyright("") == {}

View file

@ -7827,6 +7827,10 @@ export interface paths {
*
* Follows OpenAI API specification for individual model retrieval.
* https://platform.openai.com/docs/api-reference/models/retrieve
*
* Query parameters mirror `/v1/models` so the same caller context (team
* scoping, health filtering, paused deployments) drives both endpoints; the
* listing's public id must resolve to the same internal deployment here.
*/
get: operations["model_info_models__model_id__get"];
put?: never;
@ -16663,6 +16667,10 @@ export interface paths {
*
* Follows OpenAI API specification for individual model retrieval.
* https://platform.openai.com/docs/api-reference/models/retrieve
*
* Query parameters mirror `/v1/models` so the same caller context (team
* scoping, health filtering, paused deployments) drives both endpoints; the
* listing's public id must resolve to the same internal deployment here.
*/
get: operations["model_info_v1_models__model_id__get"];
put?: never;
@ -42956,7 +42964,10 @@ export interface operations {
};
model_info_models__model_id__get: {
parameters: {
query?: never;
query?: {
team_id?: string | null;
healthy_only?: boolean | null;
};
header?: never;
path: {
model_id: string;
@ -53834,7 +53845,10 @@ export interface operations {
};
model_info_v1_models__model_id__get: {
parameters: {
query?: never;
query?: {
team_id?: string | null;
healthy_only?: boolean | null;
};
header?: never;
path: {
model_id: string;

103
uv.lock generated
View file

@ -9,7 +9,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-06-11T06:56:06.940919973Z"
exclude-newer = "2026-06-14T15:53:04.946308996Z"
exclude-newer-span = "P3D"
[manifest]
@ -3231,65 +3231,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/7e/e7394eeb49a41cc514b3eb49020223666cbf40d86f5721c2f07871e6d84a/legacy_cgi-2.6.4-py3-none-any.whl", hash = "sha256:7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd", size = 20035, upload-time = "2025-10-27T05:20:04.289Z" },
]
[[package]]
name = "librt"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" },
{ url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" },
{ url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" },
{ url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" },
{ url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" },
{ url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" },
{ url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" },
{ url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" },
{ url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" },
{ url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" },
{ url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" },
{ url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" },
{ url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" },
{ url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" },
{ url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" },
{ url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" },
{ url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" },
{ url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" },
{ url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" },
{ url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" },
{ url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" },
{ url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" },
{ url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" },
{ url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" },
{ url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" },
{ url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" },
{ url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" },
{ url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" },
{ url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" },
{ url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" },
{ url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" },
{ url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" },
{ url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" },
{ url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" },
{ url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" },
{ url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" },
{ url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" },
{ url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" },
{ url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" },
{ url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" },
{ url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" },
{ url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" },
{ url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" },
]
[[package]]
name = "litellm"
version = "1.89.0"
@ -3441,7 +3382,6 @@ dev = [
{ name = "fastapi-offline" },
{ name = "flake8" },
{ name = "langfuse" },
{ name = "mypy" },
{ name = "openapi-core" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp" },
@ -3609,7 +3549,6 @@ dev = [
{ name = "fastapi-offline", specifier = "==1.7.6" },
{ name = "flake8", specifier = "==7.3.0" },
{ name = "langfuse", specifier = "==2.59.7" },
{ name = "mypy", specifier = "==1.19.0" },
{ name = "openapi-core", marker = "python_full_version < '3.14'", specifier = "==0.22.0" },
{ name = "opentelemetry-api", specifier = "==1.28.0" },
{ name = "opentelemetry-exporter-otlp", specifier = "==1.28.0" },
@ -4224,46 +4163,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]
[[package]]
name = "mypy"
version = "1.19.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "librt" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f9/b5/b58cdc25fadd424552804bf410855d52324183112aa004f0732c5f6324cf/mypy-1.19.0.tar.gz", hash = "sha256:f6b874ca77f733222641e5c46e4711648c4037ea13646fd0cdc814c2eaec2528", size = 3579025, upload-time = "2025-11-28T15:49:01.26Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/8f/55fb488c2b7dabd76e3f30c10f7ab0f6190c1fcbc3e97b1e588ec625bbe2/mypy-1.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6148ede033982a8c5ca1143de34c71836a09f105068aaa8b7d5edab2b053e6c8", size = 13093239, upload-time = "2025-11-28T15:45:11.342Z" },
{ url = "https://files.pythonhosted.org/packages/72/1b/278beea978456c56b3262266274f335c3ba5ff2c8108b3b31bec1ffa4c1d/mypy-1.19.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a9ac09e52bb0f7fb912f5d2a783345c72441a08ef56ce3e17c1752af36340a39", size = 12156128, upload-time = "2025-11-28T15:46:02.566Z" },
{ url = "https://files.pythonhosted.org/packages/21/f8/e06f951902e136ff74fd7a4dc4ef9d884faeb2f8eb9c49461235714f079f/mypy-1.19.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f7254c15ab3f8ed68f8e8f5cbe88757848df793e31c36aaa4d4f9783fd08ab", size = 12753508, upload-time = "2025-11-28T15:44:47.538Z" },
{ url = "https://files.pythonhosted.org/packages/67/5a/d035c534ad86e09cee274d53cf0fd769c0b29ca6ed5b32e205be3c06878c/mypy-1.19.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318ba74f75899b0e78b847d8c50821e4c9637c79d9a59680fc1259f29338cb3e", size = 13507553, upload-time = "2025-11-28T15:44:39.26Z" },
{ url = "https://files.pythonhosted.org/packages/6a/17/c4a5498e00071ef29e483a01558b285d086825b61cf1fb2629fbdd019d94/mypy-1.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cf7d84f497f78b682edd407f14a7b6e1a2212b433eedb054e2081380b7395aa3", size = 13792898, upload-time = "2025-11-28T15:44:31.102Z" },
{ url = "https://files.pythonhosted.org/packages/67/f6/bb542422b3ee4399ae1cdc463300d2d91515ab834c6233f2fd1d52fa21e0/mypy-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:c3385246593ac2b97f155a0e9639be906e73534630f663747c71908dfbf26134", size = 10048835, upload-time = "2025-11-28T15:48:15.744Z" },
{ url = "https://files.pythonhosted.org/packages/0f/d2/010fb171ae5ac4a01cc34fbacd7544531e5ace95c35ca166dd8fd1b901d0/mypy-1.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a31e4c28e8ddb042c84c5e977e28a21195d086aaffaf08b016b78e19c9ef8106", size = 13010563, upload-time = "2025-11-28T15:48:23.975Z" },
{ url = "https://files.pythonhosted.org/packages/41/6b/63f095c9f1ce584fdeb595d663d49e0980c735a1d2004720ccec252c5d47/mypy-1.19.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34ec1ac66d31644f194b7c163d7f8b8434f1b49719d403a5d26c87fff7e913f7", size = 12077037, upload-time = "2025-11-28T15:47:51.582Z" },
{ url = "https://files.pythonhosted.org/packages/d7/83/6cb93d289038d809023ec20eb0b48bbb1d80af40511fa077da78af6ff7c7/mypy-1.19.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb64b0ba5980466a0f3f9990d1c582bcab8db12e29815ecb57f1408d99b4bff7", size = 12680255, upload-time = "2025-11-28T15:46:57.628Z" },
{ url = "https://files.pythonhosted.org/packages/99/db/d217815705987d2cbace2edd9100926196d6f85bcb9b5af05058d6e3c8ad/mypy-1.19.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:120cffe120cca5c23c03c77f84abc0c14c5d2e03736f6c312480020082f1994b", size = 13421472, upload-time = "2025-11-28T15:47:59.655Z" },
{ url = "https://files.pythonhosted.org/packages/4e/51/d2beaca7c497944b07594f3f8aad8d2f0e8fc53677059848ae5d6f4d193e/mypy-1.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7a500ab5c444268a70565e374fc803972bfd1f09545b13418a5174e29883dab7", size = 13651823, upload-time = "2025-11-28T15:45:29.318Z" },
{ url = "https://files.pythonhosted.org/packages/aa/d1/7883dcf7644db3b69490f37b51029e0870aac4a7ad34d09ceae709a3df44/mypy-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:c14a98bc63fd867530e8ec82f217dae29d0550c86e70debc9667fff1ec83284e", size = 10049077, upload-time = "2025-11-28T15:45:39.818Z" },
{ url = "https://files.pythonhosted.org/packages/11/7e/1afa8fb188b876abeaa14460dc4983f909aaacaa4bf5718c00b2c7e0b3d5/mypy-1.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0fb3115cb8fa7c5f887c8a8d81ccdcb94cff334684980d847e5a62e926910e1d", size = 13207728, upload-time = "2025-11-28T15:46:26.463Z" },
{ url = "https://files.pythonhosted.org/packages/b2/13/f103d04962bcbefb1644f5ccb235998b32c337d6c13145ea390b9da47f3e/mypy-1.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3e19e3b897562276bb331074d64c076dbdd3e79213f36eed4e592272dabd760", size = 12202945, upload-time = "2025-11-28T15:48:49.143Z" },
{ url = "https://files.pythonhosted.org/packages/e4/93/a86a5608f74a22284a8ccea8592f6e270b61f95b8588951110ad797c2ddd/mypy-1.19.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9d491295825182fba01b6ffe2c6fe4e5a49dbf4e2bb4d1217b6ced3b4797bc6", size = 12718673, upload-time = "2025-11-28T15:47:37.193Z" },
{ url = "https://files.pythonhosted.org/packages/3d/58/cf08fff9ced0423b858f2a7495001fda28dc058136818ee9dffc31534ea9/mypy-1.19.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6016c52ab209919b46169651b362068f632efcd5eb8ef9d1735f6f86da7853b2", size = 13608336, upload-time = "2025-11-28T15:48:32.625Z" },
{ url = "https://files.pythonhosted.org/packages/64/ed/9c509105c5a6d4b73bb08733102a3ea62c25bc02c51bca85e3134bf912d3/mypy-1.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f188dcf16483b3e59f9278c4ed939ec0254aa8a60e8fc100648d9ab5ee95a431", size = 13833174, upload-time = "2025-11-28T15:45:48.091Z" },
{ url = "https://files.pythonhosted.org/packages/cd/71/01939b66e35c6f8cb3e6fdf0b657f0fd24de2f8ba5e523625c8e72328208/mypy-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:0e3c3d1e1d62e678c339e7ade72746a9e0325de42cd2cccc51616c7b2ed1a018", size = 10112208, upload-time = "2025-11-28T15:46:41.702Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0d/a1357e6bb49e37ce26fcf7e3cc55679ce9f4ebee0cd8b6ee3a0e301a9210/mypy-1.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7686ed65dbabd24d20066f3115018d2dce030d8fa9db01aa9f0a59b6813e9f9e", size = 13191993, upload-time = "2025-11-28T15:47:22.336Z" },
{ url = "https://files.pythonhosted.org/packages/5d/75/8e5d492a879ec4490e6ba664b5154e48c46c85b5ac9785792a5ec6a4d58f/mypy-1.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd4a985b2e32f23bead72e2fb4bbe5d6aceee176be471243bd831d5b2644672d", size = 12174411, upload-time = "2025-11-28T15:44:55.492Z" },
{ url = "https://files.pythonhosted.org/packages/71/31/ad5dcee9bfe226e8eaba777e9d9d251c292650130f0450a280aec3485370/mypy-1.19.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc51a5b864f73a3a182584b1ac75c404396a17eced54341629d8bdcb644a5bba", size = 12727751, upload-time = "2025-11-28T15:44:14.169Z" },
{ url = "https://files.pythonhosted.org/packages/77/06/b6b8994ce07405f6039701f4b66e9d23f499d0b41c6dd46ec28f96d57ec3/mypy-1.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37af5166f9475872034b56c5efdcf65ee25394e9e1d172907b84577120714364", size = 13593323, upload-time = "2025-11-28T15:46:34.699Z" },
{ url = "https://files.pythonhosted.org/packages/68/b1/126e274484cccdf099a8e328d4fda1c7bdb98a5e888fa6010b00e1bbf330/mypy-1.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:510c014b722308c9bd377993bcbf9a07d7e0692e5fa8fc70e639c1eb19fc6bee", size = 13818032, upload-time = "2025-11-28T15:46:18.286Z" },
{ url = "https://files.pythonhosted.org/packages/f8/56/53a8f70f562dfc466c766469133a8a4909f6c0012d83993143f2a9d48d2d/mypy-1.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:cabbee74f29aa9cd3b444ec2f1e4fa5a9d0d746ce7567a6a609e224429781f53", size = 10120644, upload-time = "2025-11-28T15:47:43.99Z" },
{ url = "https://files.pythonhosted.org/packages/09/0e/fe228ed5aeab470c6f4eb82481837fadb642a5aa95cc8215fd2214822c10/mypy-1.19.0-py3-none-any.whl", hash = "sha256:0c01c99d626380752e527d5ce8e69ffbba2046eb8a060db0329690849cf9b6f9", size = 2469714, upload-time = "2025-11-28T15:45:33.22Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"