mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
chore: sync current staging changes for memory integration
This commit is contained in:
commit
bbbb0e1592
46 changed files with 436 additions and 150 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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==",
|
||||
|
|
|
|||
|
|
@ -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==",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([]));
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
6
uv.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue