Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/modest-hodgkin-5775d7

This commit is contained in:
Yuneng Jiang 2026-08-18 15:30:37 -07:00
commit 653dc5c601
No known key found for this signature in database
36 changed files with 1140 additions and 204 deletions

View file

@ -41,6 +41,11 @@ OBJECT_KEYS: dict[str, JsonSchema] = {
},
"additionalProperties": False,
},
"guardrail_cost_per_unit": {
"type": "object",
"description": "USD cost per billable guardrail unit, keyed by the provider's usage counter name (e.g. Bedrock's contentPolicyUnits).",
"additionalProperties": NONNEG_NUMBER,
},
"metadata": {
"type": "object",
"description": "Free-form notes about the entry (e.g. pricing derivation).",

View file

@ -792,6 +792,8 @@ def _populate_provider_model_sets(model_cost_map: Dict) -> None:
nlp_cloud_models.add(key)
elif value.get("litellm_provider") == "aleph_alpha":
aleph_alpha_models.add(key)
elif value.get("litellm_provider") == "bedrock" and value.get("mode") == "guardrail":
pass
elif value.get("litellm_provider") == "bedrock" and not is_bedrock_pricing_only_model(key):
bedrock_models.add(key)
elif value.get("litellm_provider") == "bedrock_converse":

View file

@ -64,6 +64,10 @@ from litellm.integrations.mlflow import MlflowLogger
from litellm.integrations.sqs import SQSLogger
from litellm.litellm_core_utils.core_helpers import reconstruct_model_name
from litellm.litellm_core_utils.get_litellm_params import get_litellm_params
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import (
cost_breakdown_with_guardrail,
guardrail_information_cost,
)
from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import (
StandardBuiltInToolCostTracking,
)
@ -5650,12 +5654,14 @@ def get_standard_logging_object_payload(
base_model = metadata.get("deployment")
custom_pricing: Final = use_custom_pricing_for_model(litellm_params=litellm_params)
raw_response_cost: Final = kwargs.get("response_cost")
response_cost: Final[float] = raw_response_cost or 0.0
llm_response_cost: Final[float] = raw_response_cost or 0.0
guardrail_cost: Final = guardrail_information_cost(metadata.get("standard_logging_guardrail_information"))
response_cost: Final[float] = llm_response_cost + guardrail_cost
# clean up litellm hidden params
clean_hidden_params: Final = StandardLoggingPayloadSetup.get_hidden_params(hidden_params)
if clean_hidden_params["response_cost"] is None and raw_response_cost is not None:
clean_hidden_params["response_cost"] = response_cost
clean_hidden_params["response_cost"] = llm_response_cost
model_cost_information: Final = StandardLoggingPayloadSetup.get_model_cost_information(
base_model=base_model,
@ -5735,7 +5741,7 @@ def get_standard_logging_object_payload(
metadata=clean_metadata,
cache_key=clean_hidden_params["cache_key"],
response_cost=response_cost,
cost_breakdown=logging_obj.cost_breakdown,
cost_breakdown=cost_breakdown_with_guardrail(logging_obj.cost_breakdown, guardrail_cost),
total_tokens=usage_dict.get("total_tokens", 0),
prompt_tokens=usage_dict.get("prompt_tokens", 0),
completion_tokens=usage_dict.get("completion_tokens", 0),

View file

@ -0,0 +1,78 @@
import math
from collections.abc import Mapping
from typing import Final
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
import litellm
from litellm._logging import verbose_logger
from litellm.types.utils import CostBreakdown
BEDROCK_GUARDRAIL_PRICING_KEY: Final = "bedrock/guardrails"
class GuardrailPricing(BaseModel):
model_config = ConfigDict(extra="ignore", frozen=True)
guardrail_cost_per_unit: Mapping[str, float]
class GuardrailCostEntry(BaseModel):
model_config = ConfigDict(extra="ignore", frozen=True)
guardrail_cost: float | None = None
GuardrailInformationShape = tuple[GuardrailCostEntry, ...] | GuardrailCostEntry | None
_GUARDRAIL_INFORMATION_ADAPTER: Final[TypeAdapter[GuardrailInformationShape]] = TypeAdapter(GuardrailInformationShape)
def _bedrock_guardrail_pricing(aws_region_name: str | None) -> GuardrailPricing | None:
regional_key: Final = f"bedrock/{aws_region_name}/guardrails" if aws_region_name else None
for key in (regional_key, BEDROCK_GUARDRAIL_PRICING_KEY):
if key is None or key not in litellm.model_cost:
continue
try:
return GuardrailPricing.model_validate(litellm.model_cost[key])
except ValidationError as e:
verbose_logger.warning("Ignoring malformed guardrail pricing entry %s: %s", key, e)
return None
def bedrock_guardrail_cost(usage_units: Mapping[str, int], aws_region_name: str | None) -> float:
pricing: Final = _bedrock_guardrail_pricing(aws_region_name)
if pricing is None:
return 0.0
return sum(units * pricing.guardrail_cost_per_unit.get(counter, 0.0) for counter, units in usage_units.items())
def _billable_entry_cost(entry: GuardrailCostEntry) -> float:
cost: Final = entry.guardrail_cost
if cost is None or not math.isfinite(cost) or cost <= 0.0:
return 0.0
return cost
def guardrail_information_cost(guardrail_information: object) -> float:
try:
parsed: Final = _GUARDRAIL_INFORMATION_ADAPTER.validate_python(guardrail_information)
except ValidationError:
return 0.0
if parsed is None:
return 0.0
if isinstance(parsed, GuardrailCostEntry):
return _billable_entry_cost(parsed)
return sum(_billable_entry_cost(entry) for entry in parsed)
def cost_breakdown_with_guardrail(cost_breakdown: CostBreakdown | None, guardrail_cost: float) -> CostBreakdown | None:
if guardrail_cost <= 0.0:
return cost_breakdown
existing: Final[CostBreakdown] = cost_breakdown if cost_breakdown is not None else CostBreakdown()
merged: Final[CostBreakdown] = {
**existing,
"guardrail_cost": guardrail_cost,
"total_cost": existing.get("total_cost", 0.0) + guardrail_cost,
}
return merged

View file

@ -42,14 +42,18 @@ _VALID_DATA_RESIDENCIES: Final = frozenset(r.value for r in DataResidency)
# Pre-resolved service-tier cost-key suffixes (e.g. "_priority"). Used per
# request in the cost-calc path, so the f-strings are built once here instead
# of being rebuilt for every model_info key on every call.
_SERVICE_TIER_SUFFIXES: Final[tuple[str, ...]] = tuple(f"_{st.value}" for st in ServiceTier)
# of being rebuilt for every model_info key on every call. Longest-first so a
# substring match resolves "_ultrafast" before "_fast".
_SERVICE_TIER_SUFFIXES: Final[tuple[str, ...]] = tuple(
sorted((f"_{st.value}" for st in ServiceTier), key=len, reverse=True)
)
_SERVICE_TIER_TO_COST_KEY_SUFFIX: Final[Mapping[str, str]] = MappingProxyType(
{
ServiceTier.FLEX.value: ServiceTier.FLEX.value,
ServiceTier.PRIORITY.value: ServiceTier.PRIORITY.value,
ServiceTier.FAST.value: ServiceTier.PRIORITY.value,
ServiceTier.ULTRAFAST.value: ServiceTier.ULTRAFAST.value,
}
)
@ -191,7 +195,7 @@ def _get_service_tier_cost_key(base_key: str, service_tier: str | None) -> str:
Args:
base_key: The base cost key (e.g., "input_cost_per_token")
service_tier: The service tier ("flex", "priority", "fast", or None for standard)
service_tier: The service tier ("flex", "priority", "fast", "ultrafast", or None for standard)
Returns:
str: The cost key to use (e.g., "input_cost_per_token_flex" or "input_cost_per_token")

View file

@ -10052,6 +10052,21 @@
"output_cost_per_second": 0.0066027,
"supports_tool_choice": true
},
"bedrock/guardrails": {
"guardrail_cost_per_unit": {
"automatedReasoningPolicyUnits": 0.00017,
"contentPolicyImageUnits": 0.00075,
"contentPolicyUnits": 0.00015,
"contextualGroundingPolicyUnits": 0.0001,
"sensitiveInformationPolicyFreeUnits": 0.0,
"sensitiveInformationPolicyUnits": 0.0001,
"topicPolicyUnits": 0.00015,
"wordPolicyUnits": 0.0
},
"litellm_provider": "bedrock",
"mode": "guardrail",
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"bedrock/ap-northeast-1/1-month-commitment/anthropic.claude-instant-v1": {
"input_cost_per_second": 0.01475,
"litellm_provider": "bedrock",

View file

@ -32,11 +32,13 @@ from litellm.constants import (
UNSAFE_PROXY_RESPONSE_HEADERS,
)
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.litellm_core_utils.core_helpers import get_or_create_metadata_bucket
from litellm.litellm_core_utils.dd_tracing import NullTracer, tracer
from litellm.litellm_core_utils.get_supported_openai_params import (
get_supported_openai_params,
)
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import guardrail_information_cost
from litellm.litellm_core_utils.llm_response_utils.get_headers import (
get_response_headers,
)
@ -2462,11 +2464,21 @@ class ProxyBaseLLMRequestProcessing:
additional_headers = hidden_params.get("additional_headers", {}) or {}
recover_response_cost: Final = not response_cost and hidden_params.get("response_cost") is None
response_cost_for_headers: Final = (
llm_cost_for_headers: Final = (
self._response_cost_from_logging_obj(response=response, logging_obj=logging_obj) or ""
if recover_response_cost
else response_cost
)
_, request_metadata_bucket = get_or_create_metadata_bucket(self.data)
guardrail_cost_for_headers: Final = guardrail_information_cost(
request_metadata_bucket.get("standard_logging_guardrail_information")
)
response_cost_for_headers: Final = (
(llm_cost_for_headers if isinstance(llm_cost_for_headers, (int, float)) else 0.0)
+ guardrail_cost_for_headers
if guardrail_cost_for_headers > 0
else llm_cost_for_headers
)
fastapi_response.headers.update(
ProxyBaseLLMRequestProcessing.get_custom_headers(

View file

@ -31,6 +31,7 @@ from litellm.constants import BEDROCK_APPLY_GUARDRAIL_CHUNK_BUDGET_CHARS
from litellm.exceptions import ModifyResponseException
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.litellm_core_utils.core_helpers import redact_nested_match_and_regex_keys
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import bedrock_guardrail_cost
from litellm.llms.anthropic.chat.guardrail_translation.handler import AnthropicMessagesHandler
from litellm.llms.base_llm.guardrail_translation.utils import (
effective_scan_only_tool_results_for_guardrail,
@ -872,6 +873,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
credentials, aws_region_name = self._load_credentials()
allow_chunking: Final = not self._content_uses_contextual_grounding(content)
completed_chunk_usages: Final[list[BedrockGuardrailUsage]] = [] # mutable-ok: billed-chunk usage accumulator
try:
responses: Final = await self._apply_guardrail_content_with_chunking(
content=content,
@ -883,6 +885,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
event_type=event_type,
start_time=start_time,
allow_chunking=allow_chunking,
completed_chunk_usages=completed_chunk_usages,
)
except HTTPException as exc:
if not isinstance(exc.detail, dict):
@ -891,6 +894,8 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
request_data=request_data,
event_type=event_type,
start_time=start_time,
aws_region_name=aws_region_name,
completed_chunk_usages=completed_chunk_usages,
)
raise
merged_response: Final = self._merge_bedrock_guardrail_responses(responses)
@ -899,6 +904,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
request_data=request_data,
event_type=event_type,
start_time=start_time,
aws_region_name=aws_region_name,
)
return merged_response
@ -913,6 +919,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
event_type: GuardrailEventHooks,
start_time: "datetime",
allow_chunking: bool,
completed_chunk_usages: list[BedrockGuardrailUsage], # mutable-ok: billed-chunk usage accumulator
) -> tuple[BedrockContentChunkResult, ...]:
"""Post `content` to ApplyGuardrail, chunking only if AWS rejects it as too large.
@ -959,6 +966,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
request_data=request_data,
event_type=event_type,
start_time=start_time,
completed_chunk_usages=completed_chunk_usages,
)
return (
BedrockContentChunkResult(
@ -989,6 +997,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
event_type=event_type,
start_time=start_time,
allow_chunking=allow_chunking,
completed_chunk_usages=completed_chunk_usages,
)
for batch in batches
]
@ -1015,6 +1024,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
event_type=event_type,
start_time=start_time,
allow_chunking=allow_chunking,
completed_chunk_usages=completed_chunk_usages,
)
second_results: Final = await self._apply_guardrail_content_with_chunking(
content=second_half,
@ -1026,6 +1036,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
event_type=event_type,
start_time=start_time,
allow_chunking=allow_chunking,
completed_chunk_usages=completed_chunk_usages,
)
combined_results: Final = tuple(first_results) + tuple(second_results)
if is_single_item_text_split:
@ -1045,6 +1056,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper
event_type: GuardrailEventHooks,
start_time: "datetime",
completed_chunk_usages: list[BedrockGuardrailUsage], # mutable-ok: passed through to the single-call layer
) -> BedrockGuardrailResponse:
"""Post one ApplyGuardrail call for `content`, retrying with exponential
backoff on AWS ThrottlingException (HTTP 429).
@ -1072,6 +1084,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
request_data=request_data,
event_type=event_type,
start_time=start_time,
completed_chunk_usages=completed_chunk_usages,
)
except HTTPException as exc:
if (
@ -1093,6 +1106,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper
event_type: GuardrailEventHooks,
start_time: "datetime",
completed_chunk_usages: list[BedrockGuardrailUsage], # mutable-ok: billed-chunk usage accumulator
) -> BedrockGuardrailResponse:
"""Make exactly one signed ApplyGuardrail HTTP call for `content` and
parse the result. Raises HTTPException on a guardrail block or any
@ -1108,7 +1122,10 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
A block is logged here rather than by the caller: it ends the whole chunking
flow immediately, with no further chunks attempted, so there is no later
merged response for the caller to log instead.
merged response for the caller to log instead. The logged usage still spans
the whole logical request: chunks that passed before the block appended what
AWS billed them to ``completed_chunk_usages``, and the attempt log sums those
with the blocking call's own usage.
"""
bedrock_request_data: Final = { # mutable-ok: outbound JSON request body
**base_request_data,
@ -1151,10 +1168,17 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
request_data=request_data,
event_type=event_type,
start_time=start_time,
aws_region_name=aws_region_name,
completed_chunk_usages=completed_chunk_usages,
)
raise self._get_http_exception_for_blocked_guardrail(
bedrock_guardrail_response, request_data=request_data
)
response_usage: Final = bedrock_guardrail_response.get("usage")
if isinstance(response_usage, dict):
completed_chunk_usages.append(
response_usage
) # rebind-ok: accumulator threaded from make_bedrock_api_request, recording this billed call
return bedrock_guardrail_response
status_code, detail_message = self._parse_bedrock_guardrail_error_response(httpx_response)
@ -1172,14 +1196,31 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper
event_type: GuardrailEventHooks,
start_time: "datetime",
aws_region_name: str | None,
completed_chunk_usages: Sequence[BedrockGuardrailUsage],
) -> None:
"""Log a single ApplyGuardrail HTTP attempt as-is (its own status,
derived from its own response). Used only for the blocked-content
case, which ends the whole chunking flow immediately."""
tracing_detail: Final = self._build_tracing_detail(BedrockGuardrailResponse(**json_response))
"""Log the blocking ApplyGuardrail attempt, which ends the whole chunking
flow immediately. Its status derives from its own response, but its usage
(and so its cost) spans every billed call of the logical request: the
chunks that passed before the block plus the blocking call itself."""
blocking_usage: Final = json_response.get("usage")
billed_usages: Final[tuple[BedrockGuardrailUsage, ...]] = tuple(completed_chunk_usages) + (
(blocking_usage,) if isinstance(blocking_usage, dict) else ()
)
logged_json_response: Final = (
{ # mutable-ok: raw AWS JSON payload carrying the total billed usage
**json_response,
"usage": self._sum_usage_counters(billed_usages),
}
if completed_chunk_usages
else json_response
)
tracing_detail: Final = self._build_tracing_detail(
BedrockGuardrailResponse(**logged_json_response), aws_region_name=aws_region_name
)
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider=self.guardrail_provider,
guardrail_json_response=json_response,
guardrail_json_response=logged_json_response,
request_data=request_data or {}, # mutable-ok: logging helper requires a dict
guardrail_status=self._get_bedrock_guardrail_response_status(response=httpx_response),
start_time=start_time.timestamp(),
@ -1195,6 +1236,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper
event_type: GuardrailEventHooks,
start_time: "datetime",
aws_region_name: str | None,
) -> None:
"""Log one logical ApplyGuardrail call -- possibly several chunk calls
under the hood -- using its final merged response, so a chunked
@ -1205,7 +1247,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
``Output.__type`` with an exception marker. That marker survives the merge,
so the status is derived from the merged response rather than assumed to be
a success, which is what the pre-chunking code reported for that shape."""
tracing_detail: Final = self._build_tracing_detail(merged_response)
tracing_detail: Final = self._build_tracing_detail(merged_response, aws_region_name=aws_region_name)
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider=self.guardrail_provider,
guardrail_json_response=dict(merged_response), # mutable-ok: logging helper requires a dict
@ -1228,20 +1270,36 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper
event_type: GuardrailEventHooks,
start_time: "datetime",
aws_region_name: str | None,
completed_chunk_usages: Sequence[BedrockGuardrailUsage],
) -> None:
"""Log one logical ApplyGuardrail call that failed end-to-end (an
unrecoverable too-large error, a non-size validation error, or
exhausted throttle retries) as a single failure, rather than logging
every failed attempt chunking made along the way."""
every failed attempt chunking made along the way. Chunk calls AWS
billed before the failure still carry their usage and cost."""
billed_usage: Final = self._sum_usage_counters(completed_chunk_usages) if completed_chunk_usages else None
error_payload: Final = {"error": str(detail)} # mutable-ok: logging helper requires a dict
json_response: Final = (
{**error_payload, "usage": billed_usage} # mutable-ok: logging helper requires a dict
if billed_usage is not None
else error_payload
)
tracing_detail: Final = (
self._build_tracing_detail(BedrockGuardrailResponse(usage=billed_usage), aws_region_name=aws_region_name)
if billed_usage is not None
else None
)
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider=self.guardrail_provider,
guardrail_json_response={"error": str(detail)}, # mutable-ok: logging helper requires a dict
guardrail_json_response=json_response,
request_data=request_data or {}, # mutable-ok: logging helper requires a dict
guardrail_status="guardrail_failed_to_respond",
start_time=start_time.timestamp(),
end_time=datetime.now(timezone.utc).timestamp(),
duration=(datetime.now(timezone.utc) - start_time).total_seconds(),
event_type=event_type,
tracing_detail=tracing_detail or None,
)
@staticmethod
@ -1504,15 +1562,20 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
Keys are taken from the responses rather than from a fixed list, so a counter
this code does not know about (AWS has added several) is still summed and
reported instead of being silently dropped to zero."""
chunk_usages: Final = tuple(
chunk_result.response.get("usage") or {} # mutable-ok: read-only empty fallback
for chunk_result in chunk_results
return BedrockGuardrail._sum_usage_counters(
tuple(
chunk_result.response.get("usage") or {} # mutable-ok: read-only empty fallback
for chunk_result in chunk_results
)
)
@staticmethod
def _sum_usage_counters(usages: Sequence[BedrockGuardrailUsage]) -> BedrockGuardrailUsage:
return cast( # cast-ok: TypedDict assembled from a comprehension
BedrockGuardrailUsage,
{ # mutable-ok: builds the TypedDict payload
key: sum(usage.get(key) or 0 for usage in chunk_usages)
for key in dict.fromkeys(key for usage in chunk_usages for key in usage)
key: sum(usage.get(key) or 0 for usage in usages)
for key in dict.fromkeys(key for usage in usages for key in usage)
},
)
@ -2036,7 +2099,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
return (status_code, err)
return (status_code, message)
def _build_tracing_detail(self, response: BedrockGuardrailResponse) -> GuardrailTracingDetail:
def _build_tracing_detail(
self, response: BedrockGuardrailResponse, aws_region_name: str | None
) -> GuardrailTracingDetail:
"""
Build the tracing detail from the raw Bedrock response, before
redaction, so downstream loggers (OTEL, Langfuse, ...) get the
@ -2060,6 +2125,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
}
if usage_units:
tracing_detail["guardrail_usage"] = usage_units
tracing_detail["guardrail_cost"] = bedrock_guardrail_cost(
usage_units=usage_units, aws_region_name=aws_region_name
)
return tracing_detail
def _extract_violation_category_names(self, response: BedrockGuardrailResponse) -> list[str]:

View file

@ -11,6 +11,7 @@ from litellm.litellm_core_utils.core_helpers import (
get_litellm_metadata_from_kwargs,
)
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import guardrail_information_cost
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.auth_checks import (
get_key_object,
@ -184,9 +185,14 @@ class _ProxyDBLogger(CustomLogger):
# recovered cost onto request_data (the usage rides along in
# ``combined_usage_object`` for the token columns), so attribute the
# real partial spend to this failure row instead of zero.
recovered_response_cost = 0.0
if isinstance(request_data.get("combined_usage_object"), litellm.Usage):
recovered_response_cost = max(float(request_data.get("response_cost") or 0.0), 0.0)
recovered_stream_cost: Final = (
max(float(request_data.get("response_cost") or 0.0), 0.0)
if isinstance(request_data.get("combined_usage_object"), litellm.Usage)
else 0.0
)
recovered_response_cost: Final = recovered_stream_cost + guardrail_information_cost(
existing_metadata.get("standard_logging_guardrail_information")
)
await proxy_logging_obj.db_spend_update_writer.update_database(
token=user_api_key_dict.api_key,

View file

@ -296,7 +296,10 @@ _ALLOW_CLIENT_MESSAGE_REDACTION_OPT_OUT_METADATA_KEY: Final = "allow_client_mess
_CLIENT_PRICING_CONTROL_FIELDS: Final = frozenset(CustomPricingLiteLLMParams.model_fields.keys())
# ``model_info`` carries the same pricing fields when read by
# ``use_custom_pricing_for_model``; strip from metadata for the same reason.
_CLIENT_PRICING_METADATA_FIELDS: Final = frozenset({"model_info"})
# ``standard_logging_guardrail_information`` is proxy-written telemetry summed
# into response_cost and spend; a client seeding it forges (even negative)
# guardrail cost.
_CLIENT_PRICING_METADATA_FIELDS: Final = frozenset({"model_info", "standard_logging_guardrail_information"})
_ALLOW_CLIENT_PRICING_OVERRIDE_METADATA_KEY: Final = "allow_client_pricing_override"
# Request fields whose value, when URL-valued, becomes the outbound destination

View file

@ -196,6 +196,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
input_cost_per_token: Required[float | None]
input_cost_per_token_flex: float | None # OpenAI flex service tier pricing
input_cost_per_token_priority: float | None # OpenAI priority service tier pricing
input_cost_per_token_ultrafast: ReadOnly[float | None] # OpenAI ultrafast service tier pricing
cache_creation_input_token_cost: float | None
cache_creation_input_token_cost_above_200k_tokens: float | None
cache_creation_input_token_cost_above_272k_tokens: float | None
@ -204,9 +205,11 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
cache_creation_input_token_cost_above_1hr: float | None
cache_creation_input_token_cost_flex: float | None # OpenAI flex service tier pricing
cache_creation_input_token_cost_priority: float | None # OpenAI priority service tier pricing
cache_creation_input_token_cost_ultrafast: ReadOnly[float | None] # OpenAI ultrafast service tier pricing
cache_read_input_token_cost: float | None
cache_read_input_token_cost_flex: float | None # OpenAI flex service tier pricing
cache_read_input_token_cost_priority: float | None # OpenAI priority service tier pricing
cache_read_input_token_cost_ultrafast: ReadOnly[float | None] # OpenAI ultrafast service tier pricing
cache_read_input_token_cost_above_200k_tokens: float | None
cache_read_input_token_cost_above_200k_tokens_priority: float | None
cache_read_input_token_cost_above_272k_tokens: float | None
@ -238,6 +241,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
output_cost_per_token: Required[float | None]
output_cost_per_token_flex: float | None # OpenAI flex service tier pricing
output_cost_per_token_priority: float | None # OpenAI priority service tier pricing
output_cost_per_token_ultrafast: ReadOnly[float | None] # OpenAI ultrafast service tier pricing
regional_processing_uplift_multiplier_eu: (
float | None
) # OpenAI EU data-residency uplift multiplier applied to all token costs (e.g. 1.10 = +10%)
@ -3023,6 +3027,11 @@ class StandardLoggingGuardrailInformation(TypedDict, total=False):
provider's counter name (e.g. Bedrock's ``contentPolicyUnits``). Kept as a
sibling of guardrail_response so spend-log prompt redaction never drops it."""
guardrail_cost: ReadOnly[float | None]
"""USD cost of this guardrail invocation, priced from ``guardrail_usage`` by the
provider hook. Summed into the request's ``response_cost`` so it counts against
spend and budgets like token cost."""
class EvalVerdict(TypedDict, total=False):
criterion_name: str
@ -3067,6 +3076,7 @@ class GuardrailTracingDetail(TypedDict, total=False):
violation_categories: list[str] | None
guardrail_action: str | None
guardrail_usage: ReadOnly[Mapping[str, int] | None]
guardrail_cost: ReadOnly[float | None]
StandardLoggingPayloadStatus = Literal["success", "failure"]
@ -3106,8 +3116,9 @@ class CostBreakdown(TypedDict, total=False):
cache_creation_cost: float # Cost of cache-write tokens (premium rate)
output_cost: float # Cost of output/completion tokens (includes reasoning if applicable)
reasoning_cost: float # Cost of reasoning tokens (subset of output_cost)
total_cost: float # Total cost (input + output + tool usage)
total_cost: ReadOnly[float] # Total cost (input + output + tool usage + guardrail)
tool_usage_cost: float # Cost of usage of built-in tools
guardrail_cost: ReadOnly[float] # Cost of guardrail invocations billed by the guardrail provider
additional_costs: dict[str, float] # Free-form additional costs (e.g., {"azure_model_router_flat_cost": 0.00014})
original_cost: float # Cost before discount (optional)
discount_percent: float # Discount percentage applied (e.g., 0.05 = 5%) (optional)
@ -3294,6 +3305,7 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
# This allows any model_info parameter to be set in litellm_params
input_cost_per_token_flex: float | None = None
input_cost_per_token_priority: float | None = None
input_cost_per_token_ultrafast: float | None = None
cache_creation_input_token_cost_above_1hr: float | None = None
cache_creation_input_token_cost_above_200k_tokens: float | None = None
cache_creation_input_token_cost_above_272k_tokens: float | None = None
@ -3301,9 +3313,11 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
cache_creation_input_token_cost_above_272k_tokens_flex: float | None = None
cache_creation_input_token_cost_flex: float | None = None
cache_creation_input_token_cost_priority: float | None = None
cache_creation_input_token_cost_ultrafast: float | None = None
cache_creation_input_audio_token_cost: float | None = None
cache_read_input_token_cost_flex: float | None = None
cache_read_input_token_cost_priority: float | None = None
cache_read_input_token_cost_ultrafast: float | None = None
cache_read_input_token_cost_above_200k_tokens: float | None = None
cache_read_input_token_cost_above_200k_tokens_priority: float | None = None
cache_read_input_token_cost_above_272k_tokens_priority: float | None = None
@ -3330,6 +3344,7 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
output_cost_per_token_batches: float | None = None
output_cost_per_token_flex: float | None = None
output_cost_per_token_priority: float | None = None
output_cost_per_token_ultrafast: float | None = None
output_cost_per_audio_token: float | None = None
output_cost_per_token_above_128k_tokens: float | None = None
output_cost_per_token_above_200k_tokens: float | None = None
@ -4000,6 +4015,7 @@ class ServiceTier(Enum):
FLEX = "flex"
PRIORITY = "priority"
FAST = "fast"
ULTRAFAST = "ultrafast"
class DataResidency(Enum):

View file

@ -5580,6 +5580,7 @@ def _get_model_info_helper(
input_cost_per_token=_input_cost_per_token,
input_cost_per_token_flex=_model_info.get("input_cost_per_token_flex", None),
input_cost_per_token_priority=_model_info.get("input_cost_per_token_priority", None),
input_cost_per_token_ultrafast=_model_info.get("input_cost_per_token_ultrafast", None),
cache_creation_input_token_cost=_model_info.get("cache_creation_input_token_cost", None),
cache_creation_input_token_cost_above_200k_tokens=_model_info.get(
"cache_creation_input_token_cost_above_200k_tokens", None
@ -5597,6 +5598,9 @@ def _get_model_info_helper(
cache_creation_input_token_cost_priority=_model_info.get(
"cache_creation_input_token_cost_priority", None
),
cache_creation_input_token_cost_ultrafast=_model_info.get(
"cache_creation_input_token_cost_ultrafast", None
),
cache_read_input_token_cost=_model_info.get("cache_read_input_token_cost", None),
prompt_cache_min_tokens=_model_info.get("prompt_cache_min_tokens", None),
cache_read_input_token_cost_above_200k_tokens=_model_info.get(
@ -5619,6 +5623,7 @@ def _get_model_info_helper(
),
cache_read_input_token_cost_flex=_model_info.get("cache_read_input_token_cost_flex", None),
cache_read_input_token_cost_priority=_model_info.get("cache_read_input_token_cost_priority", None),
cache_read_input_token_cost_ultrafast=_model_info.get("cache_read_input_token_cost_ultrafast", None),
cache_creation_input_token_cost_above_1hr=_model_info.get(
"cache_creation_input_token_cost_above_1hr", None
),
@ -5649,6 +5654,7 @@ def _get_model_info_helper(
output_cost_per_token=_output_cost_per_token,
output_cost_per_token_flex=_model_info.get("output_cost_per_token_flex", None),
output_cost_per_token_priority=_model_info.get("output_cost_per_token_priority", None),
output_cost_per_token_ultrafast=_model_info.get("output_cost_per_token_ultrafast", None),
regional_processing_uplift_multiplier_eu=_model_info.get(
"regional_processing_uplift_multiplier_eu", None
),

View file

@ -10052,6 +10052,21 @@
"output_cost_per_second": 0.0066027,
"supports_tool_choice": true
},
"bedrock/guardrails": {
"guardrail_cost_per_unit": {
"automatedReasoningPolicyUnits": 0.00017,
"contentPolicyImageUnits": 0.00075,
"contentPolicyUnits": 0.00015,
"contextualGroundingPolicyUnits": 0.0001,
"sensitiveInformationPolicyFreeUnits": 0.0,
"sensitiveInformationPolicyUnits": 0.0001,
"topicPolicyUnits": 0.00015,
"wordPolicyUnits": 0.0
},
"litellm_provider": "bedrock",
"mode": "guardrail",
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"bedrock/ap-northeast-1/1-month-commitment/anthropic.claude-instant-v1": {
"input_cost_per_second": 0.01475,
"litellm_provider": "bedrock",

View file

@ -186,6 +186,14 @@
"gemini_native_audio": {
"type": "boolean"
},
"guardrail_cost_per_unit": {
"type": "object",
"description": "USD cost per billable guardrail unit, keyed by the provider's usage counter name (e.g. Bedrock's contentPolicyUnits).",
"additionalProperties": {
"type": "number",
"minimum": 0
}
},
"input_cost_per_audio_per_second": {
"type": "number",
"minimum": 0
@ -361,6 +369,7 @@
"chat",
"completion",
"embedding",
"guardrail",
"image_edit",
"image_generation",
"moderation",

View file

@ -0,0 +1,113 @@
import os
import pytest
import litellm
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import (
bedrock_guardrail_cost,
cost_breakdown_with_guardrail,
guardrail_information_cost,
)
@pytest.fixture
def synthetic_cost_map(monkeypatch):
monkeypatch.setattr(
litellm,
"model_cost",
{
"bedrock/guardrails": {
"guardrail_cost_per_unit": {
"contentPolicyUnits": 0.00015,
"topicPolicyUnits": 0.00015,
"wordPolicyUnits": 0.0,
}
},
"bedrock/eu-west-1/guardrails": {"guardrail_cost_per_unit": {"contentPolicyUnits": 0.0002}},
"bedrock/us-west-2/guardrails": {"guardrail_cost_per_unit": "malformed"},
},
)
def test_bedrock_guardrail_cost_prices_each_counter(synthetic_cost_map):
cost = bedrock_guardrail_cost(
usage_units={"contentPolicyUnits": 2, "topicPolicyUnits": 1, "wordPolicyUnits": 5},
aws_region_name="us-east-1",
)
assert cost == pytest.approx(0.00045)
def test_bedrock_guardrail_cost_prefers_regional_entry(synthetic_cost_map):
cost = bedrock_guardrail_cost(usage_units={"contentPolicyUnits": 1}, aws_region_name="eu-west-1")
assert cost == pytest.approx(0.0002)
def test_bedrock_guardrail_cost_unknown_counter_is_free(synthetic_cost_map):
assert bedrock_guardrail_cost(usage_units={"someFutureCounter": 3}, aws_region_name="us-east-1") == 0.0
def test_bedrock_guardrail_cost_malformed_regional_entry_falls_back(synthetic_cost_map):
cost = bedrock_guardrail_cost(usage_units={"contentPolicyUnits": 1}, aws_region_name="us-west-2")
assert cost == pytest.approx(0.00015)
def test_bedrock_guardrail_cost_no_pricing_entry(monkeypatch):
monkeypatch.setattr(litellm, "model_cost", {})
assert bedrock_guardrail_cost(usage_units={"contentPolicyUnits": 1}, aws_region_name="us-east-1") == 0.0
def test_shipped_bedrock_guardrail_prices_match_aws_pricing_page():
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
assert litellm.model_cost["bedrock/guardrails"]["guardrail_cost_per_unit"] == {
"automatedReasoningPolicyUnits": 0.00017,
"contentPolicyImageUnits": 0.00075,
"contentPolicyUnits": 0.00015,
"contextualGroundingPolicyUnits": 0.0001,
"sensitiveInformationPolicyFreeUnits": 0.0,
"sensitiveInformationPolicyUnits": 0.0001,
"topicPolicyUnits": 0.00015,
"wordPolicyUnits": 0.0,
}
assert "bedrock/guardrails" not in litellm.bedrock_models
def test_guardrail_information_cost_sums_entries():
entries = [
{"guardrail_name": "a", "guardrail_cost": 0.0003},
{"guardrail_name": "b", "guardrail_cost": None},
{"guardrail_name": "c"},
{"guardrail_name": "d", "guardrail_cost": 0.0001},
]
assert guardrail_information_cost(entries) == pytest.approx(0.0004)
def test_guardrail_information_cost_single_entry_and_garbage():
assert guardrail_information_cost({"guardrail_cost": 0.0001}) == pytest.approx(0.0001)
assert guardrail_information_cost(None) == 0.0
assert guardrail_information_cost("not-guardrail-info") == 0.0
assert guardrail_information_cost([{"guardrail_cost": "bad"}]) == 0.0
def test_guardrail_information_cost_ignores_negative_and_non_finite():
entries = [
{"guardrail_name": "forged-negative", "guardrail_cost": -0.005},
{"guardrail_name": "forged-nan", "guardrail_cost": float("nan")},
{"guardrail_name": "forged-inf", "guardrail_cost": float("inf")},
{"guardrail_name": "real", "guardrail_cost": 0.0003},
]
assert guardrail_information_cost(entries) == pytest.approx(0.0003)
assert guardrail_information_cost({"guardrail_cost": -1.0}) == 0.0
def test_cost_breakdown_with_guardrail_merges_and_creates():
assert cost_breakdown_with_guardrail(None, 0.0) is None
untouched = {"input_cost": 0.1, "total_cost": 0.4}
assert cost_breakdown_with_guardrail(untouched, 0.0) is untouched
merged = cost_breakdown_with_guardrail({"input_cost": 0.1, "total_cost": 0.4}, 0.0003)
assert merged is not None
assert merged["guardrail_cost"] == pytest.approx(0.0003)
assert merged["total_cost"] == pytest.approx(0.4003)
assert merged["input_cost"] == pytest.approx(0.1)
created = cost_breakdown_with_guardrail(None, 0.0003)
assert created == {"guardrail_cost": 0.0003, "total_cost": 0.0003}

View file

@ -1781,6 +1781,81 @@ def test_service_tier_fallback_pricing():
), f"Standard completion cost mismatch: {std_cost[1]} vs {expected_standard_completion}"
def test_service_tier_ultrafast_pricing():
"""An ultrafast request bills the *_ultrafast rates for all token types.
Regression for the ultrafast service tier being absent from ServiceTier:
the cost-key lookup silently returned the standard keys, undercounting
every ultrafast request.
"""
cached_tokens = 200
cache_write_tokens = 300
text_tokens = 500
usage = Usage(
prompt_tokens=text_tokens + cached_tokens + cache_write_tokens,
completion_tokens=400,
total_tokens=text_tokens + cached_tokens + cache_write_tokens + 400,
prompt_tokens_details=PromptTokensDetailsWrapper(
cached_tokens=cached_tokens, cache_write_tokens=cache_write_tokens
),
)
model_info: ModelInfo = {
"key": "gpt-5.6-sol",
"input_cost_per_token": 5e-06,
"output_cost_per_token": 3e-05,
"cache_creation_input_token_cost": 6.25e-06,
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token_ultrafast": 5e-05,
"output_cost_per_token_ultrafast": 3e-04,
"cache_creation_input_token_cost_ultrafast": 6.25e-05,
"cache_read_input_token_cost_ultrafast": 5e-06,
}
prompt_cost, completion_cost = generic_cost_per_token(
model="gpt-5.6-sol",
usage=usage,
custom_llm_provider="openai",
service_tier="ultrafast",
model_info=model_info,
)
expected_prompt_cost = (
text_tokens * 5e-05 + cached_tokens * 5e-06 + cache_write_tokens * 6.25e-05
)
assert prompt_cost == pytest.approx(expected_prompt_cost)
assert completion_cost == pytest.approx(400 * 3e-04)
def test_service_tier_ultrafast_fallback_pricing():
"""Without *_ultrafast keys an ultrafast request bills the standard rate, not zero.
Guards the suffix fallback in _get_cost_per_unit: "_fast" is a substring of
"_ultrafast", so a shortest-first suffix match would strip the wrong suffix
and price the request at 0.
"""
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
usage = Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500)
std_prompt_cost, std_completion_cost = generic_cost_per_token(
model="gpt-5.6-sol",
usage=usage,
custom_llm_provider="openai",
service_tier=None,
)
ultrafast_prompt_cost, ultrafast_completion_cost = generic_cost_per_token(
model="gpt-5.6-sol",
usage=usage,
custom_llm_provider="openai",
service_tier="ultrafast",
)
assert std_prompt_cost + std_completion_cost > 0
assert ultrafast_prompt_cost == pytest.approx(std_prompt_cost)
assert ultrafast_completion_cost == pytest.approx(std_completion_cost)
@pytest.mark.parametrize(
"model",
[
@ -2322,7 +2397,11 @@ def test_service_tier_suffixes_constant_in_sync_with_enum():
from litellm.litellm_core_utils.llm_cost_calc.utils import _SERVICE_TIER_SUFFIXES
from litellm.types.utils import ServiceTier
assert _SERVICE_TIER_SUFFIXES == tuple(f"_{st.value}" for st in ServiceTier)
assert set(_SERVICE_TIER_SUFFIXES) == {f"_{st.value}" for st in ServiceTier}
# longest-first so a substring match resolves "_ultrafast" before "_fast"
assert list(_SERVICE_TIER_SUFFIXES) == sorted(
_SERVICE_TIER_SUFFIXES, key=len, reverse=True
)
def test_get_cost_per_unit_falls_back_from_service_tier_key_to_base():

View file

@ -4826,3 +4826,87 @@ async def test_restore_correlation_context_works_across_asyncio_task_boundary():
finally:
trace_id_var.set("")
session_id_var.set("")
def _build_success_payload(logging_obj, kwargs):
import datetime
from litellm.litellm_core_utils.litellm_logging import (
get_standard_logging_object_payload,
)
now = datetime.datetime.now()
return get_standard_logging_object_payload(
kwargs=kwargs,
init_response_obj={},
start_time=now,
end_time=now,
logging_obj=logging_obj,
status="success",
)
def _guardrail_kwargs(response_cost):
return {
"litellm_call_id": "guardrail-cost-call",
"model": "gpt-4o",
"messages": [],
"response_cost": response_cost,
"litellm_params": {
"metadata": {
"standard_logging_guardrail_information": [
{
"guardrail_name": "bedrock-pre",
"guardrail_status": "success",
"guardrail_usage": {"topicPolicyUnits": 1, "contentPolicyUnits": 1},
"guardrail_cost": 0.0003,
},
{"guardrail_name": "no-usage-guardrail", "guardrail_status": "success"},
]
}
},
}
def test_payload_response_cost_includes_guardrail_cost(logging_obj):
"""LIT-5651: provider-billed guardrail cost must count in response_cost."""
payload = _build_success_payload(logging_obj, _guardrail_kwargs(response_cost=0.0000429))
assert payload is not None
assert payload["response_cost"] == pytest.approx(0.0003429)
assert payload["cost_breakdown"] is not None
assert payload["cost_breakdown"]["guardrail_cost"] == pytest.approx(0.0003)
assert payload["cost_breakdown"]["total_cost"] == pytest.approx(0.0003)
assert payload["hidden_params"]["response_cost"] == pytest.approx(0.0000429)
def test_payload_guardrail_cost_merges_into_existing_cost_breakdown(logging_obj):
logging_obj.set_cost_breakdown(
input_cost=0.00003,
output_cost=0.0000129,
total_cost=0.0000429,
cost_for_built_in_tools_cost_usd_dollar=0.0,
)
payload = _build_success_payload(logging_obj, _guardrail_kwargs(response_cost=0.0000429))
assert payload is not None
assert payload["response_cost"] == pytest.approx(0.0003429)
assert payload["cost_breakdown"]["guardrail_cost"] == pytest.approx(0.0003)
assert payload["cost_breakdown"]["total_cost"] == pytest.approx(0.0003429)
assert payload["cost_breakdown"]["input_cost"] == pytest.approx(0.00003)
assert logging_obj.cost_breakdown["total_cost"] == pytest.approx(0.0000429)
def test_payload_without_guardrail_cost_is_unchanged(logging_obj):
kwargs = {
"litellm_call_id": "no-guardrail-call",
"model": "gpt-4o",
"messages": [],
"response_cost": 0.0000429,
"litellm_params": {"metadata": {}},
}
payload = _build_success_payload(logging_obj, kwargs)
assert payload is not None
assert payload["response_cost"] == pytest.approx(0.0000429)
assert payload["cost_breakdown"] is None

View file

@ -5079,24 +5079,200 @@ async def test_apply_guardrail_failure_logs_a_dict_not_a_bare_string():
assert "error" in logged
def test_build_tracing_detail_surfaces_usage_counters():
"""LIT-5650: the billable usage block Bedrock returns per ApplyGuardrail call must
land on the tracing detail as guardrail_usage so it reaches spend logs as a
sibling of guardrail_response (which default redaction replaces wholesale)."""
def test_build_tracing_detail_surfaces_usage_counters_and_cost(monkeypatch):
"""LIT-5650/LIT-5651: AWS-billed usage must land as guardrail_usage priced into guardrail_cost."""
monkeypatch.setattr(
litellm,
"model_cost",
{
"bedrock/guardrails": {
"guardrail_cost_per_unit": {
"topicPolicyUnits": 0.00015,
"contentPolicyUnits": 0.00015,
"wordPolicyUnits": 0.0,
}
}
},
)
guardrail = BedrockGuardrail(guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT")
detail = guardrail._build_tracing_detail(
{
"action": "GUARDRAIL_INTERVENED",
"usage": {"topicPolicyUnits": 1, "contentPolicyUnits": 2, "wordPolicyUnits": 0, "oddball": "not-an-int"},
}
},
aws_region_name="us-east-1",
)
assert detail["guardrail_usage"] == {"topicPolicyUnits": 1, "contentPolicyUnits": 2, "wordPolicyUnits": 0}
assert detail["guardrail_cost"] == pytest.approx(0.00045)
def test_build_tracing_detail_omits_guardrail_usage_when_bedrock_reports_none():
guardrail = BedrockGuardrail(guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT")
assert "guardrail_usage" not in guardrail._build_tracing_detail({"action": "NONE"})
assert "guardrail_usage" not in guardrail._build_tracing_detail({"action": "NONE", "usage": {}})
for detail in (
guardrail._build_tracing_detail({"action": "NONE"}, aws_region_name="us-east-1"),
guardrail._build_tracing_detail({"action": "NONE", "usage": {}}, aws_region_name="us-east-1"),
):
assert "guardrail_usage" not in detail
assert "guardrail_cost" not in detail
@pytest.mark.asyncio
async def test_blocked_chunk_logs_usage_and_cost_of_prior_passed_chunks(monkeypatch):
"""LIT-5651 regression: a block on a later chunk must still bill the chunks AWS already processed."""
monkeypatch.setattr(
litellm,
"model_cost",
{
"bedrock/guardrails": {
"guardrail_cost_per_unit": {
"contentPolicyUnits": 0.00015,
"wordPolicyUnits": 0.0,
}
}
},
)
guardrail = BedrockGuardrail(
guardrailIdentifier="test-guardrail",
guardrailVersion="DRAFT",
chunk_budget_chars=40,
)
too_large_response = MagicMock()
too_large_response.status_code = 429
too_large_response.json.return_value = {
"message": "Input text size (60 text units) exceeds the maximum allowed (1 text units) for the content filter policy"
}
passed_chunk_response = MagicMock()
passed_chunk_response.status_code = 200
passed_chunk_response.json.return_value = {
"action": "NONE",
"outputs": [],
"assessments": [],
"usage": {"contentPolicyUnits": 2, "wordPolicyUnits": 1},
}
blocked_chunk_response = MagicMock()
blocked_chunk_response.status_code = 200
blocked_chunk_response.json.return_value = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [{"contentPolicy": {"filters": [{"type": "HATE", "confidence": "HIGH", "action": "BLOCKED"}]}}],
"outputs": [{"text": "Content blocked"}],
"usage": {"contentPolicyUnits": 3},
}
mock_credentials = MagicMock()
mock_credentials.access_key = "test-access-key"
mock_credentials.secret_key = "test-secret-key"
mock_credentials.token = None
request_data = {
"model": "gpt-4o",
"messages": [
{"role": "user", "content": "a" * 30},
{"role": "user", "content": "b" * 30},
],
}
with (
patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as mock_post,
patch.object(guardrail, "_load_credentials", return_value=(mock_credentials, "us-east-1")),
patch.object(guardrail, "_prepare_request", return_value=MagicMock()),
):
mock_post.side_effect = [too_large_response, passed_chunk_response, blocked_chunk_response]
with pytest.raises(HTTPException):
await guardrail.make_bedrock_api_request(
source="INPUT",
messages=request_data["messages"],
request_data=request_data,
)
assert mock_post.call_count == 3
logged_entries = request_data["metadata"]["standard_logging_guardrail_information"]
assert len(logged_entries) == 1
logged = logged_entries[0]
assert logged["guardrail_usage"] == {"contentPolicyUnits": 5, "wordPolicyUnits": 1}
assert logged["guardrail_cost"] == pytest.approx(0.00075)
assert logged["guardrail_response"]["usage"] == {"contentPolicyUnits": 5, "wordPolicyUnits": 1}
@pytest.mark.asyncio
async def test_terminal_failure_logs_usage_and_cost_of_prior_passed_chunks(monkeypatch):
"""LIT-5651 regression: a terminal failure on a later chunk must still bill the chunks AWS already processed."""
monkeypatch.setattr(
litellm,
"model_cost",
{
"bedrock/guardrails": {
"guardrail_cost_per_unit": {
"contentPolicyUnits": 0.00015,
"wordPolicyUnits": 0.0,
}
}
},
)
guardrail = BedrockGuardrail(
guardrailIdentifier="test-guardrail",
guardrailVersion="DRAFT",
chunk_budget_chars=40,
)
too_large_response = MagicMock()
too_large_response.status_code = 429
too_large_response.json.return_value = {
"message": "Input text size (60 text units) exceeds the maximum allowed (1 text units) for the content filter policy"
}
passed_chunk_response = MagicMock()
passed_chunk_response.status_code = 200
passed_chunk_response.json.return_value = {
"action": "NONE",
"outputs": [],
"assessments": [],
"usage": {"contentPolicyUnits": 2, "wordPolicyUnits": 1},
}
failed_chunk_response = MagicMock()
failed_chunk_response.status_code = 400
failed_chunk_response.json.return_value = {"message": "ValidationException: guardrail is in a failed state"}
mock_credentials = MagicMock()
mock_credentials.access_key = "test-access-key"
mock_credentials.secret_key = "test-secret-key"
mock_credentials.token = None
request_data = {
"model": "gpt-4o",
"messages": [
{"role": "user", "content": "a" * 30},
{"role": "user", "content": "b" * 30},
],
}
with (
patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as mock_post,
patch.object(guardrail, "_load_credentials", return_value=(mock_credentials, "us-east-1")),
patch.object(guardrail, "_prepare_request", return_value=MagicMock()),
):
mock_post.side_effect = [too_large_response, passed_chunk_response, failed_chunk_response]
with pytest.raises(HTTPException):
await guardrail.make_bedrock_api_request(
source="INPUT",
messages=request_data["messages"],
request_data=request_data,
)
assert mock_post.call_count == 3
logged_entries = request_data["metadata"]["standard_logging_guardrail_information"]
assert len(logged_entries) == 1
logged = logged_entries[0]
assert logged["guardrail_status"] == "guardrail_failed_to_respond"
assert logged["guardrail_usage"] == {"contentPolicyUnits": 2, "wordPolicyUnits": 1}
assert logged["guardrail_cost"] == pytest.approx(0.0003)
assert logged["guardrail_response"]["usage"] == {"contentPolicyUnits": 2, "wordPolicyUnits": 1}
assert "error" in logged["guardrail_response"]

View file

@ -17,7 +17,7 @@ from litellm.proxy.hooks.proxy_track_cost_callback import (
_should_track_cost_callback,
_update_database_and_spend_counters,
)
from litellm.types.utils import CallTypes
from litellm.types.utils import CallTypes, Usage
@pytest.mark.asyncio
@ -152,6 +152,70 @@ async def test_async_post_call_failure_hook_does_not_clobber_guardrail_info_in_m
assert metadata["standard_logging_guardrail_information"] == metadata_bucket_info
@pytest.mark.asyncio
async def test_async_post_call_failure_hook_bills_guardrail_cost_on_blocked_request():
"""LIT-5651: a request blocked by a guardrail never reaches the LLM, but the
guardrail invocation itself is billed by the provider. The failure row must
charge that cost against the key instead of recording zero spend."""
logger = _ProxyDBLogger()
request_data = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"metadata": {
"standard_logging_guardrail_information": [
{
"guardrail_name": "bedrock-guard",
"guardrail_status": "guardrail_intervened",
"guardrail_usage": {"topicPolicyUnits": 1, "contentPolicyUnits": 1},
"guardrail_cost": 0.0003,
}
]
},
"proxy_server_request": {"request_id": "test_request_id"},
}
with patch(
"litellm.proxy.db.db_spend_update_writer.DBSpendUpdateWriter.update_database",
new_callable=AsyncMock,
) as mock_update_database:
await logger.async_post_call_failure_hook(
request_data=request_data,
original_exception=Exception("Violated guardrail policy"),
user_api_key_dict=UserAPIKeyAuth(api_key="test_api_key"),
)
assert mock_update_database.call_args[1]["response_cost"] == pytest.approx(0.0003)
@pytest.mark.asyncio
async def test_async_post_call_failure_hook_adds_guardrail_cost_to_recovered_stream_cost():
logger = _ProxyDBLogger()
request_data = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"metadata": {
"standard_logging_guardrail_information": [
{"guardrail_name": "bedrock-guard", "guardrail_status": "success", "guardrail_cost": 0.0003}
]
},
"proxy_server_request": {"request_id": "test_request_id"},
"combined_usage_object": Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15),
"response_cost": 0.001,
}
with patch(
"litellm.proxy.db.db_spend_update_writer.DBSpendUpdateWriter.update_database",
new_callable=AsyncMock,
) as mock_update_database:
await logger.async_post_call_failure_hook(
request_data=request_data,
original_exception=Exception("stream broke mid-flight"),
user_api_key_dict=UserAPIKeyAuth(api_key="test_api_key"),
)
assert mock_update_database.call_args[1]["response_cost"] == pytest.approx(0.0013)
@pytest.mark.asyncio
async def test_async_post_call_failure_hook_non_llm_route():
# Setup

View file

@ -102,6 +102,30 @@ class TestStripClientPricingOverrides:
assert data["metadata"] == {"user_session": "keep-me"}
assert data["litellm_metadata"] == {}
def test_metadata_guardrail_information_dropped(self):
# Client-seeded guardrail entries would otherwise be summed into
# response_cost and spend, letting a caller forge (even negative)
# guardrail cost against their own budget.
data = {
"model": "gpt-4",
"metadata": {
"user_session": "keep-me",
"standard_logging_guardrail_information": [
{
"guardrail_name": "forged",
"guardrail_status": "success",
"guardrail_cost": -0.005,
}
],
},
"litellm_metadata": {
"standard_logging_guardrail_information": [{"guardrail_cost": 5.0}],
},
}
_strip_client_pricing_overrides(data)
assert data["metadata"] == {"user_session": "keep-me"}
assert data["litellm_metadata"] == {}
def test_non_pricing_fields_untouched(self):
data = {
"model": "gpt-4",
@ -129,6 +153,7 @@ class TestStripClientPricingOverrides:
def test_metadata_field_set_contains_model_info(self):
assert "model_info" in _CLIENT_PRICING_METADATA_FIELDS
assert "standard_logging_guardrail_information" in _CLIENT_PRICING_METADATA_FIELDS
def test_strip_emits_debug_log_listing_dropped_fields(self, caplog):
# Operators need a paper trail so they can diagnose why a previously

View file

@ -869,6 +869,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"container",
"image_edit",
"embedding",
"guardrail",
"image_generation",
"video_generation",
"moderation",
@ -976,6 +977,10 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"type": "string",
},
},
"guardrail_cost_per_unit": {
"type": "object",
"additionalProperties": {"type": "number"},
},
"search_context_cost_per_query": {
"type": "object",
"properties": {

View file

@ -161,9 +161,6 @@
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -959,7 +956,7 @@
},
"src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx": {
@ -1790,7 +1787,7 @@
"count": 2
},
"no-restricted-imports": {
"count": 2
"count": 1
},
"prefer-const": {
"count": 2
@ -2735,7 +2732,7 @@
},
"src/components/team/LoggingSettings.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"src/components/team/TeamInfo.tsx": {
@ -2746,7 +2743,7 @@
"count": 3
},
"no-restricted-imports": {
"count": 2
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1

View file

@ -63,6 +63,19 @@ describe("CacheSettings advanced settings round-trip", () => {
});
});
it("reveals the advanced field sections only after the user expands them", async () => {
const user = userEvent.setup();
renderSettings();
await screen.findByText("Connection Settings");
expect(screen.queryByText("SSL Settings")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Advanced Settings" }));
expect(await screen.findByText("SSL Settings")).toBeInTheDocument();
expect(screen.getByText("Cache Management")).toBeInTheDocument();
expect(screen.getByText("GCP Authentication")).toBeInTheDocument();
});
it("sends the same payload whether or not the advanced section was expanded", async () => {
const user = userEvent.setup();
renderSettings();

View file

@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from "react";
import { ChevronRight } from "lucide-react";
import { FormProvider, useForm } from "react-hook-form";
import { Button } from "@tremor/react";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { getCacheSettingsCall, testCacheConnectionCall, updateCacheSettingsCall } from "@/components/networking";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";

View file

@ -95,6 +95,33 @@ describe("AddMarginForm", () => {
expect(onAddProvider).toHaveBeenCalledTimes(1);
});
it("should report the edited percentage as the user types", async () => {
const onPercentageChange = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<AddMarginForm {...DEFAULT_PROPS} percentageValue="1" onPercentageChange={onPercentageChange} />,
);
await user.type(screen.getByPlaceholderText("10"), "0");
expect(onPercentageChange).toHaveBeenCalledWith("10");
});
it("should report the edited fixed amount as the user types", async () => {
const onFixedAmountChange = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<AddMarginForm
{...DEFAULT_PROPS}
marginType="fixed"
fixedAmountValue="0.00"
onFixedAmountChange={onFixedAmountChange}
/>,
);
await user.type(screen.getByPlaceholderText("0.001"), "1");
expect(onFixedAmountChange).toHaveBeenCalledWith("0.001");
});
it("should call onMarginTypeChange when the Fixed Amount radio is clicked", async () => {
const onMarginTypeChange = vi.fn();
const user = userEvent.setup();

View file

@ -172,7 +172,7 @@ const AddMarginForm: React.FC<AddMarginFormProps> = ({
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-border">
<Button
type="button"
type="submit"
onClick={onAddProvider}
disabled={
!selectedProvider ||

View file

@ -62,6 +62,15 @@ describe("AddProviderForm", () => {
expect(onAddProvider).toHaveBeenCalledTimes(1);
});
it("should report the edited discount as the user types", async () => {
const onDiscountChange = vi.fn();
const user = userEvent.setup();
renderWithProviders(<AddProviderForm {...DEFAULT_PROPS} newDiscount="1" onDiscountChange={onDiscountChange} />);
await user.type(screen.getByPlaceholderText("5"), "5");
expect(onDiscountChange).toHaveBeenCalledWith("15");
});
it("should show the percent sign next to the discount input", () => {
renderWithProviders(<AddProviderForm {...DEFAULT_PROPS} />);
expect(screen.getByText("%")).toBeInTheDocument();

View file

@ -130,7 +130,7 @@ describe("CostTrackingSettings submit paths", () => {
expect(stableDiscountCallbacks.handleAddProvider).toHaveBeenCalledWith("OpenAI", "5");
});
it("leaves Enter inert in the margin field while the button still submits", async () => {
it("requests the margin exactly once when Enter is pressed in the percentage field", async () => {
const user = userEvent.setup();
renderWithProviders(<CostTrackingSettings {...ADMIN_PROPS} />);
const header = screen.getByText("Fee/Price Margin").closest("button");
@ -142,14 +142,7 @@ describe("CostTrackingSettings submit paths", () => {
await user.click((await screen.findAllByRole("option"))[0]);
await user.type(screen.getByLabelText(/Margin Percentage/i), "10{Enter}");
expect(stableMarginCallbacks.handleAddMargin).not.toHaveBeenCalled();
const submit = screen
.getAllByRole("button")
.filter((button) => (button.textContent || "").trim() === "Add Provider Margin")
.pop()!;
await user.click(submit);
await waitFor(() => expect(stableMarginCallbacks.handleAddMargin).toHaveBeenCalledTimes(1));
await waitFor(() => expect(stableMarginCallbacks.handleAddMargin).toHaveBeenCalled());
expect(stableMarginCallbacks.handleAddMargin).toHaveBeenCalledTimes(1);
});
});

View file

@ -382,7 +382,7 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
Select a provider (or &quot;Global&quot; for all providers) and configure the margin. You can use
percentage-based or fixed amount.
</p>
<div className="space-y-6">
<form onSubmit={(event) => event.preventDefault()} className="space-y-6">
<AddMarginForm
marginConfig={marginConfig}
selectedProvider={selectedMarginProvider}
@ -395,7 +395,7 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
onFixedAmountChange={setFixedAmountValue}
onAddProvider={handleAddMargin}
/>
</div>
</form>
</div>
</Modal>
</div>

View file

@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ModelSelector } from "./ModelSelector";
@ -28,6 +28,18 @@ describe("ModelSelector", () => {
expect(screen.getByTitle("custom-model-123")).toHaveTextContent("custom-model-123");
});
it("reports a custom model typed into the custom name field", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ModelSelector value="" onChange={onChange} models={MODELS} />);
await user.click(screen.getByRole("combobox"));
fireEvent.click(await screen.findByTitle("+ Add custom model"));
await user.type(await screen.findByPlaceholderText("Custom Model Name (Enter to add)"), "my-custom-model{Enter}");
expect(onChange).toHaveBeenCalledWith("my-custom-model");
});
it("disables the control when disabled is set", () => {
const { rerender } = render(<ModelSelector value="custom-model-123" onChange={vi.fn()} models={MODELS} />);
expect(screen.getByRole("combobox")).toBeEnabled();

View file

@ -1,6 +1,6 @@
import React, { useMemo, useState } from "react";
import { Select } from "antd";
import { TextInput } from "@tremor/react";
import { Input } from "@/components/ui/input";
interface ModelSelectorProps {
value: string;
onChange: (value: string) => void;
@ -68,11 +68,11 @@ export function ModelSelector({ value, onChange, models, loading, disabled }: Mo
<Select.Option value="__custom__">+ Add custom model</Select.Option>
</Select>
{isAddingCustom && (
<TextInput
<Input
className="mt-2"
placeholder="Custom Model Name (Enter to add)"
value={customValue}
onValueChange={setCustomValue}
onChange={(e) => setCustomValue(e.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();

View file

@ -5,9 +5,10 @@ import TeamInfoView from "@/components/team/TeamInfo";
import TeamSSOSettings from "@/components/TeamSSOSettings";
import { isProxyAdminRole } from "@/utils/roles";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Accordion, AccordionBody, AccordionHeader, TextInput } from "@tremor/react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input as UIInput } from "@/components/ui/input";
import { Button, Form, Input, Layout, Modal, Select, Switch, Tabs, theme, Tooltip, Typography } from "antd";
import { Plus, Users } from "lucide-react";
import { ChevronDown, Plus, Users } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { PageHeader } from "@/components/shared/PageHeader";
@ -542,7 +543,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
},
]}
>
<TextInput placeholder="" data-testid="team-name-input" />
<UIInput data-testid="team-name-input" />
</Form.Item>
{(() => {
const adminOrgs = getAdminOrganizations(userRole, userID, organizations);
@ -684,17 +685,18 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
/>
</Form.Item>
<Accordion className="mt-20 mb-8">
<AccordionHeader>
<Collapsible className="mt-20 mb-8 overflow-hidden rounded-lg border">
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Additional Settings</b>
</AccordionHeader>
<AccordionBody>
<ChevronDown className="size-5 shrink-0 text-gray-500 transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
<Form.Item
label="Team ID"
name="team_id"
help="ID of the team you want to create. If not provided, it will be generated automatically."
>
<TextInput
<UIInput
onChange={(e) => {
e.target.value = e.target.value.trim();
}}
@ -713,7 +715,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
name="team_member_key_duration"
tooltip="Set a limit to the duration of a team member's key. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days), 1mo (month)"
>
<TextInput placeholder="e.g., 30d" />
<UIInput placeholder="e.g., 30d" />
</Form.Item>
<Form.Item
label="Team Member RPM Limit"
@ -898,14 +900,15 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
disabled={!premiumUser || !isProxyAdminRole(userRole || "")}
/>
</Form.Item>
</AccordionBody>
</Accordion>
</CollapsibleContent>
</Collapsible>
<Accordion className="mt-8 mb-8">
<AccordionHeader>
<Collapsible className="mt-8 mb-8 overflow-hidden rounded-lg border">
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>MCP Settings</b>
</AccordionHeader>
<AccordionBody>
<ChevronDown className="size-5 shrink-0 text-gray-500 transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
<Form.Item
label={
<span>
@ -951,14 +954,15 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
</div>
)}
</Form.Item>
</AccordionBody>
</Accordion>
</CollapsibleContent>
</Collapsible>
<Accordion className="mt-8 mb-8">
<AccordionHeader>
<Collapsible className="mt-8 mb-8 overflow-hidden rounded-lg border">
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Agent Settings</b>
</AccordionHeader>
<AccordionBody>
<ChevronDown className="size-5 shrink-0 text-gray-500 transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
<Form.Item
label={
<span>
@ -979,14 +983,15 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
placeholder="Select agents or access groups (optional)"
/>
</Form.Item>
</AccordionBody>
</Accordion>
</CollapsibleContent>
</Collapsible>
<Accordion className="mt-8 mb-8">
<AccordionHeader>
<Collapsible className="mt-8 mb-8 overflow-hidden rounded-lg border">
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Search Tool Settings</b>
</AccordionHeader>
<AccordionBody>
<ChevronDown className="size-5 shrink-0 text-gray-500 transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
<Form.Item
label={
<span>
@ -1007,14 +1012,15 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
placeholder="Select search tools (optional, empty = all allowed)"
/>
</Form.Item>
</AccordionBody>
</Accordion>
</CollapsibleContent>
</Collapsible>
<Accordion className="mt-8 mb-8">
<AccordionHeader>
<Collapsible className="mt-8 mb-8 overflow-hidden rounded-lg border">
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Logging Settings</b>
</AccordionHeader>
<AccordionBody>
<ChevronDown className="size-5 shrink-0 text-gray-500 transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
<div className="mt-4">
<PremiumLoggingSettings
value={loggingSettings}
@ -1022,14 +1028,18 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
premiumUser={premiumUser}
/>
</div>
</AccordionBody>
</Accordion>
</CollapsibleContent>
</Collapsible>
<Accordion key={`router-settings-accordion-${routerSettingsKey}`} className="mt-8 mb-8">
<AccordionHeader>
<Collapsible
key={`router-settings-accordion-${routerSettingsKey}`}
className="mt-8 mb-8 overflow-hidden rounded-lg border"
>
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Router Settings</b>
</AccordionHeader>
<AccordionBody>
<ChevronDown className="size-5 shrink-0 text-gray-500 transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
<div className="mt-4 w-full">
<RouterSettingsAccordion
key={routerSettingsKey}
@ -1041,14 +1051,15 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
}
/>
</div>
</AccordionBody>
</Accordion>
</CollapsibleContent>
</Collapsible>
<Accordion className="mt-8 mb-8">
<AccordionHeader>
<Collapsible className="mt-8 mb-8 overflow-hidden rounded-lg border">
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Model Aliases</b>
</AccordionHeader>
<AccordionBody>
<ChevronDown className="size-5 shrink-0 text-gray-500 transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
<div className="mt-4">
<Text type="secondary" style={{ fontSize: 14, marginBottom: 16, display: "block" }}>
Create custom aliases for models that can be used by team members in API calls. This allows you to
@ -1061,8 +1072,8 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
showExampleConfig={false}
/>
</div>
</AccordionBody>
</Accordion>
</CollapsibleContent>
</Collapsible>
</>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button htmlType="submit" data-testid="create-team-submit">

View file

@ -1,5 +1,6 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen, fireEvent } from "../../../tests/test-utils";
import LoggingSettings from "./LoggingSettings";
@ -109,6 +110,29 @@ describe("LoggingSettings", () => {
expect(updatedConfig[0].callback_vars.langsmith_sampling_rate).toBe("0.3"); // Preserves initial value
});
it("masks a sensitive parameter until the reveal toggle is used", async () => {
const user = userEvent.setup({ delay: null });
const initialValue = [
{
callback_name: "langsmith",
callback_type: "success",
callback_vars: { langsmith_api_key: "sk-secret-value" },
},
];
renderWithProviders(<LoggingSettings value={initialValue} onChange={vi.fn()} />);
const apiKeyInput = screen.getByPlaceholderText("os.environ/LANGSMITH_API_KEY");
expect(apiKeyInput).toHaveAttribute("type", "password");
await user.click(screen.getByRole("button", { name: "Show password" }));
expect(apiKeyInput).toHaveAttribute("type", "text");
expect(apiKeyInput).toHaveValue("sk-secret-value");
await user.click(screen.getByRole("button", { name: "Hide password" }));
expect(apiKeyInput).toHaveAttribute("type", "password");
});
it("shows the bundled logo in the integration card header", () => {
const initialValue = [
{

View file

@ -2,14 +2,51 @@
import React from "react";
import { Select, Tooltip, Divider } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, Card, TextInput } from "@tremor/react";
import { PlusIcon, TrashIcon, CogIcon, BanIcon } from "@heroicons/react/outline";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { CogIcon, BanIcon } from "@heroicons/react/outline";
import { Eye, EyeOff, Plus, Trash2 } from "lucide-react";
import { callbackInfo, callback_map, mapDisplayToInternalNames } from "../callback_info_helpers";
import { Logo } from "@/components/molecules/logo/Logo";
import NumericalInput from "../shared/numerical_input";
const { Option } = Select;
const CallbackVarInput: React.FC<{
sensitive: boolean;
placeholder: string;
value: string;
onValueChange: (value: string) => void;
}> = ({ sensitive, placeholder, value, onValueChange }) => {
const [revealed, setRevealed] = React.useState(false);
if (!sensitive) {
return <Input placeholder={placeholder} value={value} onChange={(e) => onValueChange(e.target.value)} />;
}
return (
<InputGroup>
<InputGroupInput
type={revealed ? "text" : "password"}
placeholder={placeholder}
value={value}
onChange={(e) => onValueChange(e.target.value)}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
onClick={() => setRevealed(!revealed)}
aria-label={revealed ? "Hide password" : "Show password"}
>
{revealed ? <EyeOff /> : <Eye />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
);
};
interface LoggingConfig {
callback_name: string;
callback_type: string;
@ -138,11 +175,11 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
onChange={(e: any) => updateCallbackVar(configIndex, paramName, e.target.value)}
/>
) : (
<TextInput
type={paramType === "password" ? "password" : "text"}
<CallbackVarInput
sensitive={paramType === "password"}
placeholder={`os.environ/${paramName.toUpperCase()}`}
value={config.callback_vars[paramName] || ""}
onChange={(e) => updateCallbackVar(configIndex, paramName, e.target.value)}
onValueChange={(newValue) => updateCallbackVar(configIndex, paramName, newValue)}
/>
)}
</div>
@ -212,11 +249,11 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
<Button
variant="secondary"
onClick={addLoggingConfig}
icon={PlusIcon}
size="sm"
className="hover:border-blue-400 hover:text-blue-500"
type="button"
>
<Plus />
Add Integration
</Button>
</div>
@ -230,9 +267,7 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
return (
<Card
key={index}
className="border border-gray-200 shadow-xs hover:shadow-md transition-shadow duration-200"
decoration="top"
decorationColor="blue"
className="block p-6 border border-gray-200 shadow-xs hover:shadow-md transition-shadow duration-200"
>
<div className="flex justify-between items-start mb-4">
<div className="flex items-center space-x-2">
@ -246,14 +281,13 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
<span className="text-sm font-medium">{callbackDisplayName || "New Integration"} Configuration</span>
</div>
<Button
variant="light"
variant="ghost"
onClick={() => removeLoggingConfig(index)}
icon={TrashIcon}
size="xs"
color="red"
className="hover:bg-red-50"
size="sm"
className="text-red-500 hover:bg-red-50 hover:text-red-500"
type="button"
>
<Trash2 />
Remove
</Button>
</div>

View file

@ -29,10 +29,14 @@ import {
SaveOutlined,
} from "@ant-design/icons";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { Accordion, AccordionBody, AccordionHeader, Badge, Card, Grid, Text, TextInput, Title } from "@tremor/react";
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input as UIInput } from "@/components/ui/input";
import { Button, Form, Input, InputNumber, Select, Space, Switch, Tabs, Tag, Tooltip } from "antd";
import { toast } from "@/lib/toast";
import { CheckIcon, CopyIcon } from "lucide-react";
import { CheckIcon, ChevronDown, CopyIcon } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
import AccessGroupSelector from "../common_components/AccessGroupSelector";
@ -92,11 +96,11 @@ const UI_MANAGED_METADATA_KEYS: ReadonlySet<string> = new Set([
"disable_global_guardrails",
]);
const TEAM_MODEL_BADGE_COLORS: Record<TeamModelBadgeKind, "red" | "gray" | "blue" | "green"> = {
"all-proxy": "red",
"no-default": "gray",
direct: "blue",
"access-group": "green",
const TEAM_MODEL_BADGE_TONES: Record<TeamModelBadgeKind, StatusTone> = {
"all-proxy": "error",
"no-default": "neutral",
direct: "info",
"access-group": "success",
};
export interface TeamMembership {
@ -732,9 +736,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<Button type="text" icon={<ArrowLeftIcon className="h-4 w-4" />} onClick={onClose} className="mb-4">
Back to Teams
</Button>
<Title>{info.team_alias}</Title>
<h1 className="text-2xl font-semibold">{info.team_alias}</h1>
<div className="flex items-center">
<Text className="text-gray-500 font-mono">{info.team_id}</Text>
<p className="text-sm text-gray-500 font-mono">{info.team_id}</p>
<Button
type="text"
size="small"
@ -758,30 +762,30 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
key: TEAM_INFO_TAB_KEYS.OVERVIEW,
label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.OVERVIEW],
children: (
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
<Card>
<Text>Budget Status</Text>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<Card className="block p-6">
<p>Budget Status</p>
<div className="mt-2">
<Title>${formatNumberWithCommas(info.spend, 2)}</Title>
<Text>
<h3 className="text-lg font-medium">${formatNumberWithCommas(info.spend, 2)}</h3>
<p>
of {info.max_budget === null ? "Unlimited" : `$${formatNumberWithCommas(info.max_budget, 2)}`}
</Text>
{info.budget_duration && <Text className="text-gray-500">Reset: {info.budget_duration}</Text>}
</p>
{info.budget_duration && <p className="text-gray-500">Reset: {info.budget_duration}</p>}
<br />
{info.team_member_budget_table && (
<Text className="text-gray-500">
<p className="text-gray-500">
Team Member Budget: ${formatNumberWithCommas(info.team_member_budget_table.max_budget, 2)}
</Text>
</p>
)}
</div>
</Card>
<Card>
<Text>Rate Limits</Text>
<Card className="block p-6">
<p>Rate Limits</p>
<div className="mt-2">
<Text>TPM: {info.tpm_limit || "Unlimited"}</Text>
<Text>RPM: {info.rpm_limit || "Unlimited"}</Text>
{info.max_parallel_requests && <Text>Max Parallel Requests: {info.max_parallel_requests}</Text>}
<p>TPM: {info.tpm_limit || "Unlimited"}</p>
<p>RPM: {info.rpm_limit || "Unlimited"}</p>
{info.max_parallel_requests && <p>Max Parallel Requests: {info.max_parallel_requests}</p>}
{(() => {
const modelTpm = (info.metadata?.model_tpm_limit ?? {}) as Record<string, number>;
const modelRpm = (info.metadata?.model_rpm_limit ?? {}) as Record<string, number>;
@ -789,33 +793,33 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
if (models.length === 0) return null;
return (
<div className="mt-3">
<Text className="text-gray-500">Per-model limits:</Text>
<p className="text-gray-500">Per-model limits:</p>
{models.map((m) => (
<Text key={m} className="text-xs">
<p key={m} className="text-xs">
{m}: TPM {modelTpm[m] ?? "—"}, RPM {modelRpm[m] ?? "—"}
</Text>
</p>
))}
</div>
);
})()}
<Text>Estimated Output Tokens: {info.metadata?.default_estimated_output_tokens ?? "Default"}</Text>
<Text>
<p>Estimated Output Tokens: {info.metadata?.default_estimated_output_tokens ?? "Default"}</p>
<p>
Estimated Output Tokens Per Model:{" "}
{info.metadata?.default_estimated_output_tokens_per_model
? JSON.stringify(info.metadata.default_estimated_output_tokens_per_model)
: "Default"}
</Text>
</p>
</div>
</Card>
<Card>
<Text>Models</Text>
<Card className="block p-6">
<p>Models</p>
<div className="mt-2 flex flex-wrap gap-2">
{computeTeamModelBadges(info.models, info.access_group_models || [], info.access_group_details).map(
(badge, index) => (
<Tooltip key={`${badge.kind}-${badge.label}-${index}`} title={badge.tooltip}>
<span>
<Badge color={TEAM_MODEL_BADGE_COLORS[badge.kind]}>{badge.label}</Badge>
<StatusBadge tone={TEAM_MODEL_BADGE_TONES[badge.kind]} label={badge.label} />
</span>
</Tooltip>
),
@ -823,12 +827,12 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
</div>
</Card>
<Card>
<Text className="font-semibold text-gray-900">Virtual Keys</Text>
<Card className="block p-6">
<p className="font-semibold text-gray-900">Virtual Keys</p>
<div className="mt-2">
<Text>User Keys: {teamData.keys.filter((key) => key.user_id).length}</Text>
<Text>Service Account Keys: {teamData.keys.filter((key) => !key.user_id).length}</Text>
<Text className="text-gray-500">Total: {teamData.keys.length}</Text>
<p>User Keys: {teamData.keys.filter((key) => key.user_id).length}</p>
<p>Service Account Keys: {teamData.keys.filter((key) => !key.user_id).length}</p>
<p className="text-gray-500">Total: {teamData.keys.length}</p>
</div>
</Card>
@ -838,7 +842,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
accessToken={accessToken}
/>
<Card>
<Card className="block p-6">
<GuardrailSettingsView
globalGuardrailNames={globalGuardrailNames}
teamGuardrails={Array.isArray(info.metadata?.guardrails) ? info.metadata.guardrails : []}
@ -852,22 +856,22 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
/>
</Card>
<Card>
<Text className="font-semibold text-gray-900 mb-3">Policies</Text>
<Card className="block p-6">
<p className="font-semibold text-gray-900 mb-3">Policies</p>
{info.policies && info.policies.length > 0 ? (
<div className="space-y-4">
{info.policies.map((policy: string, index: number) => (
<div key={index} className="space-y-2">
<div className="flex items-center gap-2">
<Badge color="purple">{policy}</Badge>
{loadingPolicies && <Text className="text-xs text-gray-400">Loading guardrails...</Text>}
<Badge variant="secondary">{policy}</Badge>
{loadingPolicies && <p className="text-xs text-gray-400">Loading guardrails...</p>}
</div>
{!loadingPolicies && policyGuardrails[policy] && policyGuardrails[policy].length > 0 && (
<div className="ml-4 pl-3 border-l-2 border-gray-200">
<Text className="text-xs text-gray-500 mb-1">Resolved Guardrails:</Text>
<p className="text-xs text-gray-500 mb-1">Resolved Guardrails:</p>
<div className="flex flex-wrap gap-1">
{policyGuardrails[policy].map((guardrail: string, gIndex: number) => (
<Badge key={gIndex} color="blue" size="xs">
<Badge key={gIndex} variant="secondary">
{guardrail}
</Badge>
))}
@ -878,7 +882,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
))}
</div>
) : (
<Text className="text-gray-500">No policies configured</Text>
<p className="text-gray-500">No policies configured</p>
)}
</Card>
@ -887,7 +891,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
disabledCallbacks={[]}
variant="card"
/>
</Grid>
</div>
),
},
{
@ -923,9 +927,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
key: TEAM_INFO_TAB_KEYS.SETTINGS,
label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.SETTINGS],
children: (
<Card className="overflow-y-auto max-h-[65vh]">
<Card className="block p-6 overflow-y-auto max-h-[65vh]">
<div className="flex justify-between items-center mb-4">
<Title>Team Settings</Title>
<h3 className="text-lg font-medium">Team Settings</h3>
{canEditTeam && !isEditing && (
<Button
icon={<EditOutlined className="h-4 w-4" />}
@ -1079,15 +1083,16 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<Input placeholder="example1@test.com, example2@test.com" />
</Form.Item>
<Accordion className="mt-4 mb-4">
<AccordionHeader>
<Collapsible className="mt-4 mb-4 overflow-hidden rounded-lg border">
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Team Member Settings</b>
</AccordionHeader>
<AccordionBody>
<Text className="text-xs text-gray-500 mb-4">
<ChevronDown className="size-5 shrink-0 text-gray-500 transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
<p className="text-xs text-gray-500 mb-4">
Optional defaults applied when members join this team. All fields can be overridden per
member.
</Text>
</p>
<Form.Item
label={
<span>
@ -1132,7 +1137,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
name="team_member_key_duration"
tooltip="Set a limit to the duration of a team member's key. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days), 1mo (month)"
>
<TextInput placeholder="e.g., 30d" />
<UIInput placeholder="e.g., 30d" />
</Form.Item>
<Form.Item
label="Default TPM Limit"
@ -1148,8 +1153,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
>
<NumericalInput step={1} style={{ width: "100%" }} placeholder="e.g., 100" />
</Form.Item>
</AccordionBody>
</Accordion>
</CollapsibleContent>
</Collapsible>
<Form.Item label="Reset Budget" name="budget_duration">
<BudgetDurationDropdown placeholder="Never resets" />
@ -1447,11 +1452,12 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
/>
</Form.Item>
<Accordion className="mt-4 mb-4">
<AccordionHeader>
<Collapsible className="mt-4 mb-4 overflow-hidden rounded-lg border">
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Search Tool Settings</b>
</AccordionHeader>
<AccordionBody>
<ChevronDown className="size-5 shrink-0 text-gray-500 transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
<Form.Item
label="Allowed Search Tools"
name="object_permission_search_tools"
@ -1464,8 +1470,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
placeholder="Select search tools (optional, empty = all allowed)"
/>
</Form.Item>
</AccordionBody>
</Accordion>
</CollapsibleContent>
</Collapsible>
<Form.Item label="Organization" name="organization_id">
<Select
@ -1537,22 +1543,22 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
) : (
<div className="space-y-4">
<div>
<Text className="font-medium">Team Name</Text>
<p className="font-medium">Team Name</p>
<div>{info.team_alias}</div>
</div>
<div>
<Text className="font-medium">Team ID</Text>
<p className="font-medium">Team ID</p>
<div className="font-mono">{info.team_id}</div>
</div>
<div>
<Text className="font-medium">Created At</Text>
<p className="font-medium">Created At</p>
<div>{new Date(info.created_at).toLocaleString()}</div>
</div>
<div>
<Text className="font-medium">Models</Text>
<p className="font-medium">Models</p>
<div className="flex flex-wrap gap-2 mt-1">
{info.models.map((model, index) => (
<Badge key={index} color="red">
<Badge key={index} variant="secondary">
{model}
</Badge>
))}
@ -1560,10 +1566,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
</div>
{info.default_team_member_models && info.default_team_member_models.length > 0 && (
<div>
<Text className="font-medium">Default Member Models</Text>
<p className="font-medium">Default Member Models</p>
<div className="flex flex-wrap gap-2 mt-1">
{info.default_team_member_models.map((model, index) => (
<Badge key={index} color="blue">
<Badge key={index} variant="secondary">
{model}
</Badge>
))}
@ -1571,7 +1577,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
</div>
)}
<div>
<Text className="font-medium">Model Aliases</Text>
<p className="font-medium">Model Aliases</p>
{(() => {
const aliasEntries = Object.entries(info.litellm_model_table?.model_aliases ?? {});
if (aliasEntries.length === 0) {
@ -1591,7 +1597,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
})()}
</div>
<div>
<Text className="font-medium">Rate Limits</Text>
<p className="font-medium">Rate Limits</p>
<div>TPM: {info.tpm_limit || "Unlimited"}</div>
<div>RPM: {info.rpm_limit || "Unlimited"}</div>
{(() => {
@ -1601,7 +1607,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
if (models.length === 0) return null;
return (
<div className="mt-2">
<Text className="text-gray-500">Per-model limits:</Text>
<p className="text-gray-500">Per-model limits:</p>
{models.map((m) => (
<div key={m} className="text-xs ml-2">
{m}: TPM {modelTpm[m] ?? "—"}, RPM {modelRpm[m] ?? "—"}
@ -1619,7 +1625,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
</div>
</div>
<div>
<Text className="font-medium">Team Budget</Text>
<p className="font-medium">Team Budget</p>
<div>
Max Budget:{" "}
{info.max_budget !== null ? `$${formatNumberWithCommas(info.max_budget, 4)}` : "No Limit"}
@ -1638,12 +1644,12 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
)}
</div>
<div>
<Text className="font-medium">
<p className="font-medium">
Team Member Settings{" "}
<Tooltip title="These are limits on individual team members">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</Text>
</p>
<div>Max Budget: {info.team_member_budget_table?.max_budget || "No Limit"}</div>
<div>Budget Duration: {info.team_member_budget_table?.budget_duration || "No Limit"}</div>
<div>Key Duration: {info.metadata?.team_member_key_duration || "No Limit"}</div>
@ -1651,7 +1657,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<div>RPM Limit: {info.team_member_budget_table?.rpm_limit || "No Limit"}</div>
</div>
<div>
<Text className="font-medium">Router Settings</Text>
<p className="font-medium">Router Settings</p>
{info.router_settings &&
Object.values(info.router_settings).some(
(v) => v !== null && v !== undefined && v !== "" && !(Array.isArray(v) && v.length === 0),
@ -1659,7 +1665,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<div className="mt-1 space-y-1">
{info.router_settings.routing_strategy && (
<div>
Routing Strategy: <Badge color="blue">{info.router_settings.routing_strategy}</Badge>
Routing Strategy:{" "}
<Badge variant="secondary">{info.router_settings.routing_strategy}</Badge>
</div>
)}
{info.router_settings.num_retries != null && (
@ -1687,12 +1694,14 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
)}
</div>
<div>
<Text className="font-medium">Organization ID</Text>
<p className="font-medium">Organization ID</p>
<div>{info.organization_id}</div>
</div>
<div>
<Text className="font-medium">Status</Text>
<Badge color={info.blocked ? "red" : "green"}>{info.blocked ? "Blocked" : "Active"}</Badge>
<p className="font-medium">Status</p>
<Badge variant={info.blocked ? "destructive" : "secondary"}>
{info.blocked ? "Blocked" : "Active"}
</Badge>
</div>
<ObjectPermissionsView
@ -1724,7 +1733,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
{info.metadata?.secret_manager_settings && (
<div className="pt-4 border-t border-gray-200">
<Text className="font-medium">Secret Manager Settings</Text>
<p className="font-medium">Secret Manager Settings</p>
<pre className="mt-2 bg-gray-50 p-3 rounded-sm text-xs overflow-x-auto">
{JSON.stringify(info.metadata.secret_manager_settings, null, 2)}
</pre>

View file

@ -27356,6 +27356,8 @@ export interface components {
cache_creation_input_token_cost_flex?: number | null;
/** Cache Creation Input Token Cost Priority */
cache_creation_input_token_cost_priority?: number | null;
/** Cache Creation Input Token Cost Ultrafast */
cache_creation_input_token_cost_ultrafast?: number | null;
/** Cache Read Input Audio Token Cost */
cache_read_input_audio_token_cost?: number | null;
/** Cache Read Input Token Cost */
@ -27376,6 +27378,8 @@ export interface components {
cache_read_input_token_cost_flex?: number | null;
/** Cache Read Input Token Cost Priority */
cache_read_input_token_cost_priority?: number | null;
/** Cache Read Input Token Cost Ultrafast */
cache_read_input_token_cost_ultrafast?: number | null;
/** Citation Cost Per Token */
citation_cost_per_token?: number | null;
/** Complexity Router Config */
@ -27440,6 +27444,8 @@ export interface components {
input_cost_per_token_flex?: number | null;
/** Input Cost Per Token Priority */
input_cost_per_token_priority?: number | null;
/** Input Cost Per Token Ultrafast */
input_cost_per_token_ultrafast?: number | null;
/** Input Cost Per Video Per Second */
input_cost_per_video_per_second?: number | null;
/** Input Cost Per Video Per Second Above 128K Tokens */
@ -27537,6 +27543,8 @@ export interface components {
output_cost_per_token_flex?: number | null;
/** Output Cost Per Token Priority */
output_cost_per_token_priority?: number | null;
/** Output Cost Per Token Ultrafast */
output_cost_per_token_ultrafast?: number | null;
/** Output Cost Per Video Per Second */
output_cost_per_video_per_second?: number | null;
/** Output Cost Per Video Token */
@ -36458,6 +36466,8 @@ export interface components {
cache_creation_input_token_cost_flex?: number | null;
/** Cache Creation Input Token Cost Priority */
cache_creation_input_token_cost_priority?: number | null;
/** Cache Creation Input Token Cost Ultrafast */
cache_creation_input_token_cost_ultrafast?: number | null;
/** Cache Read Input Audio Token Cost */
cache_read_input_audio_token_cost?: number | null;
/** Cache Read Input Token Cost */
@ -36478,6 +36488,8 @@ export interface components {
cache_read_input_token_cost_flex?: number | null;
/** Cache Read Input Token Cost Priority */
cache_read_input_token_cost_priority?: number | null;
/** Cache Read Input Token Cost Ultrafast */
cache_read_input_token_cost_ultrafast?: number | null;
/** Citation Cost Per Token */
citation_cost_per_token?: number | null;
/** Complexity Router Config */
@ -36542,6 +36554,8 @@ export interface components {
input_cost_per_token_flex?: number | null;
/** Input Cost Per Token Priority */
input_cost_per_token_priority?: number | null;
/** Input Cost Per Token Ultrafast */
input_cost_per_token_ultrafast?: number | null;
/** Input Cost Per Video Per Second */
input_cost_per_video_per_second?: number | null;
/** Input Cost Per Video Per Second Above 128K Tokens */
@ -36639,6 +36653,8 @@ export interface components {
output_cost_per_token_flex?: number | null;
/** Output Cost Per Token Priority */
output_cost_per_token_priority?: number | null;
/** Output Cost Per Token Ultrafast */
output_cost_per_token_ultrafast?: number | null;
/** Output Cost Per Video Per Second */
output_cost_per_video_per_second?: number | null;
/** Output Cost Per Video Token */