mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/funny-cerf-33d2bd
This commit is contained in:
commit
c1b2df8e4f
36 changed files with 1140 additions and 204 deletions
|
|
@ -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).",
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
78
litellm/litellm_core_utils/llm_cost_calc/guardrail_cost.py
Normal file
78
litellm/litellm_core_utils/llm_cost_calc/guardrail_cost.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -382,7 +382,7 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
|
|||
Select a provider (or "Global" 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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
16
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
16
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue