Merge remote-tracking branch 'origin/main' into litellm_lit8140_flux2_input_image_billing

This commit is contained in:
shrey kharbanda 2026-09-25 01:00:58 +00:00
commit 3d7b85bdd0
19 changed files with 2364 additions and 273 deletions

View file

@ -28398,6 +28398,8 @@
"input_cost_per_image": 0.0011,
"input_cost_per_token": 2e-06,
"input_cost_per_token_batches": 1e-06,
"input_cost_per_token_flex": 1e-06,
"input_cost_per_token_priority": 3.6e-06,
"litellm_provider": "gemini",
"max_input_tokens": 131072,
"max_output_tokens": 32768,
@ -28409,6 +28411,8 @@
"rpm": 1000,
"tpm": 4000000,
"output_cost_per_token_batches": 6e-06,
"output_cost_per_token_flex": 6e-06,
"output_cost_per_token_priority": 2.16e-05,
"source": "https://ai.google.dev/gemini-api/docs/pricing",
"supported_endpoints": [
"/v1/chat/completions",
@ -41395,20 +41399,20 @@
"supports_web_search": false
},
"openrouter/deepseek/deepseek-v4-pro": {
"input_cost_per_token": 9.1263e-07,
"input_cost_per_token": 8.44944e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 1048576,
"max_output_tokens": 384000,
"max_tokens": 384000,
"mode": "chat",
"output_cost_per_token": 1.82526e-06,
"output_cost_per_token": 1.689888e-06,
"source": "https://openrouter.ai/api/v1/models",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"cache_read_input_token_cost": 7.60525e-08,
"cache_read_input_token_cost": 7.0412e-08,
"supports_audio_input": false,
"supports_pdf_input": false,
"supports_vision": false,
@ -56211,6 +56215,7 @@
"input_cost_per_image_token": 1e-06,
"input_cost_per_token": 7.5e-07,
"input_cost_per_video_per_second": 3.3333333333333335e-05,
"input_cost_per_video_token": 1e-06,
"litellm_provider": "gemini",
"max_input_tokens": 131072,
"max_output_tokens": 65536,
@ -56403,6 +56408,7 @@
"input_cost_per_image_token": 1e-06,
"input_cost_per_token": 7.5e-07,
"input_cost_per_video_per_second": 3.3333333333333335e-05,
"input_cost_per_video_token": 1e-06,
"litellm_provider": "gemini",
"max_input_tokens": 131072,
"max_output_tokens": 65536,
@ -60144,6 +60150,23 @@
"supports_tool_choice": true,
"supports_vision": true
},
"fireworks_ai/accounts/fireworks/routers/deepseek-v4p1-flash-us": {
"cache_read_input_token_cost": 9e-09,
"input_cost_per_token": 4.5e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 393216,
"max_tokens": 393216,
"mode": "chat",
"output_cost_per_token": 1.8e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-vision-exp": {
"cache_read_input_token_cost": 7e-09,
"deprecation_date": "2026-09-25",
@ -60223,6 +60246,23 @@
"supports_tool_choice": true,
"supports_vision": true
},
"fireworks_ai/deepseek-v4p1-flash-us": {
"cache_read_input_token_cost": 9e-09,
"input_cost_per_token": 4.5e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 393216,
"max_tokens": 393216,
"mode": "chat",
"output_cost_per_token": 1.8e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"fireworks_ai/deepseek-v4-flash-vision-exp": {
"cache_read_input_token_cost": 7e-09,
"deprecation_date": "2026-09-25",
@ -60319,13 +60359,16 @@
},
"fireworks_ai/kimi-k3-us": {
"cache_read_input_token_cost": 4.5e-07,
"cache_read_input_token_cost_priority": 5.625e-07,
"input_cost_per_token": 4.5e-06,
"input_cost_per_token_priority": 5.625e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 131072,
"max_tokens": 131072,
"mode": "chat",
"output_cost_per_token": 2.25e-05,
"output_cost_per_token_priority": 2.8125e-05,
"reasoning_effort_levels": [
"low",
"high",
@ -60527,13 +60570,16 @@
},
"fireworks_ai/accounts/fireworks/routers/kimi-k3-us": {
"cache_read_input_token_cost": 4.5e-07,
"cache_read_input_token_cost_priority": 5.625e-07,
"input_cost_per_token": 4.5e-06,
"input_cost_per_token_priority": 5.625e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 131072,
"max_tokens": 131072,
"mode": "chat",
"output_cost_per_token": 2.25e-05,
"output_cost_per_token_priority": 2.8125e-05,
"reasoning_effort_levels": [
"low",
"high",
@ -63292,6 +63338,22 @@
"supports_tool_choice": true,
"supports_vision": false
},
"fireworks_ai/accounts/fireworks/routers/glm-5p3-us": {
"cache_read_input_token_cost": 3.9e-07,
"input_cost_per_token": 2.1e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 6.6e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": false
},
"fireworks_ai/glm-5p3": {
"cache_read_input_token_cost": 2.6e-07,
"cache_read_input_token_cost_priority": 3.25e-07,
@ -63311,6 +63373,22 @@
"supports_tool_choice": true,
"supports_vision": false
},
"fireworks_ai/glm-5p3-us": {
"cache_read_input_token_cost": 3.9e-07,
"input_cost_per_token": 2.1e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 6.6e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": false
},
"fireworks_ai/accounts/fireworks/routers/glm-5p3-fast": {
"cache_read_input_token_cost": 3.9e-07,
"input_cost_per_token": 2.1e-06,
@ -63358,6 +63436,20 @@
"supports_tool_choice": true,
"supports_vision": true
},
"fireworks_ai/accounts/fireworks/routers/glm-5p3-flash-us": {
"cache_read_input_token_cost": 4.5e-08,
"input_cost_per_token": 2.25e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_tokens": 1048576,
"mode": "chat",
"output_cost_per_token": 7.5e-07,
"source": "https://docs.fireworks.ai/serverless/pricing",
"supports_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"fireworks_ai/glm-5p3-flash": {
"cache_read_input_token_cost": 3e-08,
"cache_read_input_token_cost_priority": 3.75e-08,
@ -63375,6 +63467,20 @@
"supports_tool_choice": true,
"supports_vision": true
},
"fireworks_ai/glm-5p3-flash-us": {
"cache_read_input_token_cost": 4.5e-08,
"input_cost_per_token": 2.25e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_tokens": 1048576,
"mode": "chat",
"output_cost_per_token": 7.5e-07,
"source": "https://docs.fireworks.ai/serverless/pricing",
"supports_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"fireworks_ai/accounts/fireworks/models/inkling": {
"cache_read_input_token_cost": 1.7e-07,
"input_cost_per_token": 1e-06,
@ -65906,9 +66012,9 @@
"supports_web_search": false
},
"openrouter/z-ai/glm-5.3-flash": {
"input_cost_per_token": 1.5e-07,
"output_cost_per_token": 5e-07,
"cache_read_input_token_cost": 5e-08,
"input_cost_per_token": 4.5e-08,
"output_cost_per_token": 6e-07,
"cache_read_input_token_cost": 2.85e-08,
"litellm_provider": "openrouter",
"max_input_tokens": 1310720,
"max_output_tokens": 943718,
@ -66657,9 +66763,9 @@
"supports_web_search": true
},
"openrouter/deepseek/deepseek-v4-flash": {
"input_cost_per_token": 8.4e-08,
"output_cost_per_token": 1.68e-07,
"cache_read_input_token_cost": 1.68e-08,
"input_cost_per_token": 4.9e-08,
"output_cost_per_token": 9.8e-08,
"cache_read_input_token_cost": 9.8e-09,
"litellm_provider": "openrouter",
"max_input_tokens": 1048576,
"max_output_tokens": 384000,
@ -73050,6 +73156,26 @@
"supports_vision": true,
"supports_web_search": false
},
"openrouter/mistralai/mistral-large-2512": {
"cache_read_input_token_cost": 5e-08,
"input_cost_per_token": 5e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 262144,
"max_output_tokens": 209715,
"max_tokens": 209715,
"mode": "chat",
"output_cost_per_token": 1.5e-06,
"source": "https://openrouter.ai/api/v1/models",
"supports_audio_input": false,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": false
},
"openrouter/mistralai/mistral-large-2512:batch": {
"cache_read_input_token_cost": 2.5e-08,
"input_cost_per_token": 2.5e-07,

View file

@ -227,6 +227,9 @@ from litellm.router_utils.pre_call_checks.deployment_affinity_check import (
DeploymentAffinityCheck,
warn_on_unknown_model_group_affinity_flags,
)
from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import (
EncryptedContentAffinityCheck,
)
from litellm.router_utils.pre_call_checks.io_token_rate_limit_check import (
build_io_token_rate_limit_headers,
deployment_has_io_token_limits,
@ -438,6 +441,7 @@ _RUNTIME_TOGGLEABLE_PRE_CALL_CHECKS: Final[Mapping[str, type[CustomLogger]]] = M
{
"prompt_caching": PromptCachingDeploymentCheck,
"enforce_model_rate_limits": ModelRateLimitingCheck,
"encrypted_content_affinity": EncryptedContentAffinityCheck,
}
)
@ -2207,10 +2211,6 @@ class Router:
)
def _add_encrypted_content_affinity_check(self, enable_global_affinity: bool) -> None:
from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import (
EncryptedContentAffinityCheck,
)
def _move_before_deployment_affinity(
callback_list: list[Any],
callback_to_move: EncryptedContentAffinityCheck,

View file

@ -36,17 +36,10 @@ Safe to enable globally:
- No cache required.
"""
import time
from collections.abc import Iterator, Mapping
from typing import TYPE_CHECKING, Final, Optional, Protocol, cast
import httpx
from typing import TYPE_CHECKING, Final, Optional, cast
from litellm._logging import verbose_router_logger
from litellm.exceptions import (
RateLimitError,
ServiceUnavailableError,
)
from litellm.integrations.custom_logger import CustomLogger, Span
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.litellm_core_utils.prompt_templates.common_utils import (
@ -55,7 +48,6 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
strip_encrypted_reasoning_from_messages,
)
from litellm.responses.utils import ResponsesAPIRequestUtils
from litellm.router_utils.cooldown_cache import CooldownCacheValue
from litellm.types.llms.openai import AllMessageValues
from litellm.types.router import Deployment
@ -63,14 +55,6 @@ if TYPE_CHECKING:
from litellm.router import Router
class _SupportsActiveCooldowns(Protocol):
"""Cooldown-cache handle: this check only reads back the currently active cooldowns."""
async def async_get_active_cooldowns(
self, model_ids: list[str], parent_otel_span: Span | None
) -> list[tuple[str, CooldownCacheValue]]: ...
class EncryptedContentAffinityCheck(CustomLogger):
"""
Routes follow-up Responses API requests to the deployment that produced
@ -194,23 +178,6 @@ class EncryptedContentAffinityCheck(CustomLogger):
return deployment
return None
@staticmethod
def _request_team_id(request_kwargs: Mapping[str, object]) -> str | None:
containers: Final = (request_kwargs.get("metadata"), request_kwargs.get("litellm_metadata"))
team_ids: Final = (c.get("user_api_key_team_id") for c in containers if isinstance(c, Mapping))
return next((tid for tid in team_ids if isinstance(tid, str)), None)
def _routed_group_candidate_model_ids(self, request_kwargs: Mapping[str, object], model: str) -> frozenset[str]:
"""
Deployment ids that could serve this turn's routed ``model``, as the router
resolves a route (model_group_alias / routing group / model_name / team /
pattern). Delegates to the router so the full precedence is not re-derived here
and no deployment ids are written into request kwargs bound for the provider.
"""
if self.router is None:
return frozenset()
return self.router.get_candidate_model_ids_for_route(model=model, team_id=self._request_team_id(request_kwargs))
@staticmethod
def _encryption_boundary_key(
litellm_params: object,
@ -262,9 +229,6 @@ class EncryptedContentAffinityCheck(CustomLogger):
Deployments in ``healthy_deployments`` sharing the originating
deployment's ``(api_base, api_key)``, alongside the originating
deployment object (or ``None`` if it was removed / router unavailable).
Returns ``([], originating_or_None)`` when no boundary match exists,
so the caller can reuse the looked-up ``originating`` rather than
re-querying the router.
"""
if self.router is None:
return [], None
@ -294,18 +258,12 @@ class EncryptedContentAffinityCheck(CustomLogger):
"""
If the request ``input`` contains litellm-encoded item IDs, or its Anthropic
``messages`` replay a bridge-tagged thinking block, decode the embedded
``model_id`` and pin the request to that deployment. Raises
``RateLimitError`` / ``ServiceUnavailableError`` when the originating
deployment is a member of the routed model group but currently unavailable
and no encryption-boundary peer exists, rather than dispatching a doomed
request to a non-peer deployment. When the origin is not a member of the
routed group (an auto-router tier change, a model switch with no peer, a
removed deployment, or an unknown/forged marker), the encrypted reasoning is
stripped and the request dispatches with its readable history instead. The
429/503 split mirrors the originating cooldown's status:
a 429-induced cooldown surfaces as 429 (with ``Retry-After`` set to the
remaining cooldown window) so OpenAI-compatible clients back off and
retry after the deployment is eligible again.
``model_id`` and pin the request to that deployment. When the origin cannot
serve this turn and no encryption-boundary peer is configured (it is
unhealthy, the request was routed to a different group by an auto-router tier
change or model switch, or the marker is removed/unknown/forged), the
encrypted reasoning is stripped and the request dispatches to the healthy
pool with its readable history instead of failing.
"""
request_kwargs = request_kwargs or {}
typed_healthy_deployments: Final = cast(list[dict], healthy_deployments)
@ -348,7 +306,7 @@ class EncryptedContentAffinityCheck(CustomLogger):
return [deployment]
# Follow-up switched model_name (LIT-2531): pin by Azure resource instead.
boundary_matches, originating = self._find_deployments_on_same_encryption_boundary(
boundary_matches, _originating = self._find_deployments_on_same_encryption_boundary(
healthy_deployments=typed_healthy_deployments,
model_id=model_id,
)
@ -362,101 +320,17 @@ class EncryptedContentAffinityCheck(CustomLogger):
request_kwargs["_encrypted_content_affinity_pinned"] = True
return boundary_matches
# The origin cannot serve this turn's routed group and no peer shares the boundary, so its
# The origin cannot serve this turn and no peer shares its encryption boundary, so its
# encrypted reasoning can never decrypt here. Strip it, keep the readable history, and dispatch
# to the routed group instead of failing. Membership is tested by deployment id against the set
# the router actually resolved for this route, not by model-group name, so an alias, a
# provider-qualified spelling, a team-public name, or a pattern route of the same group is not
# mistaken for a tier change. An unknown origin (a removed deployment, or a forged marker) is
# treated the same as a cross-group one, which also denies an authenticated caller a
# deployment-id existence oracle: a real cross-group id and a nonexistent id both strip and
# dispatch rather than returning distinguishable responses. Only a genuine same-group member
# that is currently unavailable falls through to the fail-fast, preserving the cooldown contract.
routed_group_model_ids: Final = (
self._routed_group_candidate_model_ids(request_kwargs, model) if originating is not None else frozenset()
# to the healthy pool instead of failing the request. This also denies an authenticated caller a
# deployment-id existence oracle: a same-group id, a cross-group id, a removed id and a forged
# marker all strip and dispatch rather than returning distinguishable responses.
verbose_router_logger.warning(
"EncryptedContentAffinityCheck: model_id=%s cannot serve group %s and no deployment on the same "
"encryption boundary is configured; forwarding without its encrypted reasoning",
model_id[:64],
model,
)
if str(model_id) not in routed_group_model_ids:
verbose_router_logger.debug(
"EncryptedContentAffinityCheck: model_id=%s is not a candidate for the routed group %s; "
"forwarding without its encrypted reasoning",
model_id,
model,
)
ResponsesAPIRequestUtils.strip_encrypted_reasoning_from_input(request_input)
strip_encrypted_reasoning_from_messages(anthropic_messages)
return typed_healthy_deployments
# The origin is a member of the routed group but currently unavailable (cooled down); fail fast
# rather than dispatching to a non-peer, which would guarantee an upstream 400.
raise await self._unavailable_origin_error(
model=model,
model_id=model_id,
parent_otel_span=parent_otel_span,
)
async def _unavailable_origin_error(
self,
model: str,
model_id: str,
parent_otel_span: Span | None,
) -> Exception:
# Public error messages intentionally omit the originating ``model_id`` so
# an authenticated caller forging encrypted-content markers cannot use the
# error surface to enumerate which deployment IDs exist on this router.
cooldown: Final = await self._get_origin_cooldown(model_id=model_id, parent_otel_span=parent_otel_span)
if cooldown is not None and str(cooldown.get("status_code")) == "429":
retry_after: Final = self._cooldown_seconds_remaining(cooldown)
return RateLimitError(
message=(
"The deployment that produced this encrypted_content is "
f"rate-limited (cooling down for ~{retry_after}s), and no "
"deployment on the same encryption boundary is configured. "
"Retry after the Retry-After window or configure a deployment "
"with the same (api_base, api_key)."
),
llm_provider="",
model=model,
response=httpx.Response(
status_code=429,
headers={"retry-after": str(retry_after)},
request=httpx.Request("POST", "https://litellm.ai/"),
),
)
return ServiceUnavailableError(
message=(
"The deployment that produced this encrypted_content is "
"currently unavailable (likely cooled down), and no deployment "
"on the same encryption boundary is configured. Retry later or "
"configure a deployment with the same (api_base, api_key)."
),
llm_provider="",
model=model,
)
async def _get_origin_cooldown(
self,
model_id: str,
parent_otel_span: Span | None,
) -> CooldownCacheValue | None:
if self.router is None:
return None
cooldown_cache: Final[_SupportsActiveCooldowns | None] = getattr(self.router, "cooldown_cache", None)
if cooldown_cache is None:
return None
try:
active: Final = await cooldown_cache.async_get_active_cooldowns(
model_ids=[model_id], parent_otel_span=parent_otel_span
)
except Exception:
return None
for cached_model_id, value in active:
if cached_model_id == model_id:
return value
return None
@staticmethod
def _cooldown_seconds_remaining(cooldown: CooldownCacheValue) -> int:
remaining = float(cooldown.get("timestamp", 0.0)) + float(cooldown.get("cooldown_time", 0.0)) - time.time()
return max(1, int(remaining))
ResponsesAPIRequestUtils.strip_encrypted_reasoning_from_input(request_input)
strip_encrypted_reasoning_from_messages(anthropic_messages)
return typed_healthy_deployments

View file

@ -28398,6 +28398,8 @@
"input_cost_per_image": 0.0011,
"input_cost_per_token": 2e-06,
"input_cost_per_token_batches": 1e-06,
"input_cost_per_token_flex": 1e-06,
"input_cost_per_token_priority": 3.6e-06,
"litellm_provider": "gemini",
"max_input_tokens": 131072,
"max_output_tokens": 32768,
@ -28409,6 +28411,8 @@
"rpm": 1000,
"tpm": 4000000,
"output_cost_per_token_batches": 6e-06,
"output_cost_per_token_flex": 6e-06,
"output_cost_per_token_priority": 2.16e-05,
"source": "https://ai.google.dev/gemini-api/docs/pricing",
"supported_endpoints": [
"/v1/chat/completions",
@ -41395,20 +41399,20 @@
"supports_web_search": false
},
"openrouter/deepseek/deepseek-v4-pro": {
"input_cost_per_token": 9.1263e-07,
"input_cost_per_token": 8.44944e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 1048576,
"max_output_tokens": 384000,
"max_tokens": 384000,
"mode": "chat",
"output_cost_per_token": 1.82526e-06,
"output_cost_per_token": 1.689888e-06,
"source": "https://openrouter.ai/api/v1/models",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"cache_read_input_token_cost": 7.60525e-08,
"cache_read_input_token_cost": 7.0412e-08,
"supports_audio_input": false,
"supports_pdf_input": false,
"supports_vision": false,
@ -56211,6 +56215,7 @@
"input_cost_per_image_token": 1e-06,
"input_cost_per_token": 7.5e-07,
"input_cost_per_video_per_second": 3.3333333333333335e-05,
"input_cost_per_video_token": 1e-06,
"litellm_provider": "gemini",
"max_input_tokens": 131072,
"max_output_tokens": 65536,
@ -56403,6 +56408,7 @@
"input_cost_per_image_token": 1e-06,
"input_cost_per_token": 7.5e-07,
"input_cost_per_video_per_second": 3.3333333333333335e-05,
"input_cost_per_video_token": 1e-06,
"litellm_provider": "gemini",
"max_input_tokens": 131072,
"max_output_tokens": 65536,
@ -60144,6 +60150,23 @@
"supports_tool_choice": true,
"supports_vision": true
},
"fireworks_ai/accounts/fireworks/routers/deepseek-v4p1-flash-us": {
"cache_read_input_token_cost": 9e-09,
"input_cost_per_token": 4.5e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 393216,
"max_tokens": 393216,
"mode": "chat",
"output_cost_per_token": 1.8e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-vision-exp": {
"cache_read_input_token_cost": 7e-09,
"deprecation_date": "2026-09-25",
@ -60223,6 +60246,23 @@
"supports_tool_choice": true,
"supports_vision": true
},
"fireworks_ai/deepseek-v4p1-flash-us": {
"cache_read_input_token_cost": 9e-09,
"input_cost_per_token": 4.5e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 393216,
"max_tokens": 393216,
"mode": "chat",
"output_cost_per_token": 1.8e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"fireworks_ai/deepseek-v4-flash-vision-exp": {
"cache_read_input_token_cost": 7e-09,
"deprecation_date": "2026-09-25",
@ -60319,13 +60359,16 @@
},
"fireworks_ai/kimi-k3-us": {
"cache_read_input_token_cost": 4.5e-07,
"cache_read_input_token_cost_priority": 5.625e-07,
"input_cost_per_token": 4.5e-06,
"input_cost_per_token_priority": 5.625e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 131072,
"max_tokens": 131072,
"mode": "chat",
"output_cost_per_token": 2.25e-05,
"output_cost_per_token_priority": 2.8125e-05,
"reasoning_effort_levels": [
"low",
"high",
@ -60527,13 +60570,16 @@
},
"fireworks_ai/accounts/fireworks/routers/kimi-k3-us": {
"cache_read_input_token_cost": 4.5e-07,
"cache_read_input_token_cost_priority": 5.625e-07,
"input_cost_per_token": 4.5e-06,
"input_cost_per_token_priority": 5.625e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 131072,
"max_tokens": 131072,
"mode": "chat",
"output_cost_per_token": 2.25e-05,
"output_cost_per_token_priority": 2.8125e-05,
"reasoning_effort_levels": [
"low",
"high",
@ -63292,6 +63338,22 @@
"supports_tool_choice": true,
"supports_vision": false
},
"fireworks_ai/accounts/fireworks/routers/glm-5p3-us": {
"cache_read_input_token_cost": 3.9e-07,
"input_cost_per_token": 2.1e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 6.6e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": false
},
"fireworks_ai/glm-5p3": {
"cache_read_input_token_cost": 2.6e-07,
"cache_read_input_token_cost_priority": 3.25e-07,
@ -63311,6 +63373,22 @@
"supports_tool_choice": true,
"supports_vision": false
},
"fireworks_ai/glm-5p3-us": {
"cache_read_input_token_cost": 3.9e-07,
"input_cost_per_token": 2.1e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 6.6e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": false
},
"fireworks_ai/accounts/fireworks/routers/glm-5p3-fast": {
"cache_read_input_token_cost": 3.9e-07,
"input_cost_per_token": 2.1e-06,
@ -63358,6 +63436,20 @@
"supports_tool_choice": true,
"supports_vision": true
},
"fireworks_ai/accounts/fireworks/routers/glm-5p3-flash-us": {
"cache_read_input_token_cost": 4.5e-08,
"input_cost_per_token": 2.25e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_tokens": 1048576,
"mode": "chat",
"output_cost_per_token": 7.5e-07,
"source": "https://docs.fireworks.ai/serverless/pricing",
"supports_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"fireworks_ai/glm-5p3-flash": {
"cache_read_input_token_cost": 3e-08,
"cache_read_input_token_cost_priority": 3.75e-08,
@ -63375,6 +63467,20 @@
"supports_tool_choice": true,
"supports_vision": true
},
"fireworks_ai/glm-5p3-flash-us": {
"cache_read_input_token_cost": 4.5e-08,
"input_cost_per_token": 2.25e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_tokens": 1048576,
"mode": "chat",
"output_cost_per_token": 7.5e-07,
"source": "https://docs.fireworks.ai/serverless/pricing",
"supports_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"fireworks_ai/accounts/fireworks/models/inkling": {
"cache_read_input_token_cost": 1.7e-07,
"input_cost_per_token": 1e-06,
@ -65906,9 +66012,9 @@
"supports_web_search": false
},
"openrouter/z-ai/glm-5.3-flash": {
"input_cost_per_token": 1.5e-07,
"output_cost_per_token": 5e-07,
"cache_read_input_token_cost": 5e-08,
"input_cost_per_token": 4.5e-08,
"output_cost_per_token": 6e-07,
"cache_read_input_token_cost": 2.85e-08,
"litellm_provider": "openrouter",
"max_input_tokens": 1310720,
"max_output_tokens": 943718,
@ -66657,9 +66763,9 @@
"supports_web_search": true
},
"openrouter/deepseek/deepseek-v4-flash": {
"input_cost_per_token": 8.4e-08,
"output_cost_per_token": 1.68e-07,
"cache_read_input_token_cost": 1.68e-08,
"input_cost_per_token": 4.9e-08,
"output_cost_per_token": 9.8e-08,
"cache_read_input_token_cost": 9.8e-09,
"litellm_provider": "openrouter",
"max_input_tokens": 1048576,
"max_output_tokens": 384000,
@ -73050,6 +73156,26 @@
"supports_vision": true,
"supports_web_search": false
},
"openrouter/mistralai/mistral-large-2512": {
"cache_read_input_token_cost": 5e-08,
"input_cost_per_token": 5e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 262144,
"max_output_tokens": 209715,
"max_tokens": 209715,
"mode": "chat",
"output_cost_per_token": 1.5e-06,
"source": "https://openrouter.ai/api/v1/models",
"supports_audio_input": false,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": false
},
"openrouter/mistralai/mistral-large-2512:batch": {
"cache_read_input_token_cost": 2.5e-08,
"input_cost_per_token": 2.5e-07,

View file

@ -239,6 +239,14 @@ class Provider:
response: Final = self.scenario_store.get(scenario_id)
if response is None:
return JSONResponse({"error": "Unknown scenario"}, status_code=404)
if request.method == "POST" and "json" in request.headers.get("content-type", ""):
raw_body: Final = await request.body()
if raw_body:
body: Final = JSON_OBJECT.validate_json(raw_body)
if isinstance(body, dict):
self.observations.put(
Observation(request.url.path, request.headers.get("authorization", ""), body)
)
if isinstance(response, RoutedResponse):
route_key: Final = f"{request.method} /{'/'.join(segments[1:])}"
route: Final = next(

View file

@ -0,0 +1,240 @@
"""Encrypted-content affinity when the origin deployment has no encryption-boundary peer.
A multi-region model group has several deployments sharing one upstream api_key but a
distinct api_base each, with ``optional_pre_call_checks: [encrypted_content_affinity]``
and ``disable_cooldowns: true`` (the integration proxy config). A follow-up
``POST /v1/responses`` that replays a reasoning item must never fail at the proxy while
sibling deployments in the same group are healthy: when the origin cannot serve the turn
its encrypted reasoning should be stripped and the request dispatched to a sibling.
"""
import json
import uuid
from typing import Final
import httpx
from integration._support.client import Gateway, Scenario, eventually, object_value, string_value
from integration._support.upstream import delete_scenario, register_scenario
from integration.cost_calculation.cost_tracking_case import JsonResponse
from pydantic import JsonValue
AFFINITY_CHECK: Final = "encrypted_content_affinity"
PROVIDER_MODEL: Final = "openai/gpt-5"
PROVIDER_KEY: Final = "integration-provider-key"
DEPLOYMENT_COUNT: Final = 3
def _responses_payload() -> dict[str, JsonValue]:
return {
"id": "resp_$REQUEST_ID",
"object": "response",
"created_at": 1,
"status": "completed",
"model": "gpt-5-scripted",
"output": [
{
"type": "reasoning",
"id": "rs_$REQUEST_ID",
"summary": [],
"encrypted_content": "ZHNra2RrZA==",
},
{
"type": "message",
"id": "msg_$REQUEST_ID",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "scripted answer"}],
},
],
"usage": {"input_tokens": 5, "output_tokens": 7, "total_tokens": 12},
}
def _enable_affinity_check(scenario: Scenario) -> None:
gateway: Final = scenario.gateway
settings: Final = object_value(gateway.get("/router/settings")["current_values"])
current: Final = settings.get("optional_pre_call_checks")
original: Final = list(current) if isinstance(current, list) else []
original_retries: Final = settings.get("num_retries")
scenario.cleanups.callback(
lambda: gateway.post(
"/config/update",
{"router_settings": {"optional_pre_call_checks": original, "num_retries": original_retries}},
)
)
gateway.post(
"/config/update",
{"router_settings": {"optional_pre_call_checks": [*original, AFFINITY_CHECK], "num_retries": 0}},
)
def _last_group_request_body(gateway: Gateway, group: str) -> dict[str, JsonValue]:
"""The request body the upstream last saw for this group, to prove the strip reached the wire."""
with httpx.Client(base_url=gateway.upstream_url, trust_env=False) as upstream:
requests: Final = object_value(upstream.get("/__observations").json()).get("requests")
assert isinstance(requests, list)
bodies: Final = [
object_value(request).get("body")
for request in requests
if str(object_value(request).get("path")).startswith(f"/{group}-")
]
assert bodies, f"upstream saw no requests for {group}: {requests}"
body: Final = bodies[-1]
assert isinstance(body, dict), f"upstream request body is not an object: {body}"
return body
def _assert_no_encrypted_reasoning_reached_upstream(gateway: Gateway, group: str) -> None:
body: Final = _last_group_request_body(gateway, group)
items: Final = body.get("input")
assert isinstance(items, list), f"upstream request carried no input list: {body}"
assert not any(
isinstance(item, dict) and ("encrypted_content" in item or "litellm_enc" in json.dumps(item)) for item in items
), f"undecryptable reasoning reached the sibling: {body}"
def _multi_region_group(scenario: Scenario) -> tuple[str, tuple[str, ...]]:
"""Three deployments in one model group: one shared api_key, a distinct api_base each."""
gateway: Final = scenario.gateway
group: Final = f"enc-affinity-{uuid.uuid4().hex}"
handles: Final = tuple(
register_scenario(
f"{group}-{index}",
JsonResponse(content_type="application/json", body=_responses_payload()),
)
for index in range(DEPLOYMENT_COUNT)
)
for handle in handles:
scenario.cleanups.callback(delete_scenario, handle)
def delete_model_if_present(model_id: str) -> None:
entries: Final = gateway.get("/model/info")["data"]
assert isinstance(entries, list)
if any(object_value(object_value(entry)["model_info"])["id"] == model_id for entry in entries):
scenario.delete_model(model_id)
deployment_ids: Final = tuple(
string_value(
object_value(
gateway.post(
"/model/new",
{
"model_name": group,
"litellm_params": {
"model": PROVIDER_MODEL,
"api_key": PROVIDER_KEY,
"api_base": handle.api_base(),
},
"model_info": {},
},
)["model_info"]
)["id"]
)
for handle in handles
)
for model_id in deployment_ids:
scenario.cleanups.callback(delete_model_if_present, model_id)
return group, deployment_ids
def _user_message(text: str) -> dict[str, JsonValue]:
return {"type": "message", "role": "user", "content": [{"type": "input_text", "text": text}]}
def _responses_turn(gateway: Gateway, group: str, request_input: JsonValue) -> httpx.Response:
return gateway.request(
"POST",
"/v1/responses",
{"model": group, "input": request_input, "store": False, "include": ["reasoning.encrypted_content"]},
)
def _turn_one(gateway: Gateway, group: str, deployment_ids: tuple[str, ...]) -> tuple[str, list[JsonValue]]:
# the proxy caches responses, so the turn-one prompt needs a unique marker or a
# stale body carrying another run's encoded model_id would replay instead
response: Final = _responses_turn(gateway, group, f"hello affinity {uuid.uuid4().hex}")
assert response.status_code == 200, response.text
origin: Final = str(response.headers["x-litellm-model-id"])
assert origin in deployment_ids, f"turn one served by unknown deployment {origin}: {response.text}"
output: Final = object_value(response.json()).get("output")
assert isinstance(output, list), f"turn one returned no output items: {response.text}"
reasoning: Final = next((item for item in output if object_value(item).get("type") == "reasoning"), None)
assert isinstance(reasoning, dict), f"turn one returned no reasoning item: {response.text}"
assert str(object_value(reasoning)["id"]).startswith("encitem_"), (
f"affinity encoding did not run on turn one: {reasoning}"
)
assert isinstance(object_value(reasoning).get("encrypted_content"), str), (
f"turn one reasoning item has no encrypted_content: {reasoning}"
)
message: Final = next((item for item in output if object_value(item).get("type") == "message"), None)
assert isinstance(message, dict), f"turn one returned no message item: {response.text}"
return origin, [reasoning, message]
def _replay(gateway: Gateway, group: str, items: list[JsonValue]) -> httpx.Response:
return _responses_turn(
gateway,
group,
[_user_message("hello affinity"), *items, _user_message("continue the conversation")],
)
def _model_blocked(gateway: Gateway, model_id: str) -> bool:
entries: Final = gateway.get("/model/info")["data"]
assert isinstance(entries, list)
entry: Final = next(
(entry for entry in entries if object_value(object_value(entry)["model_info"])["id"] == model_id),
None,
)
return entry is not None and object_value(object_value(entry)["model_info"]).get("blocked") is True
def test_replayed_encrypted_content_serves_from_sibling_when_origin_blocked(gateway: Gateway) -> None:
"""Origin excluded from healthy deployments (admin-blocked, no cooldown) must not 503.
On unfixed code the origin is a routed-group candidate with no (api_base, api_key)
peer, so the affinity check raises a proxy-level 503 instead of stripping the
encrypted reasoning and dispatching to a healthy sibling.
"""
with gateway.scenario() as scenario:
_enable_affinity_check(scenario)
group, deployment_ids = _multi_region_group(scenario)
origin, items = _turn_one(gateway, group, deployment_ids)
gateway.post("/model/block", {"model_id": origin})
eventually(lambda: _model_blocked(gateway, origin), lambda blocked: blocked)
response: Final = _replay(gateway, group, items)
assert response.status_code == 200, response.text
siblings: Final = tuple(model_id for model_id in deployment_ids if model_id != origin)
assert response.headers.get("x-litellm-model-id") in siblings, (
f"turn two served by {response.headers.get('x-litellm-model-id')}, "
f"expected a sibling of blocked origin {origin}: {response.text}"
)
_assert_no_encrypted_reasoning_reached_upstream(gateway, group)
def test_replayed_encrypted_content_serves_from_sibling_when_origin_deleted(gateway: Gateway) -> None:
"""Origin permanently removed: strip-and-dispatch to a sibling, the shipped behavior."""
with gateway.scenario() as scenario:
_enable_affinity_check(scenario)
group, deployment_ids = _multi_region_group(scenario)
origin, items = _turn_one(gateway, group, deployment_ids)
gateway.post("/model/delete", {"id": origin})
eventually(
lambda: gateway.get("/model/info")["data"],
lambda entries: (
isinstance(entries, list)
and all(object_value(object_value(entry)["model_info"])["id"] != origin for entry in entries)
),
)
response: Final = _replay(gateway, group, items)
assert response.status_code == 200, response.text
siblings: Final = tuple(model_id for model_id in deployment_ids if model_id != origin)
assert response.headers.get("x-litellm-model-id") in siblings, (
f"turn two served by {response.headers.get('x-litellm-model-id')}, "
f"expected a sibling of deleted origin {origin}: {response.text}"
)
_assert_no_encrypted_reasoning_reached_upstream(gateway, group)

View file

@ -30,6 +30,7 @@ from pydantic import ValidationError
import litellm
from litellm.router_strategy.budget_limiter import RouterBudgetLimiting
from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import EncryptedContentAffinityCheck
from litellm.router_utils.pre_call_checks.model_rate_limit_check import ModelRateLimitingCheck
from litellm.router_utils.pre_call_checks.prompt_caching_deployment_check import PromptCachingDeploymentCheck
from litellm.types.router import RetryPolicy, UpdateRouterConfig
@ -203,6 +204,63 @@ def test_update_settings_replaces_toggleable_pre_call_checks():
assert any(isinstance(callback, ModelRateLimitingCheck) for callback in (router.optional_callbacks or []))
def test_update_settings_clears_omitted_encrypted_content_affinity_check():
router = _build_router()
router.update_settings(optional_pre_call_checks=["encrypted_content_affinity"])
router.update_settings(optional_pre_call_checks=[])
assert not any(
isinstance(callback, EncryptedContentAffinityCheck) for callback in (router.optional_callbacks or [])
)
assert not any(isinstance(callback, EncryptedContentAffinityCheck) for callback in litellm.callbacks)
@pytest.mark.asyncio
async def test_update_settings_turning_off_encrypted_content_affinity_stops_flagging_requests():
router = _build_router()
router.update_settings(optional_pre_call_checks=["encrypted_content_affinity"])
enabled_request: Final = {"litellm_metadata": {}, "input": "hello"}
await router.async_get_available_deployment(model="test-model", request_kwargs=enabled_request)
assert enabled_request["litellm_metadata"]["encrypted_content_affinity_enabled"] is True
router.update_settings(optional_pre_call_checks=[])
disabled_request: Final = {"litellm_metadata": {}, "input": "hello"}
await router.async_get_available_deployment(model="test-model", request_kwargs=disabled_request)
assert "encrypted_content_affinity_enabled" not in disabled_request["litellm_metadata"]
router.update_settings(optional_pre_call_checks=["encrypted_content_affinity"])
reenabled_request: Final = {"litellm_metadata": {}, "input": "hello"}
await router.async_get_available_deployment(model="test-model", request_kwargs=reenabled_request)
assert reenabled_request["litellm_metadata"]["encrypted_content_affinity_enabled"] is True
def test_update_settings_keeps_per_group_encrypted_content_affinity_when_global_toggle_is_omitted():
router = litellm.Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "openai/gpt-4",
"api_key": "sk-fake",
"api_base": "http://localhost:9999",
},
}
],
model_group_affinity_config={"test-model": ["encrypted_content_affinity"]},
)
router.update_settings(optional_pre_call_checks=["encrypted_content_affinity"])
router.update_settings(optional_pre_call_checks=[])
affinity_checks: Final = [
callback for callback in (router.optional_callbacks or []) if isinstance(callback, EncryptedContentAffinityCheck)
]
assert len(affinity_checks) == 1
assert affinity_checks[0].enable_global_affinity is False
@pytest.mark.asyncio
async def test_update_settings_preserves_router_budget_limiting_when_omitted(monkeypatch):
async def _disable_periodic_sync(*args, **kwargs):

View file

@ -1285,7 +1285,7 @@ def test_boundary_key_rejects_non_dict_like_inputs():
# ---------------------------------------------------------------------------
# Fail-fast when originating deployment is unavailable and no boundary peer
# Degraded dispatch when the originating deployment is unavailable and no boundary peer
# ---------------------------------------------------------------------------
@ -1323,14 +1323,13 @@ def _make_router_mock_with_cooldown(
@pytest.mark.asyncio
async def test_affinity_raises_service_unavailable_when_origin_cooled_for_non_429():
async def test_affinity_strips_and_dispatches_when_origin_cooled_for_non_429():
"""
Originating deployment is in the router config, in cooldown for a non-429
cause (e.g. a 500), and no boundary peer is configured. The check must
surface this as a 503 (transient, but not rate-limit-specific) rather than
dispatching to a non-peer deployment.
degrade: strip the encrypted reasoning and dispatch to the healthy pool
rather than failing the request.
"""
from litellm.exceptions import ServiceUnavailableError
from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import (
EncryptedContentAffinityCheck,
)
@ -1366,32 +1365,36 @@ async def test_affinity_raises_service_unavailable_when_origin_cooled_for_non_42
}
]
request_kwargs = {
"input": [{"id": encoded_id, "type": "reasoning"}],
"input": [
{
"id": encoded_id,
"type": "reasoning",
"encrypted_content": ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id(
"gAAAAA-blob", "deployment-a-cooled"
),
}
],
}
with pytest.raises(ServiceUnavailableError) as excinfo:
await check.async_filter_deployments(
model="gpt-5.4",
healthy_deployments=healthy_only_b,
messages=None,
request_kwargs=request_kwargs,
)
result = await check.async_filter_deployments(
model="gpt-5.4",
healthy_deployments=healthy_only_b,
messages=None,
request_kwargs=request_kwargs,
)
# Public error message intentionally omits the originating model_id to
# avoid an authenticated-caller probing oracle.
assert "deployment-a-cooled" not in str(excinfo.value)
assert excinfo.value.status_code == 503
assert result is healthy_only_b
assert not any(isinstance(item, dict) and item.get("encrypted_content") for item in request_kwargs["input"])
@pytest.mark.asyncio
async def test_affinity_raises_rate_limit_with_retry_after_when_origin_cooled_for_429():
async def test_affinity_strips_and_dispatches_when_origin_cooled_for_429():
"""
Originating deployment is in cooldown specifically because of a 429.
The check must surface this as a 429 RateLimitError with a Retry-After
header derived from the cooldown's remaining window, so OpenAI-compatible
clients respect the backoff instead of giving up on a 503.
Originating deployment is in cooldown specifically because of a 429 and no
boundary peer is configured. The check degrades the same way: strip the
encrypted reasoning and dispatch to the healthy pool rather than surfacing
a rate-limit error to the caller.
"""
from litellm.exceptions import RateLimitError
from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import (
EncryptedContentAffinityCheck,
)
@ -1431,29 +1434,23 @@ async def test_affinity_raises_rate_limit_with_retry_after_when_origin_cooled_fo
"input": [{"id": encoded_id, "type": "reasoning"}],
}
with pytest.raises(RateLimitError) as excinfo:
await check.async_filter_deployments(
model="gpt-5.4",
healthy_deployments=healthy_only_b,
messages=None,
request_kwargs=request_kwargs,
)
result = await check.async_filter_deployments(
model="gpt-5.4",
healthy_deployments=healthy_only_b,
messages=None,
request_kwargs=request_kwargs,
)
assert "deployment-a-cooled-429" not in str(excinfo.value)
assert excinfo.value.status_code == 429
retry_after = excinfo.value.response.headers.get("retry-after")
assert retry_after is not None
assert 1 <= int(retry_after) <= 60
assert result is healthy_only_b
@pytest.mark.asyncio
async def test_affinity_raises_service_unavailable_when_origin_filtered_without_cooldown_entry():
async def test_affinity_strips_and_dispatches_when_origin_filtered_without_cooldown_entry():
"""
Originating deployment is configured but absent from healthy_deployments
with no active cooldown entry. Surface as 503 (we cannot prove the cause
was rate-limiting) rather than guessing 429.
with no active cooldown entry and no boundary peer. The check degrades:
strip the encrypted reasoning and dispatch to the healthy pool.
"""
from litellm.exceptions import ServiceUnavailableError
from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import (
EncryptedContentAffinityCheck,
)
@ -1480,15 +1477,72 @@ async def test_affinity_raises_service_unavailable_when_origin_filtered_without_
"input": [{"id": encoded_id, "type": "reasoning"}],
}
with pytest.raises(ServiceUnavailableError) as excinfo:
await check.async_filter_deployments(
model="gpt-5.4",
healthy_deployments=healthy_only_b,
messages=None,
request_kwargs=request_kwargs,
)
result = await check.async_filter_deployments(
model="gpt-5.4",
healthy_deployments=healthy_only_b,
messages=None,
request_kwargs=request_kwargs,
)
assert excinfo.value.status_code == 503
assert result is healthy_only_b
@pytest.mark.asyncio
async def test_affinity_serves_sibling_when_candidate_origin_has_no_boundary_peer():
"""
Regression for the multi-region group where every deployment has a distinct
api_base: the origin is still a routed-group candidate but absent from
healthy_deployments, and no (api_base, api_key) peer exists. The turn must
degrade on a sibling with its encrypted reasoning stripped, not fail.
"""
from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import (
EncryptedContentAffinityCheck,
)
originating = _make_originating_mock("https://region-a.example.com/v1", "shared-key")
mock_router = _make_router_mock_with_cooldown(
originating, cooldown_entries=[], routed_group_model_ids=["region-a", "region-b", "region-c"]
)
check = EncryptedContentAffinityCheck(router=mock_router)
wrapped = ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id("gAAAAA-blob", "region-a")
siblings = [
{
"model_info": {"id": "region-b"},
"model_name": "gpt-5.4",
"litellm_params": {"api_base": "https://region-b.example.com/v1", "api_key": "shared-key"},
},
{
"model_info": {"id": "region-c"},
"model_name": "gpt-5.4",
"litellm_params": {"api_base": "https://region-c.example.com/v1", "api_key": "shared-key"},
},
]
request_kwargs = {
"litellm_metadata": {},
"input": [
{"role": "user", "content": "why is the sky blue?"},
{
"type": "reasoning",
"id": ResponsesAPIRequestUtils._build_encrypted_item_id("region-a", "rs_test"),
"encrypted_content": wrapped,
"summary": [{"type": "summary_text", "text": "scattering"}],
},
{"role": "user", "content": "and sunsets?"},
],
}
result = await check.async_filter_deployments(
model="gpt-5.4",
healthy_deployments=siblings,
messages=None,
request_kwargs=request_kwargs,
)
assert result is siblings
assert request_kwargs["input"][1] == {
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "scattering"}],
}
@pytest.mark.asyncio
@ -1981,15 +2035,14 @@ async def test_affinity_strips_encrypted_reasoning_when_routed_to_another_model_
@pytest.mark.asyncio
async def test_affinity_fails_fast_within_the_origins_own_group():
async def test_affinity_degrades_within_the_origins_own_group():
"""
Negative class for the tier-change discriminator: the routed group IS the
origin's group (a same-group cooldown, not a tier change), so even with a
healthy non-origin sibling that cannot decrypt the content, the request
still fails fast and the encrypted reasoning is left intact rather than
stripped. Preserves the LIT-3051 cooldown contract.
The routed group IS the origin's group (a same-group cooldown), and the
healthy sibling sits on a different encryption boundary, so it cannot
decrypt the replayed reasoning. The check still degrades instead of
failing: the encrypted reasoning is stripped and the request dispatches
to the sibling.
"""
from litellm.exceptions import ServiceUnavailableError
from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import (
EncryptedContentAffinityCheck,
)
@ -2014,29 +2067,26 @@ async def test_affinity_fails_fast_within_the_origins_own_group():
]
request_kwargs = _cross_group_request_kwargs()
with pytest.raises(ServiceUnavailableError):
await check.async_filter_deployments(
model="gpt-reasoning-tier",
healthy_deployments=sibling_pool,
messages=None,
request_kwargs=request_kwargs,
)
result = await check.async_filter_deployments(
model="gpt-reasoning-tier",
healthy_deployments=sibling_pool,
messages=None,
request_kwargs=request_kwargs,
)
assert request_kwargs["input"][1].get("encrypted_content")
assert result is sibling_pool
assert not request_kwargs["input"][1].get("encrypted_content")
@pytest.mark.asyncio
async def test_affinity_does_not_strip_when_group_is_spelled_differently_but_same_by_id():
async def test_affinity_strips_when_group_is_spelled_differently_but_same_by_id():
"""
The discriminator must key on deployment-id membership, not on the model-group
name string. Here the origin's configured group is spelled ``openai/gpt-5.4-mini``
while the routed group is the canonical ``gpt-5.4-mini``: same group, different
spelling. A name compare (``originating.model_name != model``) would read this as
a tier change and strip the reasoning it did not have to. Because the origin's id
is a member of the routed group, this is a same-group cooldown instead: the request
fails fast and the encrypted reasoning is left intact.
The origin's configured group is spelled ``openai/gpt-5.4-mini`` while the
routed group is the canonical ``gpt-5.4-mini``: same group, different
spelling, with the origin a member but currently unavailable. There is no
boundary peer, so the check degrades: strip the encrypted reasoning and
dispatch to the sibling pool.
"""
from litellm.exceptions import ServiceUnavailableError
from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import (
EncryptedContentAffinityCheck,
)
@ -2071,28 +2121,24 @@ async def test_affinity_does_not_strip_when_group_is_spelled_differently_but_sam
],
}
with pytest.raises(ServiceUnavailableError):
await check.async_filter_deployments(
model="gpt-5.4-mini",
healthy_deployments=sibling_pool,
messages=None,
request_kwargs=request_kwargs,
)
result = await check.async_filter_deployments(
model="gpt-5.4-mini",
healthy_deployments=sibling_pool,
messages=None,
request_kwargs=request_kwargs,
)
assert request_kwargs["input"][1].get("encrypted_content")
assert result is sibling_pool
assert not request_kwargs["input"][1].get("encrypted_content")
@pytest.mark.asyncio
async def test_affinity_honors_router_candidate_ids_for_team_and_pattern_routes():
async def test_affinity_strips_for_team_and_pattern_routes():
"""
The exact `model_name` index does not include team-public or pattern routes, so a
same-group cooldown reached only through one of those would be misread as a tier change
and stripped. The check asks the router for the candidate ids it resolves for the route
(`get_candidate_model_ids_for_route`), which covers those paths, rather than the bare
index. Here that set marks the origin as a candidate, so the request fails fast with its
reasoning intact, and the routed group and team are passed through to the router.
Team-public or pattern routes resolve the same routed group as the origin's,
so an unavailable origin there also degrades rather than failing: the
encrypted reasoning is stripped and the request dispatches to the sibling.
"""
from litellm.exceptions import ServiceUnavailableError
from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import (
EncryptedContentAffinityCheck,
)
@ -2127,13 +2173,12 @@ async def test_affinity_honors_router_candidate_ids_for_team_and_pattern_routes(
],
}
with pytest.raises(ServiceUnavailableError):
await check.async_filter_deployments(
model="team-public-model",
healthy_deployments=sibling_pool,
messages=None,
request_kwargs=request_kwargs,
)
result = await check.async_filter_deployments(
model="team-public-model",
healthy_deployments=sibling_pool,
messages=None,
request_kwargs=request_kwargs,
)
assert request_kwargs["input"][1].get("encrypted_content")
mock_router.get_candidate_model_ids_for_route.assert_called_once_with(model="team-public-model", team_id="teamA")
assert result is sibling_pool
assert not request_kwargs["input"][1].get("encrypted_content")

View file

@ -0,0 +1,103 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import ResetMemberBudgetsDialog from "./ResetMemberBudgetsDialog";
import type { MemberBudgetResetState } from "./useMemberBudgetReset";
const pendingFor = (memberCount: number, newBudget = 10) => ({
teamId: "team-123",
updateData: {},
userIds: Array.from({ length: memberCount }, (_, i) => `u-${i}`),
newBudget,
});
const promptingWith = (memberCount: number, newBudget = 10): MemberBudgetResetState => ({
phase: "prompting",
pending: pendingFor(memberCount, newBudget),
});
const defaultHandlers = () => ({
onReset: vi.fn(),
onRetry: vi.fn(),
onKeep: vi.fn(),
onDismiss: vi.fn(),
});
describe("ResetMemberBudgetsDialog", () => {
it("names the members and the new default so the prompt is self-explanatory", () => {
render(<ResetMemberBudgetsDialog state={promptingWith(3)} {...defaultHandlers()} />);
expect(
screen.getByText(
"3 members have a custom budget, so the new team default of $10.00 will not apply to them. " +
"Reset them to the default, or keep the custom budgets?",
),
).toBeInTheDocument();
});
it("switches to singular for one member", () => {
render(<ResetMemberBudgetsDialog state={promptingWith(1)} {...defaultHandlers()} />);
expect(
screen.getByText(
"1 member has a custom budget, so the new team default of $10.00 will not apply to that member. " +
"Reset it to the default, or keep the custom budget?",
),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Reset to $10.00" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Keep custom budget" })).toBeInTheDocument();
});
it("formats large budgets like the rest of the team page", () => {
render(<ResetMemberBudgetsDialog state={promptingWith(2, 1000)} {...defaultHandlers()} />);
expect(screen.getByRole("button", { name: "Reset all to $1,000.00" })).toBeInTheDocument();
expect(screen.getByText(/new team default of \$1,000\.00/)).toBeInTheDocument();
});
it("routes each choice to the matching action", async () => {
const handlers = defaultHandlers();
const user = userEvent.setup();
render(<ResetMemberBudgetsDialog state={promptingWith(2)} {...handlers} />);
await user.click(screen.getByRole("button", { name: "Reset all to $10.00" }));
expect(handlers.onReset).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole("button", { name: "Keep custom budgets" }));
expect(handlers.onKeep).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(handlers.onDismiss).toHaveBeenCalledTimes(1);
});
it("locks every button while the reset is in flight", () => {
const state: MemberBudgetResetState = { phase: "resetting", pending: pendingFor(2), attempted: 0 };
render(<ResetMemberBudgetsDialog state={state} {...defaultHandlers()} />);
expect(screen.getByRole("button", { name: "Reset all to $10.00" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Keep custom budgets" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
});
it("offers retry or cancel after a failed reset", async () => {
const handlers = defaultHandlers();
const user = userEvent.setup();
const state: MemberBudgetResetState = { phase: "resetFailed", pending: pendingFor(2), attempted: 0 };
render(<ResetMemberBudgetsDialog state={state} {...handlers} />);
await user.click(screen.getByRole("button", { name: "Retry reset" }));
expect(handlers.onRetry).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(handlers.onDismiss).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("button", { name: "Keep custom budgets" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reset all to $10.00" })).not.toBeInTheDocument();
});
it("renders nothing while idle", () => {
render(<ResetMemberBudgetsDialog state={{ phase: "idle" }} {...defaultHandlers()} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,71 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { pluralize } from "./memberBudgetReset";
import type { MemberBudgetResetState } from "./useMemberBudgetReset";
interface ResetMemberBudgetsDialogProps {
state: MemberBudgetResetState;
onReset: () => void;
onRetry: () => void;
onKeep: () => void;
onDismiss: () => void;
}
export default function ResetMemberBudgetsDialog({
state,
onReset,
onRetry,
onKeep,
onDismiss,
}: ResetMemberBudgetsDialogProps) {
const open = state.phase !== "idle";
const busy = state.phase === "resetting";
const failed = state.phase === "resetFailed";
const memberCount = state.phase === "idle" ? 0 : state.pending.userIds.length;
const newBudget = state.phase === "idle" ? 0 : state.pending.newBudget;
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && !busy) onDismiss();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset member budgets?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{memberCount} {pluralize(memberCount, "member has", "members have")} a custom budget, so the new team default
of ${formatNumberWithCommas(newBudget, 2)} will not apply to {pluralize(memberCount, "that member", "them")}.
Reset {pluralize(memberCount, "it", "them")} to the default, or keep the custom{" "}
{pluralize(memberCount, "budget", "budgets")}?
</p>
<DialogFooter>
{failed ? (
<>
<Button variant="outline" onClick={onDismiss}>
Cancel
</Button>
<Button onClick={onRetry}>Retry reset</Button>
</>
) : (
<>
<Button variant="ghost" onClick={onDismiss} disabled={busy}>
Cancel
</Button>
<Button variant="outline" onClick={onKeep} disabled={busy}>
Keep custom {pluralize(memberCount, "budget", "budgets")}
</Button>
<Button onClick={onReset} disabled={busy}>
{pluralize(memberCount, "Reset", "Reset all")} to ${formatNumberWithCommas(newBudget, 2)}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,722 @@
import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
import * as networking from "@/components/networking";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import { toast } from "@/lib/toast";
import type { Team } from "../key_team_helpers/key_list";
import { MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES } from "./memberBudgetReset";
import TeamInfoView, { type TeamData } from "./TeamInfo";
const authState = vi.hoisted(() => ({ userRole: "Admin" }));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({
token: "123",
accessToken: "123",
userId: "user-1",
userEmail: "user@example.com",
userRole: authState.userRole,
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
}),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/components/networking", () => ({
serverRootPath: "",
teamInfoCall: vi.fn(),
teamMemberDeleteCall: vi.fn(),
teamMemberAddCall: vi.fn(),
teamMemberUpdateCall: vi.fn(),
teamUpdateCall: vi.fn(),
getGuardrailsList: vi.fn(),
getPoliciesList: vi.fn(),
getPolicyInfoWithGuardrails: vi.fn(),
fetchMCPAccessGroups: vi.fn(),
getTeamPermissionsCall: vi.fn(),
organizationInfoCall: vi.fn(),
getRouterSettingsCall: vi.fn().mockResolvedValue({ fields: [] }),
getPassThroughEndpointsCall: vi.fn().mockResolvedValue({ endpoints: [] }),
fetchMCPServers: vi.fn().mockResolvedValue([]),
fetchMCPToolsets: vi.fn().mockResolvedValue([]),
listMCPTools: vi.fn().mockResolvedValue({ tools: [] }),
vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }),
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
getClaudeCodePluginsList: vi.fn().mockResolvedValue({ plugins: [], count: 0 }),
}));
const { bulkUpdatePOST } = vi.hoisted(() => ({ bulkUpdatePOST: vi.fn() }));
vi.mock("@/lib/http/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/http/api")>();
return { ...actual, fetchClient: { ...actual.fetchClient, POST: bulkUpdatePOST } };
});
const can = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({
default: (...args: unknown[]) => can(...args),
}));
vi.mock("@/components/utils/dataUtils", () => ({
copyToClipboard: vi.fn().mockResolvedValue(true),
formatNumberWithCommas: vi.fn((value: number) => value.toLocaleString()),
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeamMetadataSchema", () => ({
useTeamMetadataSchema: vi.fn(() => ({ data: [], isLoading: false })),
}));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useAllProxyModels: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeam: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
organizationKeys: { all: ["organizations"] },
useOrganization: vi.fn(),
useOrganizations: vi.fn().mockReturnValue({ data: [], isLoading: false }),
}));
vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({
useCurrentUser: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPServers", () => ({
useMCPServers: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPToolsets", () => ({
useMCPToolsets: vi.fn(),
}));
vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({
default: ({
value,
onChange,
}: {
value?: { servers: string[]; accessGroups: string[]; toolsets?: string[] };
onChange: (next: { servers: string[]; accessGroups: string[]; toolsets: string[] }) => void;
}) => (
<>
<button
type="button"
onClick={() =>
onChange({ servers: [], accessGroups: value?.accessGroups ?? [], toolsets: value?.toolsets ?? [] })
}
>
deselect all mcp servers
</button>
<button type="button" onClick={() => onChange({ servers: value?.servers ?? [], accessGroups: [], toolsets: [] })}>
remove all access groups
</button>
</>
),
}));
vi.mock("@/components/team/TeamMemberTab", () => ({
default: vi.fn(({ setIsAddMemberModalVisible }) => (
<div>
<button onClick={() => setIsAddMemberModalVisible(true)}>Add Member</button>
</div>
)),
}));
vi.mock("@/components/common_components/user_search_modal", () => ({
default: vi.fn(({ isVisible, onCancel, onSubmit }) =>
isVisible ? (
<div>
<button onClick={onCancel}>Cancel</button>
<button onClick={() => onSubmit({ user_email: "new@test.com", user_id: "new-user", role: "user" })}>
Submit
</button>
</div>
) : null,
),
}));
vi.mock("@/components/team/EditMembership", () => ({
default: vi.fn(({ visible, onCancel, onSubmit }) =>
visible ? (
<div>
<button onClick={onCancel}>Cancel</button>
<button onClick={() => onSubmit({ user_email: "edit@test.com", user_id: "edit-user", role: "admin" })}>
Submit
</button>
</div>
) : null,
),
}));
vi.mock("@/components/common_components/DeleteResourceModal", () => ({
default: vi.fn(({ isOpen, onCancel, onOk }) =>
isOpen ? (
<div>
<button onClick={onCancel}>Cancel</button>
<button onClick={onOk}>Confirm Delete</button>
</div>
) : null,
),
}));
vi.mock("@/components/team/member_permissions", () => ({
default: vi.fn(() => <div>Member Permissions</div>),
}));
vi.mock("@/components/common_components/ModelAliasManager", () => ({
default: vi.fn(({ initialModelAliases, onAliasUpdate }) => (
<div>
<div data-testid="alias-editor-initial">{JSON.stringify(initialModelAliases)}</div>
<button type="button" onClick={() => onAliasUpdate({ "gpt-4o": "gpt-4" })}>
Set Alias
</button>
<button type="button" onClick={() => onAliasUpdate({})}>
Clear Aliases
</button>
</div>
)),
}));
vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({
useAccessGroups: vi.fn().mockReturnValue({
data: [
{ access_group_id: "ag-1", access_group_name: "Group 1", access_mcp_server_ids: [] },
{ access_group_id: "ag-2", access_group_name: "Group 2", access_mcp_server_ids: [] },
],
isLoading: false,
isError: false,
}),
}));
vi.mock("@/components/common_components/AccessGroupSelector", () => ({
default: ({ value, onChange }: { value?: string[]; onChange?: (next: string[]) => void }) => (
<button type="button" onClick={() => onChange?.((value ?? []).slice(1))}>
remove first unified access group
</button>
),
}));
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => {
const useKeysResult = {
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 },
isPending: false,
isFetching: false,
refetch: vi.fn(),
};
return { useKeys: vi.fn().mockReturnValue(useKeysResult) };
});
vi.mock("../key_team_helpers/filter_helpers", () => ({
fetchTeamFilterOptions: vi.fn().mockResolvedValue({
keyAliases: [],
organizationIds: [],
userIds: [],
}),
fetchAllKeyAliases: vi.fn().mockResolvedValue([]),
fetchAllOrganizations: vi.fn().mockResolvedValue([]),
}));
import { useAllProxyModels } from "@/app/(dashboard)/hooks/models/useModels";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
import { useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
const mockUseAllProxyModels = vi.mocked(useAllProxyModels);
const mockUseKeys = vi.mocked(useKeys);
const mockUseTeam = vi.mocked(useTeam);
const mockUseOrganization = vi.mocked(useOrganization);
const mockUseCurrentUser = vi.mocked(useCurrentUser);
const mockUseMCPServers = vi.mocked(useMCPServers);
const mockUseMCPToolsets = vi.mocked(useMCPToolsets);
const mockUseAccessGroups = vi.mocked(useAccessGroups);
const mockUseUISettings = vi.mocked(useUISettings);
const createMockTeamData = (overrides = {}) => ({
team_id: "123",
team_info: {
team_alias: "Test Team",
team_id: "123",
organization_id: null,
admins: ["admin@test.com"],
members: ["user1@test.com"],
members_with_roles: [
{
user_id: "user1@test.com",
user_email: "user1@test.com",
role: "member",
spend: 0,
budget_id: "budget1",
},
],
metadata: {},
tpm_limit: null,
rpm_limit: null,
max_budget: null,
budget_duration: null,
models: [],
blocked: false,
spend: 0,
max_parallel_requests: null,
budget_reset_at: null,
model_id: null,
litellm_model_table: null,
created_at: "2024-01-01T00:00:00Z",
team_member_budget_table: null,
guardrails: [],
policies: [],
object_permission: null,
...overrides,
},
keys: [],
team_memberships: [],
});
const seedDefaultMocks = () => {
mockUseAllProxyModels.mockReturnValue({
data: { data: [] },
isLoading: false,
} as unknown as ReturnType<typeof useAllProxyModels>);
mockUseTeam.mockReturnValue({
data: undefined,
isLoading: false,
} as unknown as ReturnType<typeof useTeam>);
mockUseOrganization.mockReturnValue({
data: undefined,
isLoading: false,
} as unknown as ReturnType<typeof useOrganization>);
mockUseCurrentUser.mockReturnValue({
data: { models: [] },
isLoading: false,
} as unknown as ReturnType<typeof useCurrentUser>);
mockUseMCPServers.mockReturnValue({ data: [], isLoading: false, isError: false } as unknown as ReturnType<
typeof useMCPServers
>);
mockUseMCPToolsets.mockReturnValue({ data: [], isLoading: false, isError: false } as unknown as ReturnType<
typeof useMCPToolsets
>);
mockUseAccessGroups.mockReturnValue({
data: [
{ access_group_id: "ag-1", access_group_name: "Group 1", access_mcp_server_ids: [] },
{ access_group_id: "ag-2", access_group_name: "Group 2", access_mcp_server_ids: [] },
],
isLoading: false,
isError: false,
} as unknown as ReturnType<typeof useAccessGroups>);
mockUseUISettings.mockReturnValue({
data: { values: {} },
isLoading: false,
} as unknown as ReturnType<typeof useUISettings>);
mockUseKeys.mockReturnValue({
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 },
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useKeys>);
vi.mocked(useTeamMetadataSchema).mockReturnValue({
data: [],
isLoading: false,
} as unknown as ReturnType<typeof useTeamMetadataSchema>);
can.mockReturnValue(true);
vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] });
vi.mocked(networking.getPoliciesList).mockResolvedValue({ policies: [] });
vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
vi.mocked(networking.getTeamPermissionsCall).mockResolvedValue({
all_available_permissions: [],
team_member_permissions: [],
});
};
describe("TeamInfoView - member budget reset prompt", () => {
const props = {
teamId: "123",
onUpdate: vi.fn(),
onClose: vi.fn(),
accessToken: "test-token",
is_team_admin: true,
is_proxy_admin: true,
userModels: ["gpt-4", "gpt-3.5-turbo"],
editTeam: false,
premiumUser: false,
};
const customBudgetMembership = (
userId: string,
maxBudget: number | null = 50,
): TeamData["team_memberships"][number] => ({
user_id: userId,
team_id: "123",
budget_id: `budget-${userId}`,
budget_source: "custom",
spend: 0,
total_spend: 0,
litellm_budget_table: {
budget_id: `budget-${userId}`,
soft_budget: null,
max_budget: maxBudget,
max_parallel_requests: null,
tpm_limit: null,
rpm_limit: null,
model_max_budget: null,
budget_duration: null,
budget_reset_at: null,
},
});
const savedTeam: Team = {
team_id: "123",
team_alias: "Test Team",
models: [],
max_budget: null,
budget_duration: null,
tpm_limit: null,
rpm_limit: null,
organization_id: "org-1",
created_at: "2024-01-01T00:00:00Z",
keys: [],
members_with_roles: [],
spend: 0,
};
const openEditorWithCustomMembers = async (
user: ReturnType<typeof userEvent.setup>,
userIds: string[] = ["user-custom"],
maxBudget: number | null = 50,
) => {
const data = {
...createMockTeamData({
team_member_budget_table: { max_budget: 10, budget_duration: null, tpm_limit: null, rpm_limit: null },
}),
team_memberships: userIds.map((id) => customBudgetMembership(id, maxBudget)),
} as TeamData;
vi.mocked(networking.teamInfoCall).mockResolvedValue(data);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: savedTeam, team_id: "123" });
renderWithProviders(<TeamInfoView {...props} />);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await user.click(screen.getByText("Team Member Settings"));
return await screen.findByLabelText("Default Budget (USD)");
};
const submitNewDefault = async (user: ReturnType<typeof userEvent.setup>, input: HTMLElement, value: string) => {
fireEvent.change(input, { target: { value } });
await user.click(screen.getByRole("button", { name: /save changes/i }));
};
beforeEach(() => {
seedDefaultMocks();
bulkUpdatePOST.mockResolvedValue({ data: { data: [] } });
});
afterEach(() => {
vi.clearAllMocks();
});
it("holds the save behind the prompt when the default changes while a member has a custom budget", async () => {
const user = userEvent.setup({ delay: null });
const input = await openEditorWithCustomMembers(user);
await submitNewDefault(user, input, "20");
expect(await screen.findByText("Reset member budgets?")).toBeInTheDocument();
expect(screen.getByText(/1 member has a custom budget/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Reset to $20.00" })).toBeInTheDocument();
expect(networking.teamUpdateCall).not.toHaveBeenCalled();
expect(bulkUpdatePOST).not.toHaveBeenCalled();
});
it("saves the new default and leaves custom budgets alone on keep", async () => {
const user = userEvent.setup({ delay: null });
const input = await openEditorWithCustomMembers(user);
await submitNewDefault(user, input, "20");
await user.click(await screen.findByRole("button", { name: "Keep custom budget" }));
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1].team_member_budget).toBe(20);
expect(bulkUpdatePOST).not.toHaveBeenCalled();
await waitFor(() => expect(screen.queryByText("Reset member budgets?")).not.toBeInTheDocument());
});
it("saves the new default then clears each custom budget through the bulk endpoint on reset all", async () => {
const user = userEvent.setup({ delay: null });
const input = await openEditorWithCustomMembers(user, ["user-a", "user-b"]);
bulkUpdatePOST.mockResolvedValue({
data: {
data: [
{ success: true, user_id: "user-a" },
{ success: true, user_id: "user-b" },
],
},
});
await submitNewDefault(user, input, "20");
await user.click(await screen.findByRole("button", { name: "Reset all to $20.00" }));
await waitFor(() => expect(bulkUpdatePOST).toHaveBeenCalledTimes(1));
expect(networking.teamUpdateCall).toHaveBeenCalled();
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1].team_member_budget).toBe(20);
expect(bulkUpdatePOST).toHaveBeenCalledWith("/management/v1/teams/{team_id}/members/bulk_update", {
params: { path: { team_id: "123" } },
body: {
members: [
{ user_id: "user-a", max_budget_in_team: null },
{ user_id: "user-b", max_budget_in_team: null },
],
},
});
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Reset 2 member budgets to the team default"));
});
it("keeps the prompt mounted while the member-budget reset is in flight", async () => {
const user = userEvent.setup({ delay: null });
const input = await openEditorWithCustomMembers(user);
const bulk = Promise.withResolvers<{ data: { data: { success: boolean; user_id: string }[] } }>();
vi.mocked(networking.teamInfoCall).mockImplementationOnce(() => new Promise(() => {}));
bulkUpdatePOST.mockImplementationOnce(() => bulk.promise);
await submitNewDefault(user, input, "20");
await user.click(await screen.findByRole("button", { name: "Reset to $20.00" }));
await waitFor(() => expect(bulkUpdatePOST).toHaveBeenCalled());
expect(screen.getByText("Reset member budgets?")).toBeInTheDocument();
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
bulk.resolve({ data: { data: [{ success: true, user_id: "user-custom" }] } });
await waitFor(() => expect(screen.queryByText("Reset member budgets?")).not.toBeInTheDocument());
});
it("offers a retry after a failed reset and never re-saves the team", async () => {
const user = userEvent.setup({ delay: null });
const input = await openEditorWithCustomMembers(user);
bulkUpdatePOST
.mockRejectedValueOnce(new Error("bulk update down"))
.mockResolvedValueOnce({ data: { data: [{ success: true, user_id: "user-custom" }] } });
await submitNewDefault(user, input, "20");
await user.click(await screen.findByRole("button", { name: "Reset to $20.00" }));
await waitFor(() =>
expect(toast.fromError).toHaveBeenCalledWith("Team updated, but member budgets could not be reset"),
);
expect(screen.getByText("Reset member budgets?")).toBeInTheDocument();
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
await waitFor(() => expect(vi.mocked(networking.teamInfoCall).mock.calls.length).toBeGreaterThan(1));
await user.click(screen.getByRole("button", { name: "Retry reset" }));
await waitFor(() => expect(bulkUpdatePOST).toHaveBeenCalledTimes(2));
expect(vi.mocked(networking.teamUpdateCall).mock.calls).toHaveLength(1);
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Reset 1 member budget to the team default"));
await waitFor(() => expect(screen.queryByText("Reset member budgets?")).not.toBeInTheDocument());
});
it("returns to the prompt without its own toast when the team save fails", async () => {
const user = userEvent.setup({ delay: null });
const input = await openEditorWithCustomMembers(user);
vi.mocked(networking.teamUpdateCall).mockRejectedValueOnce(new Error("save failed"));
await submitNewDefault(user, input, "20");
await user.click(await screen.findByRole("button", { name: "Reset to $20.00" }));
expect(await screen.findByRole("button", { name: "Reset to $20.00" })).toBeEnabled();
expect(bulkUpdatePOST).not.toHaveBeenCalled();
expect(toast.fromError).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});
it("reports how many budgets were already reset when a later batch fails", async () => {
const user = userEvent.setup({ delay: null });
const userIds = Array.from({ length: MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES + 1 }, (_, i) => `user-${i}`);
const input = await openEditorWithCustomMembers(user, userIds);
bulkUpdatePOST
.mockResolvedValueOnce({
data: {
data: userIds.slice(0, MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES).map((user_id) => ({ success: true, user_id })),
},
})
.mockRejectedValueOnce(new Error("second batch down"))
.mockResolvedValueOnce({
data: { data: [{ success: true, user_id: `user-${MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES}` }] },
});
await submitNewDefault(user, input, "20");
await user.click(await screen.findByRole("button", { name: "Reset all to $20.00" }));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(
`Reset ${MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES} of ${MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES + 1} member budgets; the rest could not be reset`,
),
);
expect(bulkUpdatePOST).toHaveBeenCalledTimes(2);
await user.click(screen.getByRole("button", { name: "Retry reset" }));
await waitFor(() => expect(bulkUpdatePOST).toHaveBeenCalledTimes(3));
expect(vi.mocked(bulkUpdatePOST).mock.calls[2][1]).toEqual({
params: { path: { team_id: "123" } },
body: {
members: [{ user_id: `user-${MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES}`, max_budget_in_team: null }],
},
});
expect(vi.mocked(networking.teamUpdateCall).mock.calls).toHaveLength(1);
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith(
`Reset ${MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES + 1} member budgets to the team default`,
),
);
});
it("surfaces a failure toast when some members cannot be reset", async () => {
const user = userEvent.setup({ delay: null });
const input = await openEditorWithCustomMembers(user, ["user-a", "user-b"]);
bulkUpdatePOST.mockResolvedValue({
data: {
data: [
{ success: true, user_id: "user-a" },
{ success: false, user_id: "user-b", error: "no such member" },
],
},
});
await submitNewDefault(user, input, "20");
await user.click(await screen.findByRole("button", { name: "Reset all to $20.00" }));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Team updated, but 1 member budget could not be reset"),
);
});
it("aborts the save entirely on cancel", async () => {
const user = userEvent.setup({ delay: null });
const input = await openEditorWithCustomMembers(user);
await submitNewDefault(user, input, "20");
await user.click(await screen.findByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByText("Reset member budgets?")).not.toBeInTheDocument());
expect(networking.teamUpdateCall).not.toHaveBeenCalled();
expect(bulkUpdatePOST).not.toHaveBeenCalled();
});
it("saves directly when no member carries a custom budget", async () => {
const user = userEvent.setup({ delay: null });
const input = await openEditorWithCustomMembers(user, []);
await submitNewDefault(user, input, "20");
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1].team_member_budget).toBe(20);
expect(screen.queryByText("Reset member budgets?")).not.toBeInTheDocument();
expect(bulkUpdatePOST).not.toHaveBeenCalled();
});
it("saves directly when the default is resubmitted unchanged", async () => {
const user = userEvent.setup({ delay: null });
await openEditorWithCustomMembers(user);
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1].team_member_budget).toBe(10);
expect(screen.queryByText("Reset member budgets?")).not.toBeInTheDocument();
expect(bulkUpdatePOST).not.toHaveBeenCalled();
});
it("dismisses a pending prompt when the selected team changes", async () => {
const user = userEvent.setup({ delay: null });
const data = {
...createMockTeamData({
team_member_budget_table: { max_budget: 10, budget_duration: null, tpm_limit: null, rpm_limit: null },
}),
team_memberships: [customBudgetMembership("user-custom")],
} as TeamData;
vi.mocked(networking.teamInfoCall).mockResolvedValue(data);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: savedTeam, team_id: "123" });
const { rerender } = renderWithProviders(<TeamInfoView {...props} />);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await user.click(screen.getByText("Team Member Settings"));
const input = await screen.findByLabelText("Default Budget (USD)");
await submitNewDefault(user, input, "20");
expect(await screen.findByText("Reset member budgets?")).toBeInTheDocument();
rerender(<TeamInfoView {...props} teamId="456" />);
await waitFor(() => expect(screen.queryByText("Reset member budgets?")).not.toBeInTheDocument());
expect(bulkUpdatePOST).not.toHaveBeenCalled();
});
it("does not reopen the dialog or refetch the old team when the team changes mid-reset", async () => {
const user = userEvent.setup({ delay: null });
const data = {
...createMockTeamData({
team_member_budget_table: { max_budget: 10, budget_duration: null, tpm_limit: null, rpm_limit: null },
}),
team_memberships: [customBudgetMembership("user-custom")],
} as TeamData;
vi.mocked(networking.teamInfoCall).mockResolvedValue(data);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: savedTeam, team_id: "123" });
const bulkDone = Promise.withResolvers<{ data: { data: { success: boolean; user_id: string }[] } }>();
bulkUpdatePOST.mockReturnValue(bulkDone.promise);
const { rerender } = renderWithProviders(<TeamInfoView {...props} />);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await user.click(screen.getByText("Team Member Settings"));
const input = await screen.findByLabelText("Default Budget (USD)");
await submitNewDefault(user, input, "20");
await user.click(await screen.findByRole("button", { name: "Reset to $20.00" }));
await waitFor(() => expect(bulkUpdatePOST).toHaveBeenCalled());
const infoCallsBeforeSwitch = vi.mocked(networking.teamInfoCall).mock.calls.length;
rerender(<TeamInfoView {...props} teamId="456" />);
bulkDone.resolve({ data: { data: [{ success: true, user_id: "user-custom" }] } });
await waitFor(() => expect(screen.queryByText("Reset member budgets?")).not.toBeInTheDocument());
await waitFor(() =>
expect(
vi
.mocked(networking.teamInfoCall)
.mock.calls.slice(infoCallsBeforeSwitch)
.some((call) => call[1] === "456"),
).toBe(true),
);
const infoCallsAfterSwitch = vi.mocked(networking.teamInfoCall).mock.calls.slice(infoCallsBeforeSwitch);
expect(infoCallsAfterSwitch.every((call) => call[1] === "456")).toBe(true);
expect(screen.queryByRole("button", { name: "Retry reset" })).not.toBeInTheDocument();
});
it("saves directly when a custom member's cap is null and already inherits the default", async () => {
const user = userEvent.setup({ delay: null });
const input = await openEditorWithCustomMembers(user, ["user-limits-only"], null);
await submitNewDefault(user, input, "20");
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1].team_member_budget).toBe(20);
expect(screen.queryByText("Reset member budgets?")).not.toBeInTheDocument();
expect(bulkUpdatePOST).not.toHaveBeenCalled();
});
});

View file

@ -55,6 +55,12 @@ vi.mock("@/components/networking", () => ({
getClaudeCodePluginsList: vi.fn().mockResolvedValue({ plugins: [], count: 0 }),
}));
const { bulkUpdatePOST } = vi.hoisted(() => ({ bulkUpdatePOST: vi.fn() }));
vi.mock("@/lib/http/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/http/api")>();
return { ...actual, fetchClient: { ...actual.fetchClient, POST: bulkUpdatePOST } };
});
const can = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({
default: (...args: unknown[]) => can(...args),

View file

@ -119,6 +119,10 @@ import {
} from "./tabVisibilityUtils";
import TeamMembersComponent from "./TeamMemberTab";
import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable";
import ResetMemberBudgetsDialog from "./ResetMemberBudgetsDialog";
import { customBudgetMemberUserIds, shouldPromptMemberBudgetReset } from "./memberBudgetReset";
import { useMemberBudgetReset } from "./useMemberBudgetReset";
import { fetchClient } from "@/lib/http/api";
const UI_MANAGED_METADATA_KEYS: ReadonlySet<string> = new Set([
"logging",
@ -884,17 +888,41 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const persistTeamUpdate = async (token: string, updateData: Record<string, unknown>) => {
await teamUpdateCall(token, updateData);
queryClient.invalidateQueries({ queryKey: organizationKeys.all });
toast.success("Team settings updated successfully");
setIsEditing(false);
fetchTeamInfo();
};
const memberBudgetReset = useMemberBudgetReset({
saveTeam: async (updateData) => {
if (!accessToken) return;
setIsTeamSaving(true);
try {
await persistTeamUpdate(accessToken, updateData);
} finally {
setIsTeamSaving(false);
}
},
resetMemberBudgets: async (bulkTeamId, userIds) => {
const { data } = await fetchClient.POST("/management/v1/teams/{team_id}/members/bulk_update", {
params: { path: { team_id: bulkTeamId } },
body: { members: userIds.map((user_id) => ({ user_id, max_budget_in_team: null })) },
});
return data?.data ?? [];
},
refreshTeamData,
});
const { dismiss: dismissMemberBudgetReset } = memberBudgetReset;
useEffect(() => {
dismissMemberBudgetReset();
}, [teamId, dismissMemberBudgetReset]);
const saveTeamAdminSettings = async (changes: TeamAdminSettingsChanges) => {
if (!accessToken) return;
setIsTeamSaving(true);
try {
await persistTeamUpdate(accessToken, { team_id: teamId, ...changes });
toast.success("Team settings updated successfully");
await fetchTeamInfo();
} catch (error) {
console.error("Error updating team:", error);
} finally {
@ -1005,8 +1033,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
updateData.max_budget = mapEmptyStringToNull(updateData.max_budget);
updateData.team_member_budget_duration = values.team_member_budget_duration;
if (values.team_member_budget !== undefined) {
updateData.team_member_budget = Number(values.team_member_budget);
const newTeamMemberBudget =
values.team_member_budget !== undefined ? Number(values.team_member_budget) : undefined;
if (newTeamMemberBudget !== undefined) {
updateData.team_member_budget = newTeamMemberBudget;
}
if (values.team_member_key_duration !== undefined) {
@ -1152,7 +1182,28 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
}
}
const customBudgetUserIds = customBudgetMemberUserIds(teamData?.team_memberships ?? []);
if (
newTeamMemberBudget !== undefined &&
shouldPromptMemberBudgetReset(
newTeamMemberBudget,
info.team_member_budget_table?.max_budget,
customBudgetUserIds,
)
) {
const pendingReset = {
teamId,
updateData,
userIds: customBudgetUserIds,
newBudget: newTeamMemberBudget,
};
memberBudgetReset.prompt(pendingReset);
return;
}
await persistTeamUpdate(accessToken, updateData);
toast.success("Team settings updated successfully");
await fetchTeamInfo();
} catch (error) {
console.error("Error updating team:", error);
} finally {
@ -2426,6 +2477,14 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
onOk={handleDeleteConfirm}
confirmLoading={isDeleting}
/>
<ResetMemberBudgetsDialog
state={memberBudgetReset.state}
onReset={memberBudgetReset.reset}
onRetry={memberBudgetReset.retry}
onKeep={memberBudgetReset.keepCustom}
onDismiss={memberBudgetReset.dismiss}
/>
</div>
);
};

View file

@ -682,6 +682,44 @@ describe("TeamMembersComponent", () => {
expect(inheritedRow).toHaveTextContent("$25.00");
});
it("caps a Custom member at the team default when the private row has no budget limit", () => {
const base = createMockTeamData();
renderTab(
createMockTeamData({
team_info: {
...base.team_info,
team_member_budget_table: { max_budget: 20, budget_duration: null, tpm_limit: null, rpm_limit: null },
},
team_memberships: [
{
user_id: "user2@test.com",
team_id: "team-123",
budget_id: "budget2",
budget_source: "custom",
spend: 0,
total_spend: 0,
litellm_budget_table: {
budget_id: "budget3",
soft_budget: null,
max_budget: null,
max_parallel_requests: null,
tpm_limit: null,
rpm_limit: 100,
model_max_budget: null,
budget_duration: null,
budget_reset_at: null,
},
},
],
}),
);
const row = screen.getByRole("row", { name: /user2@test\.com/ });
expect(within(row).getByTestId("member-budget-source")).toHaveTextContent("Custom");
expect(row).toHaveTextContent("$20.00");
expect(row).not.toHaveTextContent("Unlimited");
});
it("shows no source label for a member with neither a custom nor a team budget", () => {
renderTab(createMockTeamData({ team_memberships: [] }));

View file

@ -106,10 +106,7 @@ export default function TeamMemberTab({
const getUserBudget = (userId: string | null): number | null => {
if (!userId) return null;
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
return (
membership?.litellm_budget_table?.max_budget ??
(membership?.budget_source === "team_default" ? teamDefaultBudget : null)
);
return membership?.litellm_budget_table?.max_budget ?? teamDefaultBudget;
};
// Helper function to get rate limits for a user

View file

@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import {
chunk,
customBudgetMemberUserIds,
MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES,
pluralize,
shouldPromptMemberBudgetReset,
} from "./memberBudgetReset";
describe("customBudgetMemberUserIds", () => {
const customRow = (user_id: string, max_budget: number | null = 50) => ({
user_id,
budget_source: "custom" as const,
litellm_budget_table: { max_budget },
});
it("returns only members whose budget_source is custom with a private cap", () => {
const memberships = [
customRow("u-custom"),
{ user_id: "u-default", budget_source: "team_default" as const },
{ user_id: "u-none", budget_source: "none" as const },
];
expect(customBudgetMemberUserIds(memberships)).toEqual(["u-custom"]);
});
it("skips a custom row whose cap is null since it already inherits the default", () => {
const memberships = [customRow("u-rate-limits-only", null), customRow("u-custom")];
expect(customBudgetMemberUserIds(memberships)).toEqual(["u-custom"]);
});
it("skips a custom row that has no budget table at all", () => {
const memberships = [
{ user_id: "u-no-table", budget_source: "custom" as const, litellm_budget_table: null },
customRow("u-custom"),
];
expect(customBudgetMemberUserIds(memberships)).toEqual(["u-custom"]);
});
});
describe("shouldPromptMemberBudgetReset", () => {
it("prompts when the default changes and custom-budget members exist", () => {
expect(shouldPromptMemberBudgetReset(10, 5, ["u-1"])).toBe(true);
});
it("prompts when a default is set for the first time", () => {
expect(shouldPromptMemberBudgetReset(10, null, ["u-1"])).toBe(true);
expect(shouldPromptMemberBudgetReset(10, undefined, ["u-1"])).toBe(true);
});
it("does not prompt when the submitted budget is unchanged", () => {
expect(shouldPromptMemberBudgetReset(10, 10, ["u-1"])).toBe(false);
});
it("does not prompt when the budget is cleared or zero", () => {
expect(shouldPromptMemberBudgetReset(undefined, 10, ["u-1"])).toBe(false);
expect(shouldPromptMemberBudgetReset(0, 10, ["u-1"])).toBe(false);
});
it("does not prompt when no member has a custom budget", () => {
expect(shouldPromptMemberBudgetReset(10, 5, [])).toBe(false);
});
});
describe("chunk", () => {
it("splits selections larger than the bulk endpoint limit", () => {
const ids = Array.from({ length: MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES + 1 }, (_, i) => `u-${i}`);
const chunks = chunk(ids, MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES);
expect(chunks).toHaveLength(2);
expect(chunks[0]).toHaveLength(MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES);
expect(chunks[1]).toEqual([`u-${MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES}`]);
});
it("keeps a selection under the limit in a single chunk", () => {
expect(chunk(["u-1", "u-2"], MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES)).toEqual([["u-1", "u-2"]]);
});
it("returns no chunks for an empty selection", () => {
expect(chunk([], MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES)).toEqual([]);
});
it("returns no chunks for a non-positive size", () => {
expect(chunk(["u-1"], 0)).toEqual([]);
expect(chunk(["u-1"], -3)).toEqual([]);
});
});
describe("pluralize", () => {
it("uses the singular form for exactly one", () => {
expect(pluralize(1, "budget", "budgets")).toBe("budget");
});
it("uses the plural form for zero and for more than one", () => {
expect(pluralize(0, "budget", "budgets")).toBe("budgets");
expect(pluralize(3, "budget", "budgets")).toBe("budgets");
});
});

View file

@ -0,0 +1,38 @@
import type { components } from "@/lib/http/schema";
import type { TeamMembership } from "./TeamInfo";
export const MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES = 500;
export type TeamUpdatePayload = components["schemas"]["UpdateTeamRequest"];
export interface MemberBudgetResetPending {
readonly teamId: string;
readonly updateData: TeamUpdatePayload;
readonly userIds: readonly string[];
readonly newBudget: number;
}
type MembershipBudgetRow = Pick<TeamMembership, "user_id" | "budget_source"> & {
litellm_budget_table?: Pick<TeamMembership["litellm_budget_table"], "max_budget"> | null;
};
export const customBudgetMemberUserIds = (memberships: readonly MembershipBudgetRow[]): string[] =>
memberships
.filter((m) => m.budget_source === "custom" && m.litellm_budget_table?.max_budget != null)
.map((m) => m.user_id);
export const shouldPromptMemberBudgetReset = (
nextBudget: number | undefined,
previousBudget: number | null | undefined,
customBudgetUserIds: string[],
): boolean => {
const budgetChanged = typeof nextBudget === "number" && nextBudget > 0 && nextBudget !== previousBudget;
return budgetChanged && customBudgetUserIds.length > 0;
};
export const chunk = <T>(items: readonly T[], size: number): T[][] => {
if (size <= 0) return [];
return Array.from({ length: Math.ceil(items.length / size) }, (_, i) => items.slice(i * size, i * size + size));
};
export const pluralize = (count: number, singular: string, plural: string): string => (count === 1 ? singular : plural);

View file

@ -0,0 +1,345 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { toast } from "@/lib/toast";
import { MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES } from "./memberBudgetReset";
import { useMemberBudgetReset, type MemberBudgetResetGateway } from "./useMemberBudgetReset";
const buildGateway = () => ({
saveTeam: vi.fn(async (_updateData: Record<string, unknown>) => {}),
resetMemberBudgets: vi.fn(async (_teamId: string, userIds: readonly string[]) =>
userIds.map((user_id) => ({ success: true, user_id })),
),
refreshTeamData: vi.fn(async () => {}),
});
const pendingFor = (userIds: string[]) => ({
teamId: "team-123",
updateData: { team_id: "team-123", team_member_budget: 20 },
userIds,
newBudget: 20,
});
const renderReset = (gateway: MemberBudgetResetGateway) => renderHook(() => useMemberBudgetReset(gateway));
describe("useMemberBudgetReset", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("starts idle and prompts with the pending update", async () => {
const gateway = buildGateway();
const { result } = renderReset(gateway);
expect(result.current.state.phase).toBe("idle");
await act(async () => {
result.current.prompt(pendingFor(["u-1"]));
});
expect(result.current.state).toEqual({
phase: "prompting",
pending: pendingFor(["u-1"]),
});
expect(gateway.saveTeam).not.toHaveBeenCalled();
});
it("saves the team once, resets every member, and refreshes on success", async () => {
const gateway = buildGateway();
const { result } = renderReset(gateway);
await act(async () => {
result.current.prompt(pendingFor(["u-1", "u-2"]));
});
await act(async () => {
await result.current.reset();
});
expect(gateway.saveTeam).toHaveBeenCalledTimes(1);
expect(gateway.saveTeam).toHaveBeenCalledWith({ team_id: "team-123", team_member_budget: 20 });
expect(gateway.resetMemberBudgets).toHaveBeenCalledWith("team-123", ["u-1", "u-2"]);
expect(toast.success).toHaveBeenCalledWith("Reset 2 member budgets to the team default");
expect(gateway.refreshTeamData).toHaveBeenCalledTimes(1);
expect(result.current.state.phase).toBe("idle");
});
it("resets members in batches no larger than the bulk endpoint limit", async () => {
const userIds = Array.from({ length: MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES + 1 }, (_, i) => `u-${i}`);
const gateway = buildGateway();
const { result } = renderReset(gateway);
await act(async () => {
result.current.prompt(pendingFor(userIds));
});
await act(async () => {
await result.current.reset();
});
expect(gateway.resetMemberBudgets).toHaveBeenCalledTimes(2);
expect(gateway.resetMemberBudgets.mock.calls[0][0]).toBe("team-123");
expect(gateway.resetMemberBudgets.mock.calls[0][1]).toHaveLength(MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES);
expect(gateway.resetMemberBudgets.mock.calls[1][1]).toEqual([`u-${MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES}`]);
expect(result.current.state.phase).toBe("idle");
});
it("resets against the team carried by the pending update", async () => {
const gateway = buildGateway();
const { result } = renderReset(gateway);
const pending = {
teamId: "team-999",
updateData: { team_id: "team-999", team_member_budget: 20 },
userIds: ["u-1"],
newBudget: 20,
};
await act(async () => {
result.current.prompt(pending);
});
await act(async () => {
await result.current.reset();
});
expect(gateway.resetMemberBudgets).toHaveBeenCalledWith("team-999", ["u-1"]);
expect(gateway.resetMemberBudgets).not.toHaveBeenCalledWith("team-123", expect.anything());
});
it("returns to prompting without its own toast when the team save fails", async () => {
const gateway = buildGateway();
gateway.saveTeam.mockRejectedValueOnce(new Error("team update failed"));
const { result } = renderReset(gateway);
await act(async () => {
result.current.prompt(pendingFor(["u-1"]));
});
await act(async () => {
await result.current.reset();
});
expect(result.current.state.phase).toBe("prompting");
expect(gateway.resetMemberBudgets).not.toHaveBeenCalled();
expect(gateway.refreshTeamData).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
expect(toast.fromError).not.toHaveBeenCalled();
expect(toast.success).not.toHaveBeenCalled();
});
it("retries only the unsent members when a later batch fails, without re-saving the team", async () => {
const userIds = Array.from({ length: MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES + 1 }, (_, i) => `u-${i}`);
const gateway = buildGateway();
gateway.resetMemberBudgets
.mockResolvedValueOnce(
userIds.slice(0, MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES).map((user_id) => ({ success: true, user_id })),
)
.mockRejectedValueOnce(new Error("second batch failed"));
const { result } = renderReset(gateway);
await act(async () => {
result.current.prompt(pendingFor(userIds));
});
await act(async () => {
await result.current.reset();
});
expect(result.current.state.phase).toBe("resetFailed");
expect(toast.error).toHaveBeenCalledWith(
`Reset ${MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES} of ${MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES + 1} member budgets; the rest could not be reset`,
);
gateway.resetMemberBudgets.mockResolvedValueOnce([
{ success: true, user_id: `u-${MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES}` },
]);
await act(async () => {
await result.current.retry();
});
expect(gateway.saveTeam).toHaveBeenCalledTimes(1);
expect(gateway.resetMemberBudgets).toHaveBeenCalledTimes(3);
expect(gateway.resetMemberBudgets.mock.calls[2][0]).toBe("team-123");
expect(gateway.resetMemberBudgets.mock.calls[2][1]).toEqual([`u-${MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES}`]);
expect(toast.success).toHaveBeenCalledWith(
`Reset ${MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES + 1} member budgets to the team default`,
);
expect(result.current.state.phase).toBe("idle");
});
it("reports partial progress when the first batch fails", async () => {
const gateway = buildGateway();
gateway.resetMemberBudgets.mockRejectedValueOnce(new Error("batch failed"));
const { result } = renderReset(gateway);
await act(async () => {
result.current.prompt(pendingFor(["u-1", "u-2"]));
});
await act(async () => {
await result.current.reset();
});
expect(result.current.state.phase).toBe("resetFailed");
expect(toast.fromError).toHaveBeenCalledWith("Team updated, but member budgets could not be reset");
expect(gateway.refreshTeamData).toHaveBeenCalledTimes(1);
});
it("reports members the backend could not reset and closes", async () => {
const gateway = buildGateway();
gateway.resetMemberBudgets.mockResolvedValueOnce([
{ success: true, user_id: "u-1" },
{ success: false, user_id: "u-2" },
]);
const { result } = renderReset(gateway);
await act(async () => {
result.current.prompt(pendingFor(["u-1", "u-2"]));
});
await act(async () => {
await result.current.reset();
});
expect(toast.error).toHaveBeenCalledWith("Team updated, but 1 member budget could not be reset");
expect(toast.success).not.toHaveBeenCalled();
expect(result.current.state.phase).toBe("idle");
expect(gateway.refreshTeamData).toHaveBeenCalledTimes(1);
});
it("saves the team once and closes on keep custom", async () => {
const gateway = buildGateway();
const { result } = renderReset(gateway);
await act(async () => {
result.current.prompt(pendingFor(["u-1"]));
});
await act(async () => {
await result.current.keepCustom();
});
expect(gateway.saveTeam).toHaveBeenCalledTimes(1);
expect(toast.success).toHaveBeenCalledWith("Team settings updated successfully");
expect(gateway.resetMemberBudgets).not.toHaveBeenCalled();
expect(gateway.refreshTeamData).toHaveBeenCalledTimes(1);
expect(result.current.state.phase).toBe("idle");
});
it("abandons the pending update on dismiss", async () => {
const gateway = buildGateway();
const { result } = renderReset(gateway);
await act(async () => {
result.current.prompt(pendingFor(["u-1"]));
});
await act(async () => {
result.current.dismiss();
});
expect(result.current.state.phase).toBe("idle");
expect(gateway.saveTeam).not.toHaveBeenCalled();
expect(gateway.resetMemberBudgets).not.toHaveBeenCalled();
});
it("skips the refresh and stays idle when dismissed while a reset is in flight", async () => {
const gateway = buildGateway();
const bulkDone = Promise.withResolvers<{ success: boolean; user_id: string }[]>();
gateway.resetMemberBudgets.mockReturnValueOnce(bulkDone.promise);
const { result } = renderReset(gateway);
await act(async () => {
result.current.prompt(pendingFor(["u-1"]));
});
let resetPromise = Promise.resolve();
await act(async () => {
resetPromise = result.current.reset();
});
expect(result.current.state.phase).toBe("resetting");
act(() => {
result.current.dismiss();
});
await act(async () => {
bulkDone.resolve([{ success: true, user_id: "u-1" }]);
await resetPromise;
});
expect(result.current.state.phase).toBe("idle");
expect(gateway.refreshTeamData).not.toHaveBeenCalled();
expect(toast.success).not.toHaveBeenCalled();
});
it("does not return to prompting when the team save fails after a dismiss", async () => {
const gateway = buildGateway();
const saveDone = Promise.withResolvers<void>();
gateway.saveTeam.mockReturnValueOnce(saveDone.promise);
const { result } = renderReset(gateway);
await act(async () => {
result.current.prompt(pendingFor(["u-1"]));
});
let resetPromise = Promise.resolve();
await act(async () => {
resetPromise = result.current.reset();
});
act(() => {
result.current.dismiss();
});
await act(async () => {
saveDone.reject(new Error("team update failed"));
await resetPromise;
});
expect(result.current.state.phase).toBe("idle");
expect(gateway.resetMemberBudgets).not.toHaveBeenCalled();
expect(gateway.refreshTeamData).not.toHaveBeenCalled();
});
it("stays idle without a refresh when the reset fails after a dismiss", async () => {
const gateway = buildGateway();
const bulkDone = Promise.withResolvers<{ success: boolean; user_id: string }[]>();
gateway.resetMemberBudgets.mockReturnValueOnce(bulkDone.promise);
const { result } = renderReset(gateway);
await act(async () => {
result.current.prompt(pendingFor(["u-1"]));
});
let resetPromise = Promise.resolve();
await act(async () => {
resetPromise = result.current.reset();
});
act(() => {
result.current.dismiss();
});
await act(async () => {
bulkDone.reject(new Error("bulk failed"));
await resetPromise;
});
expect(result.current.state.phase).toBe("idle");
expect(gateway.refreshTeamData).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
expect(toast.fromError).not.toHaveBeenCalled();
});
it("skips the refresh when dismissed while keep-custom is saving", async () => {
const gateway = buildGateway();
const saveDone = Promise.withResolvers<void>();
gateway.saveTeam.mockReturnValueOnce(saveDone.promise);
const { result } = renderReset(gateway);
await act(async () => {
result.current.prompt(pendingFor(["u-1"]));
});
let keepPromise = Promise.resolve();
await act(async () => {
keepPromise = result.current.keepCustom();
});
act(() => {
result.current.dismiss();
});
await act(async () => {
saveDone.resolve();
await keepPromise;
});
expect(result.current.state.phase).toBe("idle");
expect(gateway.refreshTeamData).not.toHaveBeenCalled();
expect(toast.success).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,134 @@
import { useCallback, useRef, useState } from "react";
import type { components } from "@/lib/http/schema";
import { toast } from "@/lib/toast";
import {
chunk,
MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES,
pluralize,
type MemberBudgetResetPending,
type TeamUpdatePayload,
} from "./memberBudgetReset";
export type MemberBudgetBulkResult = components["schemas"]["TeamMemberBudgetUpdateResult"];
export type MemberBudgetResetState =
| { phase: "idle" }
| { phase: "prompting"; pending: MemberBudgetResetPending }
| { phase: "resetting"; pending: MemberBudgetResetPending; attempted: number }
| { phase: "resetFailed"; pending: MemberBudgetResetPending; attempted: number };
export interface MemberBudgetResetGateway {
saveTeam: (updateData: TeamUpdatePayload) => Promise<void>;
resetMemberBudgets: (teamId: string, userIds: readonly string[]) => Promise<MemberBudgetBulkResult[]>;
refreshTeamData: () => Promise<void>;
}
type ResetRun =
| { ok: true; attempted: number; results: readonly MemberBudgetBulkResult[] }
| { ok: false; attempted: number; error: unknown };
export const useMemberBudgetReset = (gateway: MemberBudgetResetGateway) => {
const [state, setState] = useState<MemberBudgetResetState>({ phase: "idle" });
const activeRun = useRef<object | null>(null);
const isCurrent = (run: object) => activeRun.current === run;
const runReset = async (pending: MemberBudgetResetPending, attempted: number, run: object) => {
const { resetMemberBudgets, refreshTeamData } = gateway;
const runChunks = (
chunks: readonly (readonly string[])[],
attemptedSoFar: number,
results: readonly MemberBudgetBulkResult[],
): Promise<ResetRun> => {
const [ids, ...rest] = chunks;
if (ids === undefined) return Promise.resolve({ ok: true, attempted: attemptedSoFar, results });
return resetMemberBudgets(pending.teamId, ids).then(
(batch) => runChunks(rest, attemptedSoFar + ids.length, [...results, ...batch]),
(error: unknown) => ({ ok: false as const, attempted: attemptedSoFar, error }),
);
};
const outcome = await runChunks(
chunk(pending.userIds.slice(attempted), MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES),
attempted,
[],
);
if (!isCurrent(run)) return;
if (!outcome.ok) {
console.error("Error resetting member budgets:", outcome.error);
const total = pending.userIds.length;
if (outcome.attempted > 0) {
toast.error(
`Reset ${outcome.attempted} of ${total} member ${pluralize(total, "budget", "budgets")}; the rest could not be reset`,
);
} else {
toast.fromError("Team updated, but member budgets could not be reset");
}
setState({ phase: "resetFailed", pending, attempted: outcome.attempted });
await refreshTeamData();
return;
}
const failed = outcome.results.filter((r) => !r.success);
if (failed.length > 0) {
toast.error(
`Team updated, but ${failed.length} member ${pluralize(failed.length, "budget", "budgets")} could not be reset`,
);
} else {
toast.success(
`Reset ${outcome.attempted} member ${pluralize(outcome.attempted, "budget", "budgets")} to the team default`,
);
}
setState({ phase: "idle" });
await refreshTeamData();
};
const prompt = (pending: MemberBudgetResetPending) => setState({ phase: "prompting", pending });
const reset = async () => {
if (state.phase !== "prompting") return;
const { pending } = state;
const run = {};
activeRun.current = run;
setState({ phase: "resetting", pending, attempted: 0 });
try {
await gateway.saveTeam(pending.updateData);
} catch (error) {
console.error("Error updating team:", error);
if (isCurrent(run)) setState({ phase: "prompting", pending });
return;
}
await runReset(pending, 0, run);
};
const retry = async () => {
if (state.phase !== "resetFailed") return;
const { pending, attempted } = state;
const run = {};
activeRun.current = run;
setState({ phase: "resetting", pending, attempted });
await runReset(pending, attempted, run);
};
const keepCustom = async () => {
if (state.phase !== "prompting") return;
const { pending } = state;
const run = {};
activeRun.current = run;
setState({ phase: "idle" });
try {
await gateway.saveTeam(pending.updateData);
if (isCurrent(run)) toast.success("Team settings updated successfully");
} catch (error) {
console.error("Error updating team:", error);
}
if (isCurrent(run)) await gateway.refreshTeamData();
};
const dismiss = useCallback(() => {
activeRun.current = null;
setState({ phase: "idle" });
}, []);
return { state, prompt, reset, retry, keepCustom, dismiss };
};