chore: sync current staging changes for memory integration

This commit is contained in:
moe-berri 2026-09-12 15:09:40 -07:00
commit bbbb0e1592
46 changed files with 436 additions and 150 deletions

View file

@ -780,7 +780,10 @@ async def update_project(
# Handle budget updates
budget_fields = LiteLLM_BudgetTable.model_fields.keys()
budget_updates = {k: v for k, v in update_data.items() if k in budget_fields}
budget_updates = {
**{k: v for k, v in update_data.items() if k in budget_fields},
**({"max_budget": None} if "max_budget" in data.model_fields_set and data.max_budget is None else {}),
}
if budget_updates and existing_project.budget_id:
# Update existing budget

View file

@ -1,6 +1,6 @@
[project]
name = "litellm-enterprise"
version = "0.1.66"
version = "0.1.67"
description = "Package for LiteLLM Enterprise features"
readme = "README.md"
requires-python = ">=3.9"
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
module-root = ""
[tool.commitizen]
version = "0.1.66"
version = "0.1.67"
version_files = [
"pyproject.toml:^version",
"../pyproject.toml:litellm-enterprise==",

View file

@ -1,6 +1,6 @@
[project]
name = "litellm-proxy-extras"
version = "0.4.96"
version = "0.4.97"
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
readme = "README.md"
requires-python = ">=3.9"
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
module-root = ""
[tool.commitizen]
version = "0.4.96"
version = "0.4.97"
version_files = [
"pyproject.toml:^version",
"../pyproject.toml:litellm-proxy-extras==",

View file

@ -5,6 +5,7 @@ import os
import secrets
from collections.abc import Mapping
from datetime import datetime
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, Optional, get_args
from litellm._logging import verbose_logger
@ -1279,6 +1280,7 @@ class CustomGuardrail(CustomLogger):
guardrail_response: Final = self._summarize_guardrail_response(
response=response,
original_inputs=original_inputs,
event_type=event_type,
)
verbose_logger.debug("Guardrail response: %s", response)
@ -1298,6 +1300,7 @@ class CustomGuardrail(CustomLogger):
self,
response: object,
original_inputs: Mapping[str, object] | None,
event_type: GuardrailEventHooks | None,
) -> object:
"""Reduce a hook's return value to what is safe to log as ``guardrail_response``.
@ -1305,15 +1308,21 @@ class CustomGuardrail(CustomLogger):
returns the (possibly modified) request payload. Neither is a provider verdict, and
logging them verbatim ships the user's prompt to every logging sink (OTEL spans,
Datadog, spend logs), so both collapse to ``"allow"`` / ``"mask"`` by comparing
against ``original_inputs``, a copy taken before the hook ran. A string result is the
hook's own rejection message (the proxy turns it into a 400), not user input, so it is
logged as is.
against ``original_inputs``, a copy taken before the hook ran. A pre_call baseline only
holds the prompt-bearing keys, so the returned request is narrowed to those same keys
before the comparison. A string result is the hook's own rejection message (the proxy
turns it into a 400), not user input, so it is logged as is.
"""
if response is None:
return {}
if original_inputs is None or not isinstance(response, Mapping):
return response
return "mask" if self._inputs_were_modified(original_inputs, response) else "allow"
compared_response: Final[Mapping[str, object]] = (
MappingProxyType({key: value for key, value in response.items() if key in _PRE_CALL_CONTENT_KEYS})
if event_type == GuardrailEventHooks.pre_call
else response
)
return "mask" if self._inputs_were_modified(original_inputs, compared_response) else "allow"
@staticmethod
def _is_guardrail_intervention(e: Exception) -> bool:
@ -1355,8 +1364,8 @@ class CustomGuardrail(CustomLogger):
raise e
def _inputs_were_modified(self, original_inputs: Mapping[str, object], response: Mapping[str, object]) -> bool:
"""True when any baseline key's value differs in ``response`` (mask), False otherwise (allow)."""
return any(response.get(key) != value for key, value in original_inputs.items())
"""True when any key of either mapping differs between them (mask), False otherwise (allow)."""
return any(original_inputs.get(key) != response.get(key) for key in original_inputs.keys() | response.keys())
def mask_content_in_string(
self,
@ -1476,13 +1485,13 @@ def _original_inputs_for(
) -> dict | None: # mutable-ok: matches _process_response(original_inputs=) signature
"""Baseline the hook's return value is compared against to decide "allow" vs "mask".
``apply_guardrail`` masks a fresh ``inputs`` dict, so that dict is the baseline. Pre-call
hooks edit the request in place and return it, so the baseline is a deep copy of the
prompt-bearing keys taken before the hook runs.
Hooks may edit their argument in place and return it, so the baseline is always a deep
copy taken before the hook runs: the whole ``inputs`` dict for ``apply_guardrail``, the
prompt-bearing request keys for pre-call hooks.
"""
if func_name == "apply_guardrail":
inputs: Final = kwargs.get("inputs")
return inputs if isinstance(inputs, dict) else None
return copy.deepcopy(inputs) if isinstance(inputs, dict) else None
if event_type != GuardrailEventHooks.pre_call:
return None
return {key: copy.deepcopy(value) for key, value in request_data.items() if key in _PRE_CALL_CONTENT_KEYS}

View file

@ -3,7 +3,7 @@ import contextlib
import json
import logging
import math
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine, Mapping, Sequence
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence
from datetime import datetime
from functools import lru_cache
from types import MappingProxyType
@ -3224,20 +3224,24 @@ class ProxyBaseLLMRequestProcessing:
end-of-stream blocks complete, so the spend log sees
guardrail_information.
Three closure shapes, matching who owns logging for the stream:
Two closure shapes, matching who owns logging for the stream:
- CustomStreamWrapper (chat completions) stores
(assembled_response, cache_hit); the closure also runs
non-apply_guardrail post-call hooks via
_run_deferred_stream_guardrails.
- Bridged /v1/responses (LiteLLMCompletionStreamingIterator) shares
its inner CustomStreamWrapper's logging_obj, so it stores the same
(assembled_response, cache_hit) shape; the closure only dispatches
success logging, matching the route's pre-existing hook surface.
- Native anthropic_messages/aresponses iterators store a single
ready-made logging coroutine to enqueue.
- Every other anthropic_messages/aresponses stream gets a closure
that dispatches on the stored args shape, because the arming site
cannot tell the producers apart: native iterators store a single
ready-made logging coroutine to enqueue, while bridged streams
(LiteLLMCompletionStreamingIterator, and the plain SSE generator
AnthropicStreamWrapper returns for bridged /v1/messages) share
their inner CustomStreamWrapper's logging_obj and so store
(assembled_response, cache_hit); for those the closure only
dispatches success logging, matching the route's pre-existing
hook surface.
Raw async generators from passthrough routes bypass all three and
would orphan the closure, so they are not armed here.
Raw async generators from passthrough routes bypass both and would
orphan the closure, so they are not armed here.
The router wraps iterators that cannot carry _hidden_params in
HiddenParamsAsyncIteratorWrapper, so class sniffing runs on the
@ -3271,31 +3275,27 @@ class ProxyBaseLLMRequestProcessing:
if route_type not in ("anthropic_messages", "aresponses") or not self._is_streaming_response(response):
return
from litellm.responses.litellm_completion_transformation.streaming_iterator import (
LiteLLMCompletionStreamingIterator,
)
if isinstance(unwrapped, LiteLLMCompletionStreamingIterator):
_captured_bridge_logging_obj: Final = logging_obj
async def _on_deferred_bridged_stream_complete(assembled_response: object, cache_hit: object) -> None:
await _as_success_dispatcher(_captured_bridge_logging_obj).dispatch_success_handlers(
assembled_response,
cache_hit=cache_hit,
start_time=None,
end_time=None,
prefer_async_handlers=True,
)
logging_obj._on_deferred_stream_complete = _on_deferred_bridged_stream_complete
return
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
async def _on_deferred_native_stream_complete(
logging_coroutine: Coroutine[object, object, object],
) -> None:
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(async_coroutine=logging_coroutine)
_captured_native_logging_obj: Final = logging_obj
async def _on_deferred_native_stream_complete(*args: object) -> None:
match args:
case (logging_coroutine,) if asyncio.iscoroutine(logging_coroutine):
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(async_coroutine=logging_coroutine)
case (assembled_response, cache_hit):
await _as_success_dispatcher(_captured_native_logging_obj).dispatch_success_handlers(
assembled_response,
cache_hit=cache_hit,
start_time=None,
end_time=None,
prefer_async_handlers=True,
)
case _:
verbose_proxy_logger.error(
"Deferred stream logging dropped: unexpected stored args shape %s",
tuple(type(arg).__name__ for arg in args),
)
logging_obj._on_deferred_stream_complete = _on_deferred_native_stream_complete

View file

@ -341,6 +341,7 @@ class AzureContentSafetyPromptShieldGuardrail(AzureGuardrailBase, CustomGuardrai
guardrail_response: Final = self._summarize_guardrail_response(
response=response,
original_inputs=original_inputs,
event_type=event_type,
)
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response=guardrail_response,

View file

@ -14,6 +14,7 @@ All /budget management endpoints
#### BUDGET TABLE MANAGEMENT ####
import math
from collections.abc import Mapping
from types import MappingProxyType
from typing import Final
from fastapi import APIRouter, Depends, HTTPException
@ -176,6 +177,10 @@ async def update_budget(
recomputed_reset_at: Final = (
{"budget_reset_at": get_budget_reset_time(budget_duration=budget_obj.budget_duration)}
if budget_obj.budget_duration is not None and "budget_reset_at" not in budget_obj.model_fields_set
else MappingProxyType({"budget_reset_at": None})
if "budget_duration" in budget_obj.model_fields_set
and budget_obj.budget_duration is None
and "budget_reset_at" not in budget_obj.model_fields_set
else {}
)

View file

@ -1254,8 +1254,8 @@ def _update_internal_user_params(data_json: dict, data: UpdateUserRequest | Upda
fields_set: Final = data.fields_set() if hasattr(data, "fields_set") else set()
for k, v in data_json.items():
if k == "max_budget":
if "max_budget" in fields_set:
if k in ("max_budget", "budget_duration"):
if k in fields_set:
non_default_values[k] = v
elif k == "model_max_budget":
if k in fields_set:
@ -1283,8 +1283,10 @@ def _update_internal_user_params(data_json: dict, data: UpdateUserRequest | Upda
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
validate_budget_duration(non_default_values["budget_duration"])
non_default_values["budget_reset_at"] = get_budget_reset_time(
budget_duration=non_default_values["budget_duration"]
non_default_values["budget_reset_at"] = (
get_budget_reset_time(budget_duration=non_default_values["budget_duration"])
if non_default_values["budget_duration"] is not None
else None
)
if "max_budget" not in non_default_values:

View file

@ -438,6 +438,7 @@ async def update_tag(
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
litellm_proxy_admin_name=litellm_proxy_admin_name,
budget_duration_cleared="budget_duration" in tag.model_fields_set and tag.budget_duration is None,
)
# Get model names for model_info

View file

@ -3,6 +3,7 @@
from collections.abc import Callable, Mapping, MutableMapping, Sequence
from datetime import datetime
from functools import wraps
from types import MappingProxyType
from typing import Any, Final, Protocol
from fastapi import HTTPException, Request
@ -180,6 +181,7 @@ async def handle_budget_for_entity(
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
litellm_proxy_admin_name: str,
budget_duration_cleared: bool = False,
) -> str | None:
"""
Common helper to handle budget creation/updates for entities (organizations, tags, etc).
@ -208,7 +210,14 @@ async def handle_budget_for_entity(
# Extract budget fields from data
_json_data: Final = data.model_dump(exclude_none=True) if hasattr(data, "model_dump") else data
_budget_data: Final = {k: v for k, v in _json_data.items() if k in budget_params}
_budget_data: Final = MappingProxyType(
{
k: _json_data.get(k)
for k in budget_params
if k in _json_data
or (k == "budget_duration" and existing_budget_id is not None and budget_duration_cleared)
}
)
# Check if budget_id is explicitly provided in the data
data_budget_id: Final[str | None] = getattr(data, "budget_id", None)

View file

@ -6,6 +6,7 @@ This allows the same policy to be attached to multiple scopes.
"""
from datetime import datetime, timezone
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, TypedDict
from litellm._logging import verbose_proxy_logger
@ -141,8 +142,11 @@ class AttachmentRegistry:
),
key=_attachment_specificity,
)
broadest_attachment_by_policy: Final = MappingProxyType(
{attachment.policy: attachment for attachment in reversed(matching_attachments)}
)
unique_attachments: Final = tuple(
next(attachment for attachment in matching_attachments if attachment.policy == policy_name)
broadest_attachment_by_policy[policy_name]
for policy_name in dict.fromkeys(attachment.policy for attachment in matching_attachments)
)

View file

@ -67,8 +67,8 @@ proxy = [
"azure-identity>=1.25.2,<2.0",
"azure-storage-blob>=12.28.0,<13.0",
"mcp>=1.28.1,<2.0",
"litellm-proxy-extras==0.4.96",
"litellm-enterprise==0.1.66",
"litellm-proxy-extras==0.4.97",
"litellm-enterprise==0.1.67",
"RestrictedPython>=8.5,<9.0",
"rich>=13.9.4,<14.0",
"InquirerPy>=0.3.4,<1.0",

View file

@ -1292,6 +1292,21 @@ async def test_update_project_leaves_metadata_untouched_when_no_limit_is_sent(mo
assert "metadata" not in _written_project_data(mock_prisma)
@pytest.mark.asyncio
async def test_update_project_clears_only_the_explicit_budget_cap(monkeypatch):
mock_prisma = _project_update_mocks(monkeypatch, {})
mock_prisma.db.litellm_projecttable.find_unique.return_value.budget_id = "budget-clear-test"
mock_prisma.db.litellm_budgettable.update = mock.AsyncMock()
await _run_project_update("project-clear-test", max_budget=None)
mock_prisma.db.litellm_budgettable.update.assert_awaited_once_with(
where={"budget_id": "budget-clear-test"},
data={"max_budget": None, "updated_by": "1234"},
)
assert "max_budget" not in _written_project_data(mock_prisma)
@pytest.mark.parametrize("entry", ["all-proxy-models", "*", "azure/*"])
def test_enforce_project_model_quota_rejects_entries_that_expand_at_request_time(entry):
"""A quota keyed on a wildcard entry is never applied by the limiter, so it fails loudly."""

View file

@ -3016,3 +3016,62 @@ class TestPreCallHookResponseIsNotLoggedVerbatim:
)
assert self._logged_response(data) == "mask"
@pytest.mark.asyncio
async def test_pre_call_hook_adding_tools_logs_mask(self):
class ToolInjectingGuardrail(CustomGuardrail):
@log_guardrail_information
async def async_pre_call_hook(
self,
user_api_key_dict: UserAPIKeyAuth,
cache: object,
data: dict[str, object],
call_type: str,
) -> dict[str, object]:
return {**data, "tools": [{"type": "function", "function": {"name": "guardrail_injected_tool"}}]}
data = self._request()
await ToolInjectingGuardrail(guardrail_name="g").async_pre_call_hook(
user_api_key_dict=UserAPIKeyAuth(), cache=None, data=data, call_type="acompletion"
)
assert self._logged_response(data) == "mask"
@pytest.mark.asyncio
async def test_apply_guardrail_adding_tools_logs_mask(self):
class ToolInjectingGuardrail(CustomGuardrail):
async def apply_guardrail(
self,
inputs: GenericGuardrailAPIInputs,
request_data: dict[str, object],
input_type: Literal["request", "response"],
logging_obj: Optional["LiteLLMLoggingObj"] = None,
) -> GenericGuardrailAPIInputs:
return {**inputs, "tools": [{"type": "function", "function": {"name": "guardrail_injected_tool"}}]}
data = self._request()
await ToolInjectingGuardrail(guardrail_name="g").apply_guardrail(
inputs={"texts": ["SECRET_PROMPT"]}, request_data=data, input_type="request"
)
assert self._logged_response(data) == "mask"
@pytest.mark.asyncio
async def test_apply_guardrail_masking_inputs_in_place_logs_mask(self):
class InPlaceMaskingGuardrail(CustomGuardrail):
async def apply_guardrail(
self,
inputs: GenericGuardrailAPIInputs,
request_data: dict[str, object],
input_type: Literal["request", "response"],
logging_obj: Optional["LiteLLMLoggingObj"] = None,
) -> GenericGuardrailAPIInputs:
inputs["texts"] = ["<REDACTED>"]
return inputs
data = self._request()
await InPlaceMaskingGuardrail(guardrail_name="g").apply_guardrail(
inputs={"texts": ["SECRET_PROMPT"]}, request_data=data, input_type="request"
)
assert self._logged_response(data) == "mask"

View file

@ -15,6 +15,7 @@ Streaming: CSW.__anext__ stores args on logging_obj at stream end.
"""
import asyncio
import logging
from typing import Any, Final
from unittest.mock import AsyncMock, MagicMock, patch
@ -1422,7 +1423,7 @@ class TestArmDeferredStreamDispatch:
async def test_native_stream_closure_enqueues_single_coroutine(self):
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
logging_obj, _ = self._dispatch_recording_logging_obj()
logging_obj, recorded = self._dispatch_recording_logging_obj()
async def _agen():
yield b"x"
@ -1433,20 +1434,89 @@ class TestArmDeferredStreamDispatch:
user_api_key_dict=MagicMock(),
logging_obj=logging_obj,
)
closure = logging_obj._on_deferred_stream_complete
assert closure is not None
assert logging_obj._on_deferred_stream_complete is not None
async def _logging_coroutine():
return None
coro = _logging_coroutine()
logging_obj._deferred_stream_complete_args = (coro,)
with patch.object( # test-quality-ok: GLOBAL_LOGGING_WORKER is a process-global singleton with no injection seam
GLOBAL_LOGGING_WORKER, "ensure_initialized_and_enqueue"
) as mock_enqueue:
await closure(coro)
ProxyLogging._fire_deferred_stream_logging({"litellm_logging_obj": logging_obj})
await asyncio.sleep(0)
mock_enqueue.assert_called_once_with(async_coroutine=coro)
assert recorded == {}
coro.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("route_type", ["anthropic_messages", "aresponses"])
async def test_raw_generator_stream_storing_csw_arg_shape_dispatches_success(self, route_type):
"""Bridged /v1/messages returns AnthropicStreamWrapper's plain SSE
generator, which shares its inner CustomStreamWrapper's logging_obj and
so stores (assembled_response, cache_hit). The closure armed for a raw
generator must accept that shape too, or _fire_deferred_stream_logging
raises TypeError and the request loses its spend log and callbacks."""
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
logging_obj, recorded = self._dispatch_recording_logging_obj()
async def _agen():
yield b"x"
self._processor()._arm_deferred_stream_dispatch(
response=_agen(),
route_type=route_type,
user_api_key_dict=MagicMock(),
logging_obj=logging_obj,
)
assembled = object()
logging_obj._deferred_stream_complete_args = (assembled, True)
with patch.object( # test-quality-ok: GLOBAL_LOGGING_WORKER is a process-global singleton with no injection seam
GLOBAL_LOGGING_WORKER, "ensure_initialized_and_enqueue"
) as mock_enqueue:
ProxyLogging._fire_deferred_stream_logging({"litellm_logging_obj": logging_obj})
await asyncio.sleep(0)
mock_enqueue.assert_not_called()
assert recorded["result"] is assembled
assert recorded["cache_hit"] is True
assert recorded["prefer_async_handlers"] is True
@pytest.mark.asyncio
@pytest.mark.parametrize("stored_args", [(object(),), (object(), object(), object())])
async def test_raw_generator_stream_with_unknown_arg_shape_logs_and_drops(self, stored_args, caplog):
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
logging_obj, recorded = self._dispatch_recording_logging_obj()
async def _agen():
yield b"x"
self._processor()._arm_deferred_stream_dispatch(
response=_agen(),
route_type="anthropic_messages",
user_api_key_dict=MagicMock(),
logging_obj=logging_obj,
)
logging_obj._deferred_stream_complete_args = stored_args
with (
patch.object( # test-quality-ok: GLOBAL_LOGGING_WORKER is a process-global singleton with no injection seam
GLOBAL_LOGGING_WORKER, "ensure_initialized_and_enqueue"
) as mock_enqueue,
caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"),
):
ProxyLogging._fire_deferred_stream_logging({"litellm_logging_obj": logging_obj})
await asyncio.sleep(0)
mock_enqueue.assert_not_called()
assert recorded == {}
dropped = [r for r in caplog.records if r.getMessage().startswith("Deferred stream logging dropped")]
assert len(dropped) == 1
@pytest.mark.asyncio
async def test_csw_closure_routes_through_deferred_stream_guardrails(self, monkeypatch):
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper

View file

@ -340,7 +340,8 @@ async def test_update_budget_recomputes_reset_at_when_duration_changes(
@pytest.mark.asyncio
async def test_update_budget_preserves_explicit_reset_at(client_and_mocks):
@pytest.mark.parametrize("budget_duration", ["1d", None])
async def test_update_budget_preserves_explicit_reset_at(client_and_mocks, budget_duration):
"""An explicit budget_reset_at from the caller always wins over recompute."""
client, _, mock_table = client_and_mocks
captured = _capture_update_data(mock_table)
@ -350,7 +351,7 @@ async def test_update_budget_preserves_explicit_reset_at(client_and_mocks):
"/budget/update",
json={
"budget_id": "budget_explicit_reset",
"budget_duration": "1d",
"budget_duration": budget_duration,
"budget_reset_at": explicit.isoformat(),
},
)
@ -377,8 +378,7 @@ async def test_update_budget_without_duration_leaves_reset_at_untouched(
@pytest.mark.asyncio
async def test_update_budget_duration_none_does_not_recompute(client_and_mocks):
"""Clearing budget_duration (explicit null) must not recompute against a None duration."""
async def test_update_budget_duration_none_clears_obsolete_reset(client_and_mocks):
client, _, mock_table = client_and_mocks
captured = _capture_update_data(mock_table)
@ -389,7 +389,7 @@ async def test_update_budget_duration_none_does_not_recompute(client_and_mocks):
assert resp.status_code == 200, resp.text
assert "budget_duration" in captured and captured["budget_duration"] is None
assert "budget_reset_at" not in captured
assert captured["budget_reset_at"] is None
@pytest.mark.asyncio

View file

@ -2097,6 +2097,22 @@ def test_update_internal_user_params_reset_max_budget_with_none():
assert non_default_values["user_id"] == "test_user"
def test_update_internal_user_params_explicit_duration_clear_overrides_role_default(monkeypatch):
import litellm
monkeypatch.setattr(litellm, "internal_user_budget_duration", "30d")
data = UpdateUserRequest(
user_id="duration-clear-test",
user_role=LitellmUserRoles.INTERNAL_USER,
budget_duration=None,
)
updated = _update_internal_user_params(data_json=data.model_dump(exclude_unset=True), data=data)
assert updated["budget_duration"] is None
assert updated["budget_reset_at"] is None
def test_update_internal_user_params_ignores_other_nones():
"""
Test that other fields are still filtered out if None

View file

@ -4,6 +4,7 @@ Unit tests for AttachmentRegistry - tests policy attachment matching.
Tests the main entry point: get_attached_policies()
"""
import time
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
@ -222,6 +223,21 @@ class TestGetAttachedPolicies:
# Should only appear once
assert attached.count("multi-policy") == 1
def test_many_distinct_policies_resolve_in_linear_time(self):
policy_count = 20_000
registry = AttachmentRegistry()
registry.load_attachments(
[{"policy": f"policy-{index}", "scope": "*"} for index in range(policy_count)]
)
context = PolicyMatchContext(team_alias="team", key_alias="key", model="gpt-4")
started = time.perf_counter()
attached = registry.get_attached_policies(context)
elapsed = time.perf_counter() - started
assert attached == [f"policy-{index}" for index in range(policy_count)]
assert elapsed < 1.0, f"{policy_count} attachments took {elapsed:.2f}s, dedup is no longer one pass"
def test_no_attachments_returns_empty(self):
"""Test empty attachments returns empty list."""
registry = AttachmentRegistry()

View file

@ -4248,9 +4248,6 @@ def test_deepseek_flash_completion_cost():
_FIREWORKS_MODELS = [
(
"accounts/fireworks/models/glm-5p2",
1.4e-06,
4.4e-06,
1.4e-07,
1048576,
131072,
False,
@ -4258,9 +4255,6 @@ _FIREWORKS_MODELS = [
),
(
"accounts/fireworks/models/glm-5p1",
1.4e-06,
4.4e-06,
2.6e-07,
202800,
131072,
False,
@ -4268,9 +4262,6 @@ _FIREWORKS_MODELS = [
),
(
"accounts/fireworks/routers/glm-5p1-fast",
2.8e-06,
8.8e-06,
5.2e-07,
202800,
131072,
False,
@ -4278,9 +4269,6 @@ _FIREWORKS_MODELS = [
),
(
"accounts/fireworks/models/qwen3p7-plus",
4e-07,
1.6e-06,
8e-08,
262144,
65536,
True,
@ -4288,9 +4276,6 @@ _FIREWORKS_MODELS = [
),
(
"accounts/fireworks/models/minimax-m3",
3e-07,
1.2e-06,
6e-08,
512000,
512000,
True,
@ -4298,9 +4283,6 @@ _FIREWORKS_MODELS = [
),
(
"accounts/fireworks/models/minimax-m2p7",
3e-07,
1.2e-06,
6e-08,
196608,
196608,
False,
@ -4308,9 +4290,6 @@ _FIREWORKS_MODELS = [
),
(
"accounts/fireworks/models/kimi-k2p7-code",
9.5e-07,
4e-06,
1.9e-07,
262144,
32768,
True,
@ -4318,9 +4297,6 @@ _FIREWORKS_MODELS = [
),
(
"accounts/fireworks/routers/kimi-k2p7-code-fast",
1.9e-06,
8e-06,
3.8e-07,
262144,
32768,
True,
@ -4328,9 +4304,6 @@ _FIREWORKS_MODELS = [
),
(
"accounts/fireworks/models/kimi-k2p6",
9.5e-07,
4e-06,
1.6e-07,
262144,
32768,
True,
@ -4338,9 +4311,6 @@ _FIREWORKS_MODELS = [
),
(
"accounts/fireworks/routers/kimi-k2p6-fast",
2e-06,
8e-06,
3e-07,
262144,
32768,
True,
@ -4348,9 +4318,6 @@ _FIREWORKS_MODELS = [
),
(
"accounts/fireworks/models/gpt-oss-120b",
1.5e-07,
6e-07,
1.5e-08,
131072,
32768,
False,
@ -4358,9 +4325,6 @@ _FIREWORKS_MODELS = [
),
(
"accounts/fireworks/models/gpt-oss-20b",
7e-08,
3e-07,
3.5e-08,
131072,
32768,
False,
@ -4368,9 +4332,6 @@ _FIREWORKS_MODELS = [
),
(
"accounts/fireworks/models/deepseek-v4-pro",
1.74e-06,
3.48e-06,
1.45e-07,
1048576,
384000,
False,
@ -4378,9 +4339,6 @@ _FIREWORKS_MODELS = [
),
(
"accounts/fireworks/models/deepseek-v4-flash",
1.4e-07,
2.8e-07,
2.8e-08,
1048576,
384000,
False,
@ -4412,9 +4370,6 @@ _FIREWORKS_ROUTER_SHORT_FORMS = [
def _assert_fireworks_entry(
model_cost,
model_path,
expected_input,
expected_output,
expected_cache,
expected_max_input,
expected_max_output,
expected_vision,
@ -4424,9 +4379,9 @@ def _assert_fireworks_entry(
assert info is not None, f"fireworks_ai/{model_path} missing from model cost map"
assert info["litellm_provider"] == "fireworks_ai"
assert info["mode"] == "chat"
assert info["input_cost_per_token"] == expected_input
assert info["output_cost_per_token"] == expected_output
assert info["cache_read_input_token_cost"] == expected_cache
assert info["input_cost_per_token"] > 0
assert info["output_cost_per_token"] > 0
assert "cache_read_input_token_cost" in info
assert info["max_input_tokens"] == expected_max_input
assert info["max_output_tokens"] == expected_max_output
assert info["max_tokens"] == expected_max_output

View file

@ -152,6 +152,28 @@ describe("useResourceList", () => {
await waitFor(() => expect(lastCall().page_size).toBe(25));
});
it("reports loading while a new search request is still pending", async () => {
let resolveSecond: ((value: ResourceListPage<Row>) => void) | undefined;
const fetchPage = vi.fn((query: ResourceListQuery) => {
calls.push(query);
if (calls.length === 1) return Promise.resolve(page([{ id: "a" }], 3));
return new Promise<ResourceListPage<Row>>((resolve) => {
resolveSecond = resolve;
});
});
const { result } = renderList({ fetchPage });
await waitFor(() => expect(result.current.rows).toEqual([{ id: "a" }]));
expect(result.current.isLoading).toBe(false);
act(() => result.current.onSearchChange("zzz"));
await waitFor(() => expect(lastCall().q).toBe("zzz"));
expect(result.current.isLoading).toBe(true);
act(() => resolveSecond?.(page([], 0)));
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.rows).toEqual([]);
});
it("surfaces a failed page as an error instead of empty rows", async () => {
const fetchPage = vi.fn(() => Promise.reject(new Error("boom")));
const { result } = renderList({ fetchPage });

View file

@ -86,7 +86,7 @@ export function useResourceList<TRow>(options: UseResourceListOptions<TRow>): Re
enabled,
placeholderData: (previous) => previous,
};
const { data, isLoading, isFetching, error, refetch: refetchQuery } = useQuery(queryOptions);
const { data, isLoading, isPlaceholderData, isFetching, error, refetch: refetchQuery } = useQuery(queryOptions);
const toFirstPage = useCallback(() => setPagination((previous) => ({ ...previous, pageIndex: 0 })), []);
@ -123,7 +123,7 @@ export function useResourceList<TRow>(options: UseResourceListOptions<TRow>): Re
return {
rows,
rowCount: data?.meta.total_count ?? 0,
isLoading,
isLoading: isLoading || isPlaceholderData,
isFetching,
error,
refetch,

View file

@ -10,7 +10,7 @@ export interface ProjectUpdateParams {
description?: string;
team_id?: string;
models?: string[];
max_budget?: number;
max_budget?: number | null;
blocked?: boolean;
guardrails?: string[];
metadata?: Record<string, unknown>;

View file

@ -98,7 +98,11 @@ const AccessGroupBudgetModal: React.FC<AccessGroupBudgetModalProps> = ({
)}
>
{({ id, value, onChange }) => (
<BudgetDurationDropdown id={id} value={value || null} onChange={onChange} />
<BudgetDurationDropdown
id={id}
value={value || null}
onChange={(next) => onChange(next ?? undefined)}
/>
)}
</FormField>
</FieldGroup>

View file

@ -102,6 +102,20 @@ describe("EditProjectModal submit payload", () => {
});
});
it("should send an explicit clear after blanking a saved budget", async () => {
const user = setup();
renderModal();
const budgetInput = screen.getByRole("spinbutton", { name: "Max Budget (USD)" });
await user.clear(budgetInput);
await user.tab();
expect(budgetInput).toHaveValue(null);
await save(user);
await waitFor(() => expect(mutate).toHaveBeenCalled());
expect(JSON.parse(JSON.stringify(variables().params))).toMatchObject({ max_budget: null });
});
it("includes the advanced fields once Advanced Settings has been opened, even after collapsing it again", async () => {
const user = setup();
renderModal();

View file

@ -86,7 +86,7 @@ function EditProjectForm({ project, onClose, onSuccess }: Omit<EditProjectModalP
: { ...values, guardrails: undefined, modelLimits: undefined, metadata: undefined };
const params: ProjectUpdateParams = {
...buildProjectUpdateParams(submitted),
...buildProjectUpdateParams(submitted, project.litellm_budget_table?.max_budget),
team_id: submitted.team_id,
};

View file

@ -205,8 +205,19 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
type="number"
min={0}
placeholder="0.00"
value={value ?? ""}
onChange={(event) => onChange(toOptionalNumber(event.target.value))}
value={Number.isNaN(value) ? "" : value ?? ""}
onInput={(event) => {
if (event.currentTarget.validity.badInput || Number.isNaN(value)) {
onChange(
event.currentTarget.validity.badInput
? Number.NaN
: toOptionalNumber(event.currentTarget.value) ?? null,
);
}
}}
onChange={(event) =>
onChange(event.target.validity.badInput ? Number.NaN : toOptionalNumber(event.target.value) ?? null)
}
/>
</InputGroup>
)}

View file

@ -22,7 +22,7 @@ export const projectFormSchema = z
.pipe(z.string({ error: "Please select a team" }).min(1, "Please select a team")),
description: z.string().optional(),
models: z.array(z.string()),
max_budget: z.number().optional(),
max_budget: z.number().nullish(),
isBlocked: z.boolean(),
guardrails: z.array(z.string()).optional(),
modelLimits: z.array(modelLimitSchema).optional(),

View file

@ -27,9 +27,9 @@ describe("buildProjectCreateParams", () => {
expect(result.description).toBe("A description");
});
it("should pass through max_budget when provided", () => {
const result = buildProjectCreateParams({ ...baseValues, max_budget: 50.0 });
expect(result.max_budget).toBe(50.0);
it.each([50.0, 1e308])("should preserve a finite max_budget of %s", (maxBudget) => {
const result = buildProjectCreateParams({ ...baseValues, max_budget: maxBudget });
expect(JSON.parse(JSON.stringify(result)).max_budget).toBe(maxBudget);
});
it("should build model_rpm_limit from modelLimits entries", () => {

View file

@ -16,6 +16,11 @@ const buildModelLimitMap = (
const buildMetadata = (entries: ProjectFormValues["metadata"]): Record<string, string> | undefined =>
entries && Object.fromEntries(entries.flatMap((entry) => (entry.key ? [[entry.key, entry.value] as const] : [])));
const roundBudget = (value: number): number => {
const rounded = Math.round(value * 100) / 100;
return Number.isFinite(rounded) ? rounded : value;
};
const buildProjectApiParams = (values: ProjectFormValues, sendEmpty: boolean) => {
const limitEntries = values.modelLimits ?? [];
const modelRpmLimit = buildModelLimitMap(limitEntries, (entry) => entry.rpm);
@ -35,7 +40,7 @@ const buildProjectApiParams = (values: ProjectFormValues, sendEmpty: boolean) =>
project_alias: values.project_alias,
description: values.description,
models: values.models ?? [],
max_budget: values.max_budget === undefined ? undefined : Math.round(values.max_budget * 100) / 100,
max_budget: values.max_budget == null ? undefined : roundBudget(values.max_budget),
blocked: values.isBlocked ?? false,
...guardrailsParam,
...(keep(modelRpmLimit) && { model_rpm_limit: modelRpmLimit }),
@ -53,4 +58,7 @@ export const buildProjectCreateParams = (values: ProjectFormValues) => buildProj
* /project/update leaves an omitted key untouched, so a limit the operator cleared has to go out as
* an explicitly empty map. Omitting it is what silently kept a removed quota enforced.
*/
export const buildProjectUpdateParams = (values: ProjectFormValues) => buildProjectApiParams(values, true);
export const buildProjectUpdateParams = (values: ProjectFormValues, savedMaxBudget?: number | null) => ({
...buildProjectApiParams(values, true),
...(values.max_budget == null && savedMaxBudget != null ? { max_budget: null } : {}),
});

View file

@ -143,7 +143,11 @@ const CreateTagModal: React.FC<CreateTagModalProps> = ({ visible, onCancel, onSu
)}
>
{({ id, value, onChange }) => (
<BudgetDurationDropdown id={id} value={value ?? null} onChange={onChange} />
<BudgetDurationDropdown
id={id}
value={value ?? null}
onChange={(next) => onChange(next ?? undefined)}
/>
)}
</FormField>
</FieldGroup>

View file

@ -28,7 +28,7 @@ const tagEditShape = {
description: z.string().optional(),
models: z.array(z.string()).optional(),
max_budget: z.union([z.string(), z.number()]).optional(),
budget_duration: z.string().optional(),
budget_duration: z.string().nullish(),
};
const tagEditSchema = z.object(tagEditShape);

View file

@ -344,5 +344,16 @@ describe("ViewUserDashboard", () => {
expect(latest[4]).toBeNull();
expect(latest[2]).toBe(1);
});
it("replaces the previous rows with the loading state while the search request is pending", async () => {
renderDashboard();
expect(await screen.findByText("test@example.com")).toBeInTheDocument();
userListCall.mockReturnValue(new Promise(() => undefined));
fireEvent.change(screen.getByPlaceholderText("Search by email or ID…"), { target: { value: "zzznomatch" } });
expect(await screen.findByText("Loading users…")).toBeInTheDocument();
expect(screen.queryByText("test@example.com")).not.toBeInTheDocument();
});
});
});

View file

@ -295,7 +295,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
<UsersTable
data={users}
rowCount={totalUserCount}
isLoading={userListQuery.isLoading}
isLoading={userListQuery.isLoading || userListQuery.isPlaceholderData}
possibleUIRoles={possibleUIRoles}
teams={teams}
sorting={sorting}

View file

@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, renderWithProviders as render, screen, waitFor } from "../../../../../../tests/test-utils";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
import UserInfoView from "./user_info_view";
@ -163,6 +163,35 @@ describe("UserInfoView add-to-team form", () => {
expect(await openEditor(user)).toHaveValue(42);
});
it("should keep Unlimited selected after saving and reopening the user", async () => {
const user = setup();
render(<UserInfoView {...budgetProps} />);
await openEditor(user);
await user.click(screen.getByRole("checkbox", { name: "Unlimited Budget" }));
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(mockUserUpdateUserCall).toHaveBeenCalled());
expect(mockUserUpdateUserCall.mock.calls[0][1]).toMatchObject({ max_budget: null });
await openEditor(user);
expect(screen.getByRole("checkbox", { name: "Unlimited Budget" })).toBeChecked();
});
it("should keep a cleared reset period after saving and reopening the user", async () => {
const user = setup();
render(<UserInfoView {...budgetProps} />);
await openEditor(user);
await user.click(screen.getByRole("combobox", { name: "Reset Budget" }));
await user.click(await screen.findByRole("option", { name: "n/a" }));
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(mockUserUpdateUserCall).toHaveBeenCalled());
expect(mockUserUpdateUserCall.mock.calls[0][1]).toMatchObject({ budget_duration: null });
await openEditor(user);
expect(screen.getByRole("combobox", { name: "Reset Budget" })).toHaveTextContent("n/a");
});
});
it("offers only the teams the user is not already a member of", async () => {

View file

@ -332,8 +332,9 @@ export default function UserInfoView({
user_email: formValues.user_email ?? userData.user_email,
user_alias: formValues.user_alias ?? userData.user_alias,
models: formValues.models ?? userData.models,
max_budget: formValues.max_budget ?? userData.max_budget,
budget_duration: formValues.budget_duration ?? userData.budget_duration,
max_budget: formValues.max_budget === undefined ? userData.max_budget : formValues.max_budget,
budget_duration:
formValues.budget_duration === undefined ? userData.budget_duration : formValues.budget_duration,
metadata: formValues.metadata ?? userData.metadata,
model_max_budget: formValues.model_max_budget ?? userData.model_max_budget,
object_permission: mcpEntitlement

View file

@ -807,7 +807,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
showNeverResets
placeholder={budgetDurationPlaceholder}
value={value}
onChange={onChange}
onChange={(next) => onChange(next ?? undefined)}
/>
)}
</FormField>

View file

@ -138,6 +138,14 @@ it("shows a loading state on initial load and hides the data", () => {
expect(screen.queryByText("Acme Team")).not.toBeInTheDocument();
});
it("replaces the previous rows with the loading state while a new search is pending", () => {
mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam], {}, { isPlaceholderData: true, isFetching: true }));
renderTable();
expect(screen.getByText("Loading teams...")).toBeInTheDocument();
expect(screen.queryByText("Acme Team")).not.toBeInTheDocument();
});
describe("sort contract only backend-sortable columns are sortable", () => {
it("requests the default created_at descending sort on first render", () => {
renderTable();

View file

@ -83,7 +83,8 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
const {
data: teamsResponse,
isPending: isLoading,
isPending,
isPlaceholderData,
isFetching,
refetch,
} = useTeamsTable(tablePagination.pageIndex + 1, tablePagination.pageSize, teamListOptions);
@ -161,7 +162,7 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
onColumnFiltersChange={handleColumnFiltersChange}
enableColumnResizing
columnResizeMode="onChange"
isLoading={isLoading}
isLoading={isPending || isPlaceholderData}
loadingMessage="Loading teams..."
noDataMessage="No teams found"
fillHeight

View file

@ -289,6 +289,15 @@ it("should show a loading state on the initial load and hide the data", () => {
expect(screen.queryByText("Test Key Alias")).not.toBeInTheDocument();
});
it("replaces the previous rows with the loading state while a new search is pending", () => {
mockUseKeys.mockReturnValue(keysResult([mockKey], {}, { isPlaceholderData: true, isFetching: true }));
renderWithProviders(<VirtualKeysTable />);
expect(screen.getByText("Loading keys...")).toBeInTheDocument();
expect(screen.queryByText("Test Key Alias")).not.toBeInTheDocument();
});
it("should show 'No keys found' message when the key list is empty", () => {
mockUseKeys.mockReturnValue(keysResult([]));

View file

@ -128,7 +128,8 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
const {
data: keys,
isPending: isLoading,
isPending,
isPlaceholderData,
isFetching,
refetch,
} = useKeys(tablePagination.pageIndex + 1, tablePagination.pageSize, keyListOptions);
@ -280,7 +281,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
onColumnFiltersChange={handleColumnFiltersChange}
enableColumnResizing
columnResizeMode="onChange"
isLoading={isLoading}
isLoading={isPending || isPlaceholderData}
loadingMessage="Loading keys..."
noDataMessage="No keys found"
fillHeight

View file

@ -14,7 +14,7 @@ const DURATION_LABELS: Record<string, string> = {
interface BudgetDurationDropdownProps {
id?: string;
value?: string | null;
onChange?: (value: string | undefined) => void;
onChange?: (value: string | null) => void;
className?: string;
style?: React.CSSProperties;
placeholder?: string;
@ -31,11 +31,7 @@ const BudgetDurationDropdown: React.FC<BudgetDurationDropdownProps> = ({
showNeverResets = false,
}) => {
return (
<Select
items={DURATION_LABELS}
value={value || null}
onValueChange={(next: string | null) => onChange?.(next ?? undefined)}
>
<Select items={DURATION_LABELS} value={value || null} onValueChange={onChange}>
<SelectTrigger id={id} className={`w-full ${className}`} style={style}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>

View file

@ -1021,7 +1021,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
value={control.value as string | null | undefined}
showNeverResets
placeholder="Not set"
onChange={control.onChange}
onChange={(next) => control.onChange(next ?? undefined)}
/>
)}
</MountedFormField>

View file

@ -41,7 +41,7 @@ export interface TagUpdateRequest {
soft_budget?: number;
tpm_limit?: number;
rpm_limit?: number;
budget_duration?: string;
budget_duration?: string | null;
}
export interface TagDeleteRequest {

View file

@ -157,7 +157,7 @@ const MemberModal = <T extends BaseMember>({
<BudgetDurationDropdown
id={id}
value={typeof value === "string" ? value : null}
onChange={(next) => onChange(next)}
onChange={(next) => onChange(mode === "add" ? next ?? undefined : next)}
/>
);
default:

View file

@ -1467,7 +1467,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
showNeverResets
placeholder="Inherit team reset period"
value={value === null ? NEVER_RESETS_BUDGET_DURATION : value}
onChange={(next) => onChange(next === NEVER_RESETS_BUDGET_DURATION ? null : next)}
onChange={(next) =>
onChange(next === NEVER_RESETS_BUDGET_DURATION ? null : next ?? undefined)
}
/>
)}
</FormField>

6
uv.lock generated
View file

@ -10,7 +10,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-09-09T19:41:59.997668Z"
exclude-newer = "2026-09-09T21:39:49.468411Z"
exclude-newer-span = "P3D"
[manifest]
@ -4776,12 +4776,12 @@ proxy-dev = [
[[package]]
name = "litellm-enterprise"
version = "0.1.66"
version = "0.1.67"
source = { editable = "enterprise" }
[[package]]
name = "litellm-proxy-extras"
version = "0.4.96"
version = "0.4.97"
source = { editable = "litellm-proxy-extras" }
[[package]]