ci: drop mypy entirely, standardize type checking on basedpyright (#30648)

* ci: drop redundant mypy type-check gate, standardize on basedpyright

Type checking ran both mypy (via the pydantic.mypy plugin) and basedpyright.
pydantic v2 emits dataclass_transform, so basedpyright understands models
natively with no plugin, and its gated rules already cover what the mypy pass
caught (no-untyped-def, no-any-return, valid-type, import-not-found all map to
basedpyright equivalents). Running both meant two checkers, two budgets, and a
plugin only mypy could load.

This removes the mypy type-check gate: the lint-mypy/lint-mypy-budget-update
Makefile targets, the CI MyPy step, mypy-code-budget.json, the budget-ratchet
entry, and the vestigial [tool.mypy] pydantic plugin block (the gating pass used
litellm/mypy.ini, which never loaded the plugin). type_check_gate.py is
specialized to basedpyright since the mypy parsing path is now unused.

mypy stays a dev dependency because the Any-discipline gate
(scripts/check_any_discipline.py) imports it as a library to detect Any-typed
values; it is no longer run as a type checker.

* ci: remove the Any-discipline gate, rely on basedpyright's reportAny

The Any-discipline gate (scripts/check_any_discipline.py) was the last consumer
of mypy: it imported mypy as a library to detect values whose inferred type
contains Any, gated per-file against any-discipline-budget.json. basedpyright
already reports the same class of finding through reportAny/reportExplicitAny,
which are gated tree-wide in basedpyright-code-budget.json, so the separate gate
(and the mypy dependency behind it) is redundant.

Removes the gate end to end: check_any_discipline.py and its test, the
any-discipline CI job, the lint-any/lint-any-budget-update Makefile targets,
any-discipline-budget.json, litellm/mypy.ini, the .mypy_cache_any references,
and mypy from the dev dependencies. budget_ratchet_check.py drops the
any-discipline entry and the now-unused zero-floor mechanism (rewritten as a
comprehension). check_type_discipline.py drops the any-ok suppression token,
since # any-ok suppressed only the deleted gate; the 134 now-orphaned
# any-ok comments across 14 files are stripped (they never affected
basedpyright, which uses # pyright: ignore).

uv.lock is intentionally left untouched: uv still considers it consistent with
the mypy-removed pyproject (uv lock --check and uv sync --frozen both pass), and
a relock bumps 30+ unrelated packages because of the moving exclude-newer window.
A future intentional relock will prune the now-unreferenced mypy entry.

* build: relock to drop mypy from uv.lock

CI's uv 0.10.9 honors the repo's exclude-newer window and correctly flags the
lockfile as out of sync once mypy leaves pyproject; my earlier local uv 0.8.17
could not parse exclude-newer and silently passed --check. Relocking with the
pinned CI version removes only mypy and its transitive librt, with no other
version changes.
This commit is contained in:
Mateo Wang 2026-06-17 09:42:00 -07:00 • committed by GitHub
parent 60f4c01b74
commit b8d79d1e0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 198 additions and 7438 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

@ -74,8 +74,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
@ -147,14 +137,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 ..
@ -163,10 +147,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

@ -2215,7 +2215,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: Optional[List[Any]] = _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

@ -2844,7 +2844,7 @@ async def make_call(
sync_stream=False,
logging_obj=logging_obj,
response_headers=response.headers,
response=response, # any-ok: untyped stream
response=response,
)
# LOGGING
logging_obj.post_call(
@ -2888,7 +2888,7 @@ def make_sync_call(
sync_stream=True,
logging_obj=logging_obj,
response_headers=response.headers,
response=response, # any-ok: untyped stream
response=response,
)
# LOGGING
@ -3661,16 +3661,14 @@ class ModelResponseIterator:
raise RuntimeError(f"Error parsing chunk: {e},\nReceived chunk: {chunk}")
async def aclose(self) -> None:
iterator = getattr( # any-ok: untyped stream
iterator = getattr(
self,
"async_response_iterator",
self.streaming_response, # any-ok: untyped stream
self.streaming_response,
)
if iterator is not None and hasattr( # any-ok: untyped stream
iterator, "aclose" # any-ok: untyped stream
):
if iterator is not None and hasattr(iterator, "aclose"):
try:
await iterator.aclose() # any-ok: untyped stream
await iterator.aclose()
except Exception as e: # noqa: BLE001
verbose_logger.debug(
"ModelResponseIterator.aclose: error closing iterator: %s", e
@ -3684,14 +3682,10 @@ class ModelResponseIterator:
)
def close(self) -> None:
iterator = getattr( # any-ok: untyped stream
self, "response_iterator", self.streaming_response # any-ok: untyped stream
)
if iterator is not None and hasattr( # any-ok: untyped stream
iterator, "close" # any-ok: untyped stream
):
iterator = getattr(self, "response_iterator", self.streaming_response)
if iterator is not None and hasattr(iterator, "close"):
try:
iterator.close() # any-ok: untyped stream
iterator.close()
except Exception as e: # noqa: BLE001
verbose_logger.debug(
"ModelResponseIterator.close: error closing iterator: %s", e

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

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

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

@ -896,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
)
)
@ -7082,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:
@ -7123,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"
@ -7165,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"
@ -7231,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,
)
@ -7353,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)
@ -7364,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(

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

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

@ -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("") == {}

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"