mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge remote-tracking branch 'origin/main' into litellm_default_team_access_group_ids
This commit is contained in:
commit
75d9a5548e
87 changed files with 3200 additions and 330 deletions
|
|
@ -95,6 +95,8 @@ attributes! {
|
|||
source: String,
|
||||
/// Model the provider recommends migrating to before `deprecation_date`, as a catalog key when one exists.
|
||||
successor_model: String,
|
||||
/// URL of the provider page or feed `deprecation_date` was read from.
|
||||
deprecation_source: String,
|
||||
/// Audio container formats the model can return.
|
||||
supported_audio_formats: Vec<AudioFormat>,
|
||||
/// OpenAI-style API routes this model can be called through, e.g. /v1/chat/completions.
|
||||
|
|
@ -307,6 +309,8 @@ mod tests {
|
|||
#[case::negative_dbu("input_dbu_cost_per_token", json!(-1), false)]
|
||||
#[case::successor_model("successor_model", json!("gpt-6-luna"), true)]
|
||||
#[case::successor_model_not_a_name("successor_model", json!(["gpt-6-luna"]), false)]
|
||||
#[case::deprecation_source("deprecation_source", json!("https://developers.openai.com/api/docs/deprecations"), true)]
|
||||
#[case::deprecation_source_not_a_url_string("deprecation_source", json!(7), false)]
|
||||
#[case::thinking_on_by_default("thinking_on_by_default", json!(true), true)]
|
||||
#[case::thinking_on_by_default_not_a_flag("thinking_on_by_default", json!("yes"), false)]
|
||||
#[case::reasoning_contexts("reasoning_contexts", json!(["reasoning", "non_reasoning"]), true)]
|
||||
|
|
|
|||
|
|
@ -1102,20 +1102,19 @@ def _cost_map_model_name(model: str, custom_llm_provider: str | None, region_nam
|
|||
return _strip_unregistered_leading_segments(f"{provider_prefix}/{model}", region_name)
|
||||
|
||||
|
||||
def _rule_declares_rates(model: str, custom_llm_provider: str | None) -> bool:
|
||||
rule_info: Final = unmapped_model_generalization(model, custom_llm_provider)
|
||||
return rule_info is not None and _cost_map_entry_prices_anything(rule_info)
|
||||
def declared_cost_map_pricing(key: str, model: str, custom_llm_provider: str | None) -> Mapping[str, object]:
|
||||
entry: Final = _raw_cost_map_entry(key)
|
||||
if entry is None:
|
||||
return unmapped_model_generalization(model, custom_llm_provider) or MappingProxyType({})
|
||||
fill_missing: Final = mapped_model_fill_missing(key, entry)
|
||||
return entry if fill_missing is None else MappingProxyType({**fill_missing, **entry})
|
||||
|
||||
|
||||
def _resolves_to_rates(model: str, custom_llm_provider: str | None) -> bool:
|
||||
key: Final = cached_cost_map_key(model, custom_llm_provider)
|
||||
if key is None:
|
||||
return False
|
||||
entry: Final = _raw_cost_map_entry(key)
|
||||
if entry is None:
|
||||
return _rule_declares_rates(model, custom_llm_provider)
|
||||
fill_missing: Final = mapped_model_fill_missing(key, entry)
|
||||
return _cost_map_entry_prices_anything(entry if fill_missing is None else {**fill_missing, **entry})
|
||||
return key is not None and _cost_map_entry_prices_anything(
|
||||
declared_cost_map_pricing(key, model, custom_llm_provider)
|
||||
)
|
||||
|
||||
|
||||
def _served_or_priced_requested_model(
|
||||
|
|
@ -2420,18 +2419,20 @@ def completion_cost(
|
|||
raise e
|
||||
|
||||
|
||||
def get_response_cost_from_hidden_params(
|
||||
hidden_params: dict | BaseModel,
|
||||
) -> float | None:
|
||||
if isinstance(hidden_params, BaseModel):
|
||||
_hidden_params_dict = hidden_params.model_dump()
|
||||
else:
|
||||
_hidden_params_dict = hidden_params
|
||||
_ADDITIONAL_HEADERS: Final = TypeAdapter(Mapping[str, object])
|
||||
|
||||
additional_headers: Final = _hidden_params_dict.get("additional_headers", {})
|
||||
if additional_headers and "llm_provider-x-litellm-response-cost" in additional_headers:
|
||||
return _billable_header_cost(additional_headers["llm_provider-x-litellm-response-cost"])
|
||||
return None
|
||||
|
||||
def get_response_cost_from_hidden_params(
|
||||
hidden_params: Mapping[str, object] | BaseModel,
|
||||
) -> float | None:
|
||||
params: Final[Mapping[str, object]] = (
|
||||
hidden_params.model_dump() if isinstance(hidden_params, BaseModel) else hidden_params
|
||||
)
|
||||
additional_headers: Final = params.get("additional_headers")
|
||||
if not isinstance(additional_headers, Mapping):
|
||||
return None
|
||||
headers: Final = _ADDITIONAL_HEADERS.validate_python(additional_headers)
|
||||
return _billable_header_cost(headers.get("llm_provider-x-litellm-response-cost"))
|
||||
|
||||
|
||||
def _billable_header_cost(value: object) -> float | None:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from collections.abc import Iterator, Mapping
|
||||
from collections.abc import Iterable, Iterator, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
|
|
@ -25,6 +25,14 @@ def deployment_pricing_models(deployment: object) -> tuple[str, ...]:
|
|||
return pricing_models(info.get("base_model") or params.get("base_model"), params.get("model"))
|
||||
|
||||
|
||||
def deployment_backend_model(deployment: object) -> str:
|
||||
return next(iter(deployment_pricing_models(deployment)), "")
|
||||
|
||||
|
||||
def deployment_backend_models(deployments: Iterable[object]) -> tuple[str, ...]:
|
||||
return tuple(map(deployment_backend_model, deployments))
|
||||
|
||||
|
||||
def _named_models(*candidates: object) -> Iterator[str]:
|
||||
return (candidate for candidate in candidates if isinstance(candidate, str) and candidate)
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ from litellm.constants import (
|
|||
from litellm.cost_calculator import (
|
||||
RealtimeAPITokenUsageProcessor,
|
||||
ResponsesWebSocketTokenUsageProcessor,
|
||||
declared_cost_map_pricing,
|
||||
get_response_cost_from_hidden_params,
|
||||
get_usage_object,
|
||||
pricing_entry_for_cost_calc,
|
||||
select_model_name_for_cost_calc,
|
||||
|
|
@ -74,6 +76,7 @@ from litellm.integrations.deepeval.deepeval import DeepEvalLogger
|
|||
from litellm.integrations.langtrace import langtrace_trace_endpoint
|
||||
from litellm.integrations.mlflow import MlflowLogger
|
||||
from litellm.integrations.sqs import SQSLogger
|
||||
from litellm.litellm_core_utils.add_retry_fallback_headers import get_hidden_params_dict
|
||||
from litellm.litellm_core_utils.classifier_logging import (
|
||||
classifier_audit_fields,
|
||||
classifier_input_snapshot,
|
||||
|
|
@ -215,7 +218,10 @@ from litellm.types.utils import (
|
|||
batch_chat_usage,
|
||||
)
|
||||
from litellm.types.videos.main import VideoObject
|
||||
from litellm.utils import get_base_model_from_metadata, print_verbose
|
||||
from litellm.utils import (
|
||||
get_base_model_from_metadata,
|
||||
print_verbose,
|
||||
)
|
||||
|
||||
from ..integrations.argilla import ArgillaLogger
|
||||
from ..integrations.arize.arize_phoenix import ArizePhoenixLogger
|
||||
|
|
@ -2582,6 +2588,33 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
**kwargs,
|
||||
)
|
||||
|
||||
def _response_price_is_known(self, result: object, cost: float, payload: StandardLoggingPayload) -> bool:
|
||||
if (
|
||||
cost != 0
|
||||
or self._provider_reported_cost == 0
|
||||
or get_response_cost_from_hidden_params(get_hidden_params_dict(result)) == 0
|
||||
):
|
||||
return True
|
||||
if self.model_call_details.get("cache_hit") is True or _served_from_cache_at_no_charge(result):
|
||||
return True
|
||||
params: Final = _LITELLM_PARAMS.validate_python(self._model_call_details_view.get("litellm_params") or {})
|
||||
if is_unbilled_non_inference_call(
|
||||
payload["call_type"], StandardLoggingPayloadSetup.merge_litellm_metadata(params), result
|
||||
):
|
||||
return True
|
||||
if self.custom_cost_per_token is not None or self.custom_cost_per_second is not None:
|
||||
return True
|
||||
passthrough: Final = _LITELLM_PARAMS.validate_python(
|
||||
self._model_call_details_view.get("passthrough_logging_payload") or {}
|
||||
)
|
||||
if passthrough.get("cost_per_request") is not None:
|
||||
return True
|
||||
model_info: Final = payload["model_map_information"]["model_map_value"]
|
||||
if model_info is None:
|
||||
return False
|
||||
prices: Final = declared_cost_map_pricing(model_info["key"], model_info["key"], payload["custom_llm_provider"])
|
||||
return any(value is not None and ("cost" in key or "pricing" in key) for key, value in prices.items())
|
||||
|
||||
def prepare_response_cost(self, response: object, now: datetime.datetime) -> PreparedCost | None:
|
||||
if self.prepared_cost is not None:
|
||||
return self.prepared_cost
|
||||
|
|
@ -2634,6 +2667,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
"cost_breakdown": copy.deepcopy(payload["cost_breakdown"]),
|
||||
},
|
||||
usage,
|
||||
price_known=self._response_price_is_known(prepared[2], raw_cost, payload),
|
||||
)
|
||||
self.record_partial_usage_for_failure(
|
||||
usage, raw_cost, observation=self.model_call_details["_accounting_usage_observed"]
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class PreparedCost:
|
|||
amount: float
|
||||
payload: StandardLoggingPayload
|
||||
usage: Usage
|
||||
price_known: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
|
|||
|
|
@ -399,6 +399,74 @@ def split_media_for_tpm(value: object) -> tuple[object, tuple[Mapping[str, objec
|
|||
return projection.content, projection.media
|
||||
|
||||
|
||||
_TPM_EMBEDDING_FILE_MIME_PREFIXES: Final = ("application/pdf", "video/")
|
||||
|
||||
|
||||
def _tpm_embedding_file(data_uri: str) -> tuple[Mapping[str, object], ...]:
|
||||
from litellm.litellm_core_utils.prompt_templates.factory import parse_mime_type
|
||||
|
||||
mime: Final = (parse_mime_type(data_uri[:256]) or "").strip().lower()
|
||||
if not mime.startswith(_TPM_EMBEDDING_FILE_MIME_PREFIXES):
|
||||
return ()
|
||||
return ({"type": "file", "file": {"file_data": data_uri, "format": mime}},)
|
||||
|
||||
|
||||
_BASE64_PAYLOAD: Final = re.compile(r"[A-Za-z0-9+/]*={0,2}")
|
||||
|
||||
|
||||
def _is_base64_data_uri(value: str) -> bool:
|
||||
header_end: Final = value.find(",", 0, 256)
|
||||
if header_end < 0 or not value[:header_end].endswith(";base64"):
|
||||
return False
|
||||
payload_start: Final = header_end + 1
|
||||
if (len(value) - payload_start) % 4 or _BASE64_PAYLOAD.fullmatch(value, payload_start) is None:
|
||||
return False
|
||||
last_quantum: Final = value[max(payload_start, len(value) - 4) :]
|
||||
return base64.b64encode(base64.b64decode(last_quantum)).decode("ascii") == last_quantum
|
||||
|
||||
|
||||
def _tpm_embedding_file_block(part: Mapping[str, object]) -> _TPMMediaProjection:
|
||||
projection: Final = _tpm_file_media(part)
|
||||
fields: Final = as_str_mapping(part.get("file"))
|
||||
data: Final = fields.get("file_data") if fields is not None else None
|
||||
if projection.media or fields is None or not isinstance(data, str) or not _is_base64_data_uri(data):
|
||||
return projection
|
||||
media: Final = _tpm_embedding_file(data)
|
||||
return _TPMMediaProjection({**part, "file": _tpm_blank_fields(fields, ("file_data",))} if media else part, media)
|
||||
|
||||
|
||||
def _tpm_embedding_leaf(value: object) -> _TPMMediaProjection:
|
||||
part: Final = as_str_mapping(value)
|
||||
if part is not None and part.get("type") == "file":
|
||||
return _tpm_embedding_file_block(part)
|
||||
if not isinstance(value, str) or not value.startswith("data:"):
|
||||
return _tpm_media_leaf(value)
|
||||
if not _is_base64_data_uri(value):
|
||||
return _TPMMediaProjection(value)
|
||||
media: Final = _tpm_file_media({"file_data": value}).media or _tpm_embedding_file(value)
|
||||
return _TPMMediaProjection("" if media else value, media)
|
||||
|
||||
|
||||
def _is_json_array(value: object) -> bool:
|
||||
return isinstance(value, (list, tuple))
|
||||
|
||||
|
||||
def _tpm_embedding_item(value: object) -> _TPMMediaProjection:
|
||||
return _tpm_media_list(value, _tpm_embedding_leaf) if _is_json_array(value) else _tpm_embedding_leaf(value)
|
||||
|
||||
|
||||
def split_embedding_media_for_tpm(value: object) -> tuple[object, tuple[Mapping[str, object], ...]]:
|
||||
projection: Final = (
|
||||
_tpm_media_list(value, _tpm_embedding_item) if _is_json_array(value) else _tpm_embedding_leaf(value)
|
||||
)
|
||||
return projection.content, projection.media
|
||||
|
||||
|
||||
def embedding_input_is_all_media(value: object) -> bool:
|
||||
items: Final = cast(Sequence[object], value) if _is_json_array(value) else (value,) # cast-ok: JSON input array
|
||||
return all(_tpm_embedding_leaf(item).media for item in items)
|
||||
|
||||
|
||||
def _content_parts_contain_image(parts: Sequence[object]) -> bool:
|
||||
"""Depth-bounded frontier walk over nested content lists, iterative because the repo bans
|
||||
recursion; an Anthropic tool_result nests its image parts exactly one level down."""
|
||||
|
|
|
|||
|
|
@ -610,6 +610,127 @@ def estimate_image_tokens(
|
|||
return max(_image_policy_tokens(family, dimensions, detail) for family in families)
|
||||
|
||||
|
||||
_MEDIA_FILE_CHARS_PER_TOKEN: Final = 4
|
||||
_EmbeddingInputT: Final = TypeVar("_EmbeddingInputT")
|
||||
|
||||
|
||||
def _max_input_tokens(model: str) -> int | None:
|
||||
try:
|
||||
limit: Final = litellm.get_model_info(model).get("max_input_tokens")
|
||||
except Exception: # noqa: BLE001 # get_model_info raises a bare Exception for an unmapped model
|
||||
return None
|
||||
return limit if isinstance(limit, int) and limit > 0 else None
|
||||
|
||||
|
||||
def _media_file_tokens(data: str, models: Sequence[str]) -> int:
|
||||
text_estimate: Final = len(data) // _MEDIA_FILE_CHARS_PER_TOKEN
|
||||
ceilings: Final = tuple(map(_max_input_tokens, models))
|
||||
known_ceilings: Final = tuple(ceiling for ceiling in ceilings if ceiling is not None)
|
||||
if not known_ceilings or len(known_ceilings) != len(ceilings):
|
||||
return text_estimate
|
||||
return min(text_estimate, max(known_ceilings))
|
||||
|
||||
|
||||
def _block_str_field(block: Mapping[str, object], container: str, field: str) -> str | None:
|
||||
inner: Final = as_str_mapping(block.get(container))
|
||||
value: Final = inner.get(field) if inner is not None else None
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def estimate_media_block_tokens(block: Mapping[str, object], models: Sequence[str], fallback_model: str | None) -> int:
|
||||
from litellm.litellm_core_utils.audio_utils.utils import estimate_audio_tokens
|
||||
|
||||
match block.get("type"):
|
||||
case "input_audio":
|
||||
return estimate_audio_tokens(
|
||||
_block_str_field(block, "input_audio", "data"), models, fallback_model=fallback_model
|
||||
)
|
||||
case "file":
|
||||
return _media_file_tokens(_block_str_field(block, "file", "file_data") or "", models)
|
||||
case _:
|
||||
detail: Final = _block_str_field(block, "image_url", "detail")
|
||||
return estimate_image_tokens(
|
||||
models,
|
||||
_block_str_field(block, "image_url", "url") or "",
|
||||
"auto" if detail is None else detail,
|
||||
fallback_model=fallback_model,
|
||||
)
|
||||
|
||||
|
||||
_PER_ITEM_DATA_URI_EMBEDDING_PROVIDERS: Final = frozenset({"vertex_ai", "gemini", "azure_ai"})
|
||||
|
||||
_DataUriMediaScope: TypeAlias = Literal["per_item", "whole_batch"]
|
||||
|
||||
|
||||
def _is_pdf_block(block: Mapping[str, object]) -> bool:
|
||||
return (_block_str_field(block, "file", "file_data") or "").startswith("data:application/pdf")
|
||||
|
||||
|
||||
def _bedrock_data_uri_media_scope(
|
||||
resolved_model: str, media: Sequence[Mapping[str, object]]
|
||||
) -> _DataUriMediaScope | None:
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
from litellm.llms.bedrock.common_utils import BedrockModelInfo
|
||||
from litellm.utils import supports_embedding_image_input
|
||||
|
||||
base_model: Final = BedrockModelInfo.get_base_model(resolved_model.removeprefix("async_invoke/"))
|
||||
if not supports_embedding_image_input(f"bedrock/{base_model}"):
|
||||
return None
|
||||
match BaseAWSLLM.get_bedrock_embedding_provider(base_model):
|
||||
case "cohere":
|
||||
return "whole_batch"
|
||||
case "twelvelabs":
|
||||
return None
|
||||
case "nova" if any(map(_is_pdf_block, media)):
|
||||
return None
|
||||
case _:
|
||||
return "per_item"
|
||||
|
||||
|
||||
def _data_uri_media_scope(model: str, media: Sequence[Mapping[str, object]]) -> _DataUriMediaScope | None:
|
||||
from litellm.utils import supports_embedding_image_input
|
||||
|
||||
try:
|
||||
resolved_model, provider, _, _ = litellm.get_llm_provider(model=model)
|
||||
except litellm.exceptions.BadRequestError:
|
||||
return None
|
||||
if provider == "bedrock":
|
||||
return _bedrock_data_uri_media_scope(resolved_model, media)
|
||||
if not supports_embedding_image_input(model):
|
||||
return None
|
||||
if provider == "cohere":
|
||||
return "whole_batch"
|
||||
return "per_item" if provider in _PER_ITEM_DATA_URI_EMBEDDING_PROVIDERS else None
|
||||
|
||||
|
||||
def split_embedding_input_tokens(
|
||||
value: _EmbeddingInputT, media_models: Callable[[], Sequence[str]], fallback_model: str | None = None
|
||||
) -> tuple[_EmbeddingInputT, int]:
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
embedding_input_is_all_media,
|
||||
split_embedding_media_for_tpm,
|
||||
)
|
||||
|
||||
text, media = split_embedding_media_for_tpm(value)
|
||||
if not media:
|
||||
return value, 0
|
||||
models: Final = tuple(media_models())
|
||||
scopes: Final = frozenset(_data_uri_media_scope(model, media) for model in models)
|
||||
if not models or None in scopes or ("whole_batch" in scopes and not embedding_input_is_all_media(value)):
|
||||
return value, 0
|
||||
media_tokens: Final = sum(estimate_media_block_tokens(block, models, fallback_model) for block in media)
|
||||
return cast(_EmbeddingInputT, text), media_tokens # cast-ok: the projection keeps the input's container shape
|
||||
|
||||
|
||||
def embedding_aware_token_count(
|
||||
messages: Sequence[Mapping[str, str]] | None,
|
||||
text: str | list[str] | None, # mutable-ok: litellm.token_counter only takes text as list[str]
|
||||
media_models: Callable[[], Sequence[str]],
|
||||
) -> int:
|
||||
embedding_text, media_tokens = split_embedding_input_tokens(text, media_models)
|
||||
return litellm.token_counter(messages=messages, text=embedding_text) + media_tokens
|
||||
|
||||
|
||||
TokenCounterFunction = Callable[[str], int]
|
||||
"""
|
||||
Type for a function that counts tokens in a string.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import httpx
|
|||
|
||||
from litellm import COHERE_DEFAULT_EMBEDDING_INPUT_TYPE
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.custom_httpx.http_handler import header_value
|
||||
from litellm.types.llms.bedrock import (
|
||||
CohereEmbeddingRequest,
|
||||
CohereEmbeddingRequestWithModel,
|
||||
|
|
@ -23,6 +24,11 @@ class _SupportsEncode(Protocol):
|
|||
def encode(self, text: str, /) -> Sized: ...
|
||||
|
||||
|
||||
def _bedrock_billed_input_tokens(response: httpx.Response) -> int | None:
|
||||
count: Final = header_value(response.headers, "x-amzn-bedrock-input-token-count")
|
||||
return int(count) if count is not None and count.isdigit() else None
|
||||
|
||||
|
||||
class CohereEmbeddingConfig:
|
||||
"""
|
||||
Reference: https://docs.cohere.com/v2/reference/embed
|
||||
|
|
@ -76,32 +82,30 @@ class CohereEmbeddingConfig:
|
|||
) -> CohereEmbeddingRequestWithModel:
|
||||
return self._transform_request(model, input, inference_params)
|
||||
|
||||
def _calculate_usage(self, input: list[str], encoding: _SupportsEncode, meta: dict) -> Usage:
|
||||
input_tokens = 0
|
||||
|
||||
def _calculate_usage(
|
||||
self, input: list[str], encoding: _SupportsEncode, meta: dict, billed_input_tokens: int | None = None
|
||||
) -> Usage:
|
||||
text_tokens: Final[int | None] = meta.get("billed_units", {}).get("input_tokens")
|
||||
|
||||
image_tokens: Final[int | None] = meta.get("billed_units", {}).get("images")
|
||||
|
||||
prompt_tokens_details: PromptTokensDetailsWrapper | None = None
|
||||
if image_tokens is None and text_tokens is None:
|
||||
for text in input:
|
||||
input_tokens += len(encoding.encode(text))
|
||||
else:
|
||||
prompt_tokens_details = PromptTokensDetailsWrapper(
|
||||
image_tokens=image_tokens,
|
||||
text_tokens=text_tokens,
|
||||
counted_tokens: Final = (
|
||||
billed_input_tokens
|
||||
if billed_input_tokens is not None
|
||||
else sum(len(encoding.encode(text)) for text in input)
|
||||
)
|
||||
if image_tokens:
|
||||
input_tokens += image_tokens
|
||||
if text_tokens:
|
||||
input_tokens += text_tokens
|
||||
return Usage(prompt_tokens=counted_tokens, completion_tokens=0, total_tokens=counted_tokens)
|
||||
|
||||
input_tokens: Final = (image_tokens or 0) + (text_tokens or 0)
|
||||
return Usage(
|
||||
prompt_tokens=input_tokens,
|
||||
completion_tokens=0,
|
||||
total_tokens=input_tokens,
|
||||
prompt_tokens_details=prompt_tokens_details,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
image_tokens=image_tokens,
|
||||
text_tokens=text_tokens,
|
||||
),
|
||||
)
|
||||
|
||||
def _transform_response(
|
||||
|
|
@ -129,6 +133,7 @@ class CohereEmbeddingConfig:
|
|||
model=model,
|
||||
encoding=encoding,
|
||||
input=input,
|
||||
billed_input_tokens=_bedrock_billed_input_tokens(response) if "images" in data else None,
|
||||
)
|
||||
|
||||
def transform_response(
|
||||
|
|
@ -151,6 +156,7 @@ class CohereEmbeddingConfig:
|
|||
model: str,
|
||||
encoding: _SupportsEncode,
|
||||
input: list,
|
||||
billed_input_tokens: int | None = None,
|
||||
) -> EmbeddingResponse:
|
||||
"""
|
||||
Parse a Cohere embed response body into an OpenAI-style EmbeddingResponse.
|
||||
|
|
@ -195,7 +201,7 @@ class CohereEmbeddingConfig:
|
|||
setattr(
|
||||
model_response,
|
||||
"usage",
|
||||
self._calculate_usage(input, encoding, response_json.get("meta", {})),
|
||||
self._calculate_usage(input, encoding, response_json.get("meta", {}), billed_input_tokens),
|
||||
)
|
||||
|
||||
return model_response
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1887,6 +1887,13 @@ def _response_cost_header_value(hidden_params: Mapping[str, object]) -> float |
|
|||
def cost_headers(
|
||||
response_cost: float | str | None, litellm_logging_obj: LiteLLMLoggingObj | None
|
||||
) -> Mapping[str, str | None]:
|
||||
if (
|
||||
response_cost == 0
|
||||
and litellm_logging_obj is not None
|
||||
and litellm_logging_obj.prepared_cost is not None
|
||||
and not litellm_logging_obj.prepared_cost.price_known
|
||||
):
|
||||
return {}
|
||||
cost_breakdown: Final = _get_cost_breakdown_from_logging_obj(
|
||||
litellm_logging_obj=litellm_logging_obj, response_cost=response_cost
|
||||
)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class _ResolvedDeprecation:
|
|||
litellm_model: str | None
|
||||
litellm_provider: str | None
|
||||
copied_from_another_row: bool = False
|
||||
successor_model: str | None = None
|
||||
|
||||
|
||||
def _parse_deprecation_date(raw_value: object) -> date | None:
|
||||
|
|
@ -58,10 +59,11 @@ def _cost_map_lookup(model_key: str | None, catalog: Mapping[str, object]) -> _R
|
|||
entry: Final = litellm.model_cost.get(model_key)
|
||||
if not isinstance(entry, Mapping):
|
||||
return None
|
||||
parsed: Final = _parse_deprecation_date(entry.get("deprecation_date"))
|
||||
fields: Final[Mapping[str, object]] = entry
|
||||
parsed: Final = _parse_deprecation_date(fields.get("deprecation_date"))
|
||||
if parsed is None:
|
||||
return None
|
||||
provider: Final = entry.get("litellm_provider")
|
||||
provider: Final = fields.get("litellm_provider")
|
||||
return _ResolvedDeprecation(
|
||||
deprecation_date=parsed,
|
||||
litellm_model=model_key,
|
||||
|
|
@ -69,6 +71,7 @@ def _cost_map_lookup(model_key: str | None, catalog: Mapping[str, object]) -> _R
|
|||
copied_from_another_row=isinstance(entry, AliasedCostEntry)
|
||||
and "deprecation_date" not in entry.registered_fields
|
||||
and model_key not in catalog,
|
||||
successor_model=_successor_model(fields.get("successor_model")),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -127,15 +130,6 @@ def _resolve_deployment_deprecation(
|
|||
litellm_params: Final = _mapping_field(deployment, "litellm_params")
|
||||
model_key: Final = _as_model_key(litellm_params.get("model"))
|
||||
|
||||
override: Final = _parse_deprecation_date(model_info.get("deprecation_date"))
|
||||
if override is not None:
|
||||
override_provider: Final = model_info.get("litellm_provider")
|
||||
return _ResolvedDeprecation(
|
||||
deprecation_date=override,
|
||||
litellm_model=model_key,
|
||||
litellm_provider=override_provider if isinstance(override_provider, str) else None,
|
||||
)
|
||||
|
||||
provider: Final = _deployment_provider(litellm_params)
|
||||
base_model: Final = _as_model_key(model_info.get("base_model"))
|
||||
model_name: Final = model_key.split("/", 1)[1] if model_key is not None and "/" in model_key else model_key
|
||||
|
|
@ -150,7 +144,18 @@ def _resolve_deployment_deprecation(
|
|||
if pick is not None
|
||||
)
|
||||
own_rows: Final = tuple(pick for pick in picks if not pick.copied_from_another_row)
|
||||
return min(own_rows or picks, key=lambda pick: pick.deprecation_date, default=None)
|
||||
from_catalog: Final = min(own_rows or picks, key=lambda pick: pick.deprecation_date, default=None)
|
||||
|
||||
override: Final = _parse_deprecation_date(model_info.get("deprecation_date"))
|
||||
if override is None:
|
||||
return from_catalog
|
||||
override_provider: Final = model_info.get("litellm_provider")
|
||||
return _ResolvedDeprecation(
|
||||
deprecation_date=override,
|
||||
litellm_model=model_key,
|
||||
litellm_provider=override_provider if isinstance(override_provider, str) else None,
|
||||
successor_model=from_catalog.successor_model if from_catalog is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _classify(days_until: int, warn_within_days: int) -> DeprecationStatus:
|
||||
|
|
@ -182,7 +187,7 @@ def _build_info(
|
|||
today: date,
|
||||
warn_within_days: int,
|
||||
catalog: Mapping[str, object],
|
||||
) -> ModelDeprecationInfo | None:
|
||||
) -> _DeploymentInfo | None:
|
||||
model_name: Final = deployment.get("model_name")
|
||||
if not isinstance(model_name, str) or not model_name:
|
||||
return None
|
||||
|
|
@ -192,21 +197,40 @@ def _build_info(
|
|||
return None
|
||||
|
||||
days_until: Final = (resolved.deprecation_date - today).days
|
||||
return ModelDeprecationInfo(
|
||||
model_name=model_name,
|
||||
litellm_model=resolved.litellm_model,
|
||||
deprecation_date=resolved.deprecation_date,
|
||||
days_until_deprecation=days_until,
|
||||
status=_classify(days_until, warn_within_days),
|
||||
litellm_provider=resolved.litellm_provider,
|
||||
successor_model=_checked_successor(model_name, _mapping_field(deployment, "model_info"), llm_router),
|
||||
configured: Final = _checked_successor(model_name, _mapping_field(deployment, "model_info"), llm_router)
|
||||
return _DeploymentInfo(
|
||||
info=ModelDeprecationInfo(
|
||||
model_name=model_name,
|
||||
litellm_model=resolved.litellm_model,
|
||||
deprecation_date=resolved.deprecation_date,
|
||||
days_until_deprecation=days_until,
|
||||
status=_classify(days_until, warn_within_days),
|
||||
litellm_provider=resolved.litellm_provider,
|
||||
successor_model=configured or resolved.successor_model,
|
||||
),
|
||||
configured_successor=configured is not None,
|
||||
)
|
||||
|
||||
|
||||
def _merge_group(group: Sequence[ModelDeprecationInfo]) -> ModelDeprecationInfo:
|
||||
"""Keep the group's first deployment in config order, naming the first successor any of its deployments names"""
|
||||
first: Final = group[0]
|
||||
successors: Final = tuple(model.successor_model for model in group if model.successor_model is not None)
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _DeploymentInfo:
|
||||
info: ModelDeprecationInfo
|
||||
configured_successor: bool
|
||||
|
||||
|
||||
def _merge_group(group: Sequence[_DeploymentInfo]) -> ModelDeprecationInfo:
|
||||
"""Keep the group's first deployment in config order, naming the first successor any of its deployments names,
|
||||
a successor configured in model_info ahead of one the cost map supplies"""
|
||||
first: Final = group[0].info
|
||||
tiers: Final = tuple(
|
||||
tuple(
|
||||
deployment.info.successor_model
|
||||
for deployment in group
|
||||
if deployment.configured_successor is configured and deployment.info.successor_model is not None
|
||||
)
|
||||
for configured in (True, False)
|
||||
)
|
||||
successors: Final = next((tier for tier in tiers if tier), ())
|
||||
if not successors:
|
||||
return first
|
||||
if len(frozenset(successors)) > 1:
|
||||
|
|
@ -220,13 +244,13 @@ def _merge_group(group: Sequence[ModelDeprecationInfo]) -> ModelDeprecationInfo:
|
|||
|
||||
|
||||
def _dedupe(
|
||||
models: Sequence[ModelDeprecationInfo],
|
||||
models: Sequence[_DeploymentInfo],
|
||||
) -> tuple[ModelDeprecationInfo, ...]:
|
||||
"""Report a model group carrying the same date on several deployments once"""
|
||||
ordered: Final = sorted(models, key=lambda model: (model.model_name, model.deprecation_date))
|
||||
ordered: Final = sorted(models, key=lambda model: (model.info.model_name, model.info.deprecation_date))
|
||||
return tuple(
|
||||
_merge_group(tuple(group))
|
||||
for _, group in groupby(ordered, key=lambda model: (model.model_name, model.deprecation_date))
|
||||
for _, group in groupby(ordered, key=lambda model: (model.info.model_name, model.info.deprecation_date))
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ _CONNECTION_CAPACITY_PHRASES: Final = (
|
|||
_PRISMA_POOL_TIMEOUT_CODE: Final = "P2024"
|
||||
_LOCK_TIMEOUT_SQLSTATE: Final = "55P03"
|
||||
_LOCK_TIMEOUT_PHRASE: Final = "canceling statement due to lock timeout"
|
||||
_STATEMENT_TIMEOUT_SQLSTATE: Final = "57014"
|
||||
_STATEMENT_TIMEOUT_PHRASE: Final = "canceling statement due to statement timeout"
|
||||
|
||||
|
||||
def exception_chain(e: BaseException) -> Iterator[BaseException]:
|
||||
|
|
@ -302,6 +304,17 @@ class PrismaDBExceptionHandler:
|
|||
return True
|
||||
return _LOCK_TIMEOUT_PHRASE in str(e).lower()
|
||||
|
||||
@staticmethod
|
||||
def is_statement_timeout_error(e: Exception) -> bool:
|
||||
"""True iff Postgres cancelled a statement for exceeding ``statement_timeout`` (SQLSTATE 57014)."""
|
||||
import prisma
|
||||
|
||||
if not isinstance(e, _exception_types(prisma.errors.PrismaError)):
|
||||
return False
|
||||
if PrismaDBExceptionHandler.postgres_sqlstate(e) == _STATEMENT_TIMEOUT_SQLSTATE:
|
||||
return True
|
||||
return _STATEMENT_TIMEOUT_PHRASE in str(e).lower()
|
||||
|
||||
@staticmethod
|
||||
def postgres_sqlstate(e: Exception) -> str | None:
|
||||
"""The SQLSTATE Postgres attached to a failed statement, as prisma surfaces it, or None."""
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ from litellm.litellm_core_utils.add_retry_fallback_headers import (
|
|||
ensure_response_additional_headers,
|
||||
response_has_hidden_params,
|
||||
)
|
||||
from litellm.litellm_core_utils.audio_utils.utils import estimate_audio_tokens
|
||||
from litellm.litellm_core_utils.core_helpers import as_str_mapping
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
get_str_from_messages,
|
||||
|
|
@ -64,7 +63,11 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
|||
from litellm.litellm_core_utils.ptu_pricing import is_ptu_cost_attribution_enabled
|
||||
from litellm.litellm_core_utils.ptu_shares import PTUTeamCeiling
|
||||
from litellm.litellm_core_utils.routing_common_utils import resolve_model_group_alias
|
||||
from litellm.litellm_core_utils.token_counter import estimate_image_tokens, offload_token_count
|
||||
from litellm.litellm_core_utils.token_counter import (
|
||||
estimate_media_block_tokens,
|
||||
offload_token_count,
|
||||
split_embedding_input_tokens,
|
||||
)
|
||||
from litellm.llms.azure.ptu_capacity import normalized_tokens
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.auth.auth_utils import (
|
||||
|
|
@ -156,6 +159,12 @@ def _resolve_media_models(router: "Router | None", model: str, team_id: str | No
|
|||
return tuple(dict.fromkeys(router.registry().names(model, kind="litellm_base_models", team_id=team_id))) or (model,)
|
||||
|
||||
|
||||
def _media_models(
|
||||
resolver: Callable[[str, str | None], tuple[str, ...]] | None, model: str, team_id: str | None
|
||||
) -> tuple[str, ...]:
|
||||
return resolver(model, team_id) if resolver is not None else (model,)
|
||||
|
||||
|
||||
def _resolve_media_models_via_proxy_router(model: str, team_id: str | None) -> tuple[str, ...]:
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
|
|
@ -1378,20 +1387,28 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
|
|||
)
|
||||
|
||||
media_source: Final = messages if messages is not None else original_input
|
||||
is_embedding_call: Final = call_type in EMBEDDING_API_CALL_TYPES
|
||||
projected, media = (
|
||||
split_media_for_tpm(media_source)
|
||||
if include_media and (isinstance(messages, list) or call_type in RESPONSES_API_CALL_TYPES)
|
||||
else (original_input, ())
|
||||
)
|
||||
input_text: Final = projected if call_type in RESPONSES_API_CALL_TYPES else original_input
|
||||
fallback_model: Final = request_model if isinstance(request_model, str) else None
|
||||
resolved_models: Final = (
|
||||
media_model_resolver(fallback_model or "", team_id)
|
||||
if media and media_model_resolver is not None
|
||||
else (fallback_model or "",)
|
||||
_media_models(media_model_resolver, fallback_model or "", team_id) if media else (fallback_model or "",)
|
||||
)
|
||||
media_tokens: Final = sum(
|
||||
self._estimate_media_block_tokens(block, resolved_models, fallback_model) for block in media
|
||||
embedding_text, embedding_media_tokens = (
|
||||
split_embedding_input_tokens(
|
||||
original_input,
|
||||
lambda: _media_models(media_model_resolver, fallback_model or "", team_id),
|
||||
fallback_model,
|
||||
)
|
||||
if include_media and is_embedding_call
|
||||
else (original_input, 0)
|
||||
)
|
||||
input_text: Final = projected if call_type in RESPONSES_API_CALL_TYPES else embedding_text
|
||||
media_tokens: Final = embedding_media_tokens + sum(
|
||||
estimate_media_block_tokens(block, resolved_models, fallback_model) for block in media
|
||||
)
|
||||
|
||||
total_chars: Final = (
|
||||
|
|
@ -1436,24 +1453,6 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
|
|||
|
||||
return estimated_input_tokens, max_tokens_estimate * self.get_output_candidate_count(data, call_type)
|
||||
|
||||
@staticmethod
|
||||
def _estimate_media_block_tokens(
|
||||
block: Mapping[str, object], models: tuple[str, ...], fallback_model: str | None
|
||||
) -> int:
|
||||
if block.get("type") == "input_audio":
|
||||
audio: Final = _callback_kwargs_mapping(block.get("input_audio"))
|
||||
data: Final = audio.get("data")
|
||||
return estimate_audio_tokens(data if isinstance(data, str) else None, models, fallback_model=fallback_model)
|
||||
image: Final = _callback_kwargs_mapping(block.get("image_url"))
|
||||
url: Final = image.get("url")
|
||||
detail: Final = image.get("detail")
|
||||
return estimate_image_tokens(
|
||||
models,
|
||||
url if isinstance(url, str) else "",
|
||||
detail if isinstance(detail, str) else "auto",
|
||||
fallback_model=fallback_model,
|
||||
)
|
||||
|
||||
def _is_redis_cluster(self) -> bool:
|
||||
"""
|
||||
Check if the dual cache is using Redis cluster.
|
||||
|
|
@ -4098,12 +4097,20 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
|
|||
None if is_responses_request else self._translate_google_genai_native_request(data, call_type)
|
||||
)
|
||||
is_embedding_request: Final = self._is_embedding_request(data, call_type)
|
||||
embedding_text: Final = data.get("input") if is_embedding_request else None
|
||||
request: Final = as_str_mapping(data)
|
||||
raw_embedding_input: Final = request.get("input") if is_embedding_request and request is not None else None
|
||||
pretokenized_input_tokens: Final = (
|
||||
self._count_pretokenized_embedding_input(embedding_text) if is_embedding_request else None
|
||||
self._count_pretokenized_embedding_input(raw_embedding_input) if is_embedding_request else None
|
||||
)
|
||||
if pretokenized_input_tokens is not None:
|
||||
return pretokenized_input_tokens
|
||||
embedding_text, embedding_media_tokens = (
|
||||
split_embedding_input_tokens(
|
||||
raw_embedding_input, lambda: self._media_model_resolver(model or "", team_id), model
|
||||
)
|
||||
if is_embedding_request
|
||||
else (None, 0)
|
||||
)
|
||||
|
||||
prompt: Final = data.get("prompt")
|
||||
fallback_text: Final = prompt if prompt is not None else data.get("input")
|
||||
|
|
@ -4153,12 +4160,16 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
|
|||
for tokenizer_model in (self._counted_models_resolver(model, team_id) if model else ("",))
|
||||
),
|
||||
)
|
||||
return estimate + audio_token_estimate
|
||||
return estimate + audio_token_estimate + embedding_media_tokens
|
||||
except Exception: # noqa: BLE001 # tokenizer failures degrade to the cheap estimate
|
||||
if call_type in RERANK_API_CALL_TYPES and isinstance(selected_text, str):
|
||||
return max(0, len(selected_text) // DEFAULT_CHARS_PER_TOKEN)
|
||||
estimated_input_tokens, _ = self._estimate_input_and_output_tokens(
|
||||
data=data, call_type=call_type, include_media=False
|
||||
data=data,
|
||||
call_type=call_type,
|
||||
include_media=is_embedding_request,
|
||||
team_id=team_id,
|
||||
media_model_resolver=self._media_model_resolver,
|
||||
)
|
||||
return estimated_input_tokens + audio_token_estimate
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ from litellm.proxy.auth.password_policy import (
|
|||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast
|
||||
from litellm.proxy.common_utils.openai_error_payload import proxy_exception_for
|
||||
from litellm.proxy.common_utils.openai_error_payload import openai_shaped_proxy_exception, proxy_exception_for
|
||||
from litellm.proxy.common_utils.user_api_key_cache import (
|
||||
AUTH_OBJECTS_TARGET,
|
||||
object_permission_cache_key,
|
||||
|
|
@ -1048,6 +1048,7 @@ async def _check_user_info_v2_access(
|
|||
1. Proxy admins / proxy admin viewers can access any user
|
||||
2. User can access their own info
|
||||
3. Team admins can access info of users in their teams
|
||||
4. Org admins can access info of users in their organizations
|
||||
|
||||
Raises on unexpected DB errors so they surface as 500s, not silent 404s.
|
||||
"""
|
||||
|
|
@ -1090,9 +1091,33 @@ async def _check_user_info_v2_access(
|
|||
if team.team_id in (target_user.teams or []):
|
||||
return target_user
|
||||
|
||||
if user_api_key_dict.user_id is not None and await _target_in_caller_admin_org(
|
||||
prisma_client=prisma_client,
|
||||
caller_user_id=user_api_key_dict.user_id,
|
||||
target_user_id=target_user_id,
|
||||
):
|
||||
return await _fetch_target_user()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _target_in_caller_admin_org(
|
||||
prisma_client: "PrismaClient",
|
||||
caller_user_id: str,
|
||||
target_user_id: str,
|
||||
) -> bool:
|
||||
admin_memberships: Final = await prisma_client.db.litellm_organizationmembership.find_many(
|
||||
where={"user_id": caller_user_id, "user_role": LitellmUserRoles.ORG_ADMIN.value}
|
||||
)
|
||||
if not admin_memberships:
|
||||
return False
|
||||
admin_org_ids: Final = {membership.organization_id for membership in admin_memberships}
|
||||
target_memberships: Final = await prisma_client.db.litellm_organizationmembership.find_many(
|
||||
where={"user_id": target_user_id}
|
||||
)
|
||||
return any(membership.organization_id in admin_org_ids for membership in target_memberships)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/v2/user/info",
|
||||
tags=["Internal User management"],
|
||||
|
|
@ -1197,6 +1222,8 @@ async def user_info_v2(
|
|||
),
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, HTTPException) and e.status_code < 500:
|
||||
raise openai_shaped_proxy_exception(e, str(e.detail), e.status_code)
|
||||
verbose_proxy_logger.exception("litellm.proxy.proxy_server.user_info_v2(): Exception occured - %s", e)
|
||||
raise handle_exception_on_proxy(e)
|
||||
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ from litellm.proxy.common_utils.user_api_key_cache import (
|
|||
team_membership_reservation_cache_key,
|
||||
)
|
||||
from litellm.proxy.db.db_span import db_span
|
||||
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
|
||||
from litellm.proxy.hooks.key_management_event_hooks import KeyManagementEventHooks
|
||||
from litellm.proxy.hooks.model_max_budget_limiter import (
|
||||
RoutedModels,
|
||||
|
|
@ -7474,6 +7475,19 @@ class _TeamUserSpendDbRow(TypedDict):
|
|||
failed_requests: ReadOnly[int]
|
||||
|
||||
|
||||
_TEAM_USER_SPEND_STATEMENT_TIMEOUT_SECONDS: Final = 30
|
||||
|
||||
|
||||
async def _team_user_spend_rows(
|
||||
prisma_client: PrismaClient, sql: str, params: tuple[str, ...]
|
||||
) -> Sequence[_TeamUserSpendDbRow]:
|
||||
async with prisma_client.read_db.tx(
|
||||
timeout=timedelta(seconds=_TEAM_USER_SPEND_STATEMENT_TIMEOUT_SECONDS + 5)
|
||||
) as tx:
|
||||
await tx.execute_raw(f"SET LOCAL statement_timeout = {_TEAM_USER_SPEND_STATEMENT_TIMEOUT_SECONDS * 1000}")
|
||||
return await tx.query_raw(sql, *params)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/team/spend/by_user",
|
||||
response_model=TeamUserSpendResponse,
|
||||
|
|
@ -7523,14 +7537,23 @@ async def get_team_spend_by_user(
|
|||
|
||||
own_user_only: Final = scope.api_key_filter is not None
|
||||
user_param: Final = (user_api_key_dict.user_id or "",) if own_user_only else ()
|
||||
async with db_span("team_user_spend", "LiteLLM_SpendLogs"):
|
||||
rows: Final[Sequence[_TeamUserSpendDbRow]] = await prisma_client.db.query_raw(
|
||||
_team_user_spend_sql(team_count=len(scoped_team_ids), restrict_to_user=own_user_only),
|
||||
start_date,
|
||||
end_date,
|
||||
*scoped_team_ids,
|
||||
*user_param,
|
||||
)
|
||||
try:
|
||||
async with db_span("team_user_spend", "LiteLLM_SpendLogs"):
|
||||
rows: Final = await _team_user_spend_rows(
|
||||
prisma_client,
|
||||
_team_user_spend_sql(team_count=len(scoped_team_ids), restrict_to_user=own_user_only),
|
||||
(start_date, end_date, *scoped_team_ids, *user_param),
|
||||
)
|
||||
except Exception as e:
|
||||
if PrismaDBExceptionHandler.is_statement_timeout_error(e):
|
||||
raise _daily_activity_error(
|
||||
status_code=503,
|
||||
message=(
|
||||
f"Spend per user took longer than {_TEAM_USER_SPEND_STATEMENT_TIMEOUT_SECONDS}s. "
|
||||
"Pick a shorter date range or fewer teams"
|
||||
),
|
||||
) from e
|
||||
raise
|
||||
results: Final = tuple(
|
||||
TeamUserSpendRow(
|
||||
team_id=row["team_id"],
|
||||
|
|
|
|||
|
|
@ -18611,8 +18611,9 @@ async def model_deprecations(
|
|||
|
||||
Reads `deprecation_date` metadata from `model_prices_and_context_window.json`
|
||||
(and any per-deployment `model_info.deprecation_date` overrides) for the
|
||||
models configured on this proxy. An entry also names its `successor_model`
|
||||
when the deployment sets `model_info.successor_model`.
|
||||
models configured on this proxy. An entry also names its `successor_model`:
|
||||
`model_info.successor_model` when the deployment sets one, else the
|
||||
replacement the cost map records from the provider's deprecation notice.
|
||||
|
||||
Parameters:
|
||||
warn_within_days: Window (in days) used to bucket "imminent" models,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from collections import Counter
|
|||
from collections.abc import Mapping, MutableMapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Annotated,
|
||||
Final,
|
||||
Literal,
|
||||
|
|
@ -16,7 +17,6 @@ from typing import (
|
|||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile
|
||||
from prisma.errors import PrismaError
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError, create_model, field_validator
|
||||
from pydantic.fields import FieldInfo, PydanticUndefined
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict
|
||||
|
|
@ -65,6 +65,9 @@ from litellm.types.proxy.management_endpoints.ui_sso import (
|
|||
SSOConfig,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prisma.errors import PrismaError
|
||||
|
||||
router: Final = APIRouter()
|
||||
|
||||
_DECODED_JSON: Final = TypeAdapter(object)
|
||||
|
|
@ -1291,7 +1294,9 @@ def _parse_environment_variables_param(param_value: str | Mapping[str, object] |
|
|||
|
||||
async def _load_environment_variables_param(
|
||||
repo: ConfigRepository,
|
||||
) -> Mapping[str, object] | None | PrismaError | ValueError:
|
||||
) -> "Mapping[str, object] | None | PrismaError | ValueError":
|
||||
from prisma.errors import PrismaError
|
||||
|
||||
try:
|
||||
entry: Final = await _config_param_db(repo).find_unique(where={"param_name": "environment_variables"})
|
||||
param_value: Final = None if entry is None else _parse_environment_variables_param(entry.param_value)
|
||||
|
|
@ -1345,6 +1350,8 @@ async def update_sso_settings(
|
|||
detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."},
|
||||
)
|
||||
|
||||
from prisma.errors import PrismaError
|
||||
|
||||
# Read the existing SSO row first so the audit log captures a real
|
||||
# before/after diff. Stored values are encrypted; decrypt them so the
|
||||
# before-snapshot has the same shape as after_value, and rely on
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ from litellm.litellm_core_utils.core_helpers import (
|
|||
from litellm.litellm_core_utils.coroutine_checker import coroutine_checker
|
||||
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor, reject_api_key_secret_reference
|
||||
from litellm.litellm_core_utils.dd_tracing import tracer
|
||||
from litellm.litellm_core_utils.deployment_pricing import deployment_pricing_models
|
||||
from litellm.litellm_core_utils.deployment_pricing import deployment_backend_models, deployment_pricing_models
|
||||
from litellm.litellm_core_utils.get_litellm_params import SUPPLEMENTAL_KWARGS_KEYS
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import (
|
||||
declared_authenticating_provider,
|
||||
|
|
@ -14866,6 +14866,7 @@ class PythonCore(DeprecatedRouterMembers):
|
|||
input: str | list[object] | None,
|
||||
request_kwargs: Mapping[str, object] | None = None,
|
||||
tokenizer_model: str = "",
|
||||
deployments: Sequence[Mapping[str, object]] = (),
|
||||
) -> int:
|
||||
"""
|
||||
Count input tokens for context-window pre-call checks.
|
||||
|
|
@ -14900,11 +14901,15 @@ class PythonCore(DeprecatedRouterMembers):
|
|||
if input is not None:
|
||||
from openai.types.responses.response_create_params import ResponseInputParam
|
||||
|
||||
from litellm.litellm_core_utils.token_counter import split_embedding_input_tokens
|
||||
from litellm.responses.litellm_completion_transformation.transformation import (
|
||||
LiteLLMCompletionResponsesConfig,
|
||||
)
|
||||
|
||||
typed_input: Final = cast(str | ResponseInputParam, input) # cast-ok: str | list matches transform input
|
||||
text_input, _ = split_embedding_input_tokens(input, partial(deployment_backend_models, deployments))
|
||||
typed_input: Final = cast( # cast-ok: str | list matches transform input
|
||||
str | ResponseInputParam, text_input
|
||||
)
|
||||
input_messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
|
||||
input=typed_input,
|
||||
responses_api_request={"instructions": instructions} if instructions is not None else {},
|
||||
|
|
@ -14972,6 +14977,7 @@ class PythonCore(DeprecatedRouterMembers):
|
|||
self.served_openai_models(model) or served_openai_models(healthy_deployments)
|
||||
)
|
||||
or "",
|
||||
deployments=healthy_deployments,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # best-effort: an uncountable prompt must not fail the request
|
||||
verbose_router_logger.error(
|
||||
|
|
@ -15002,6 +15008,7 @@ class PythonCore(DeprecatedRouterMembers):
|
|||
self.served_openai_models(model) or served_openai_models(deployments)
|
||||
)
|
||||
or "",
|
||||
deployments=deployments,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_router_logger.error(
|
||||
|
|
|
|||
|
|
@ -2,16 +2,22 @@
|
|||
# picks based on response time (for streaming, this is time to first token)
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from typing import Final
|
||||
|
||||
import litellm
|
||||
from litellm import ModelResponse, token_counter, verbose_logger
|
||||
from litellm import ModelResponse, verbose_logger
|
||||
from litellm._internal_context import with_service_target
|
||||
from litellm._logging import verbose_router_logger
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.batch_utils import is_batch_retrieve_call_type
|
||||
from litellm.litellm_core_utils.deployment_pricing import deployment_pricing_models, str_keyed
|
||||
from litellm.litellm_core_utils.deployment_pricing import (
|
||||
deployment_backend_models,
|
||||
deployment_pricing_models,
|
||||
str_keyed,
|
||||
)
|
||||
from litellm.litellm_core_utils.token_counter import embedding_aware_token_count
|
||||
|
||||
|
||||
def _deployment_cost_map_rates(deployment: object) -> tuple[float | None, float | None]:
|
||||
|
|
@ -235,7 +241,9 @@ class LowestCostLoggingHandler(CustomLogger):
|
|||
}
|
||||
|
||||
try:
|
||||
input_tokens = token_counter(messages=messages, text=input)
|
||||
input_tokens = embedding_aware_token_count(
|
||||
messages, input, partial(deployment_backend_models, healthy_deployments)
|
||||
)
|
||||
except Exception:
|
||||
input_tokens = 0
|
||||
|
||||
|
|
|
|||
|
|
@ -3,18 +3,21 @@
|
|||
import random
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timedelta
|
||||
from functools import partial
|
||||
from math import ceil, isfinite
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
import litellm
|
||||
from litellm import ModelResponse, token_counter, verbose_logger
|
||||
from litellm import ModelResponse, verbose_logger
|
||||
from litellm._internal_context import with_service_target
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.batch_utils import is_batch_retrieve_call_type
|
||||
from litellm.litellm_core_utils.core_helpers import get_parent_otel_span_from_kwargs, safe_divide_seconds
|
||||
from litellm.litellm_core_utils.deployment_pricing import deployment_backend_models
|
||||
from litellm.litellm_core_utils.token_counter import embedding_aware_token_count
|
||||
from litellm.responses.utils import ResponseAPILoggingUtils
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
from litellm.types.utils import LiteLLMPydanticObjectBase
|
||||
|
|
@ -439,7 +442,9 @@ class LowestLatencyLoggingHandler(CustomLogger):
|
|||
}
|
||||
|
||||
try:
|
||||
input_tokens = token_counter(messages=messages, text=input)
|
||||
input_tokens = embedding_aware_token_count(
|
||||
messages, input, partial(deployment_backend_models, healthy_deployments)
|
||||
)
|
||||
except Exception:
|
||||
input_tokens = 0
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@
|
|||
# identifies lowest tpm deployment
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from typing import Final
|
||||
|
||||
from litellm import token_counter
|
||||
from litellm._internal_context import with_service_target
|
||||
from litellm._logging import verbose_router_logger
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.batch_utils import is_batch_retrieve_call_type
|
||||
from litellm.litellm_core_utils.deployment_pricing import deployment_backend_models
|
||||
from litellm.litellm_core_utils.token_counter import embedding_aware_token_count
|
||||
from litellm.types.utils import LiteLLMPydanticObjectBase
|
||||
from litellm.utils import print_verbose
|
||||
|
||||
|
|
@ -174,7 +176,9 @@ class LowestTPMLoggingHandler(CustomLogger):
|
|||
|
||||
verbose_router_logger.debug("tpm_key=%s, tpm_dict: %s, rpm_dict: %s", tpm_key, tpm_dict, rpm_dict)
|
||||
try:
|
||||
input_tokens = token_counter(messages=messages, text=input)
|
||||
input_tokens = embedding_aware_token_count(
|
||||
messages, input, partial(deployment_backend_models, healthy_deployments)
|
||||
)
|
||||
except Exception:
|
||||
input_tokens = 0
|
||||
verbose_router_logger.debug("input_tokens=%s", input_tokens)
|
||||
|
|
|
|||
|
|
@ -5,18 +5,20 @@ from collections.abc import Iterator, Mapping, Sequence
|
|||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import httpx
|
||||
|
||||
import litellm
|
||||
from litellm import token_counter
|
||||
from litellm._internal_context import with_service_target
|
||||
from litellm._logging import verbose_logger, verbose_router_logger
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.batch_utils import is_batch_retrieve_call_type
|
||||
from litellm.litellm_core_utils.core_helpers import get_parent_otel_span_from_kwargs
|
||||
from litellm.litellm_core_utils.deployment_pricing import deployment_backend_models
|
||||
from litellm.litellm_core_utils.token_counter import embedding_aware_token_count
|
||||
from litellm.types.router import RouterErrors
|
||||
from litellm.types.utils import LiteLLMPydanticObjectBase, StandardLoggingPayload
|
||||
from litellm.utils import get_utc_datetime, print_verbose
|
||||
|
|
@ -422,7 +424,9 @@ class LowestTPMLoggingHandler_v2(BaseRoutingStrategy, CustomLogger):
|
|||
rpm_dict[rpm_keys[idx].split(":")[0]] = rpm_values[idx]
|
||||
|
||||
try:
|
||||
input_tokens = token_counter(messages=messages, text=input)
|
||||
input_tokens = embedding_aware_token_count(
|
||||
messages, input, partial(deployment_backend_models, healthy_deployments)
|
||||
)
|
||||
except Exception:
|
||||
input_tokens = 0
|
||||
verbose_router_logger.debug("input_tokens=%s", input_tokens)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ class ModelDeprecationInfo(LiteLLMBaseModel):
|
|||
litellm_provider: str | None = Field(default=None, description="The provider this model belongs to.")
|
||||
successor_model: str | None = Field(
|
||||
default=None,
|
||||
description=("The model to migrate to, as set in model_info.successor_model on the deployment."),
|
||||
description=(
|
||||
"The model to migrate to: model_info.successor_model on the deployment when set, "
|
||||
"else the successor_model the cost map names for the deprecated model."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,7 @@ IMPORTED_MODULES: Final[tuple[str, ...]] = (
|
|||
"litellm.proxy.mcp_server.server",
|
||||
"litellm.proxy.mcp_server.mcp_server_manager",
|
||||
"litellm.proxy.mcp_server.rest_endpoints",
|
||||
"litellm.proxy.proxy_server",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -377,7 +377,11 @@ def test_stream_completion_controls_publication(store: RedisResponseStore, provi
|
|||
assert len(provider.hits) == (2 if truncated else 1)
|
||||
|
||||
|
||||
def test_store_outage_preserves_provider_success(provider: Provider) -> None:
|
||||
@pytest.mark.parametrize("cluster_mode", ("0", "1"))
|
||||
def test_store_outage_preserves_provider_success(
|
||||
provider: Provider, monkeypatch: pytest.MonkeyPatch, cluster_mode: str,
|
||||
) -> None:
|
||||
monkeypatch.setenv("E2E_PROVIDER_CACHE_REDIS_CLUSTER", cluster_mode)
|
||||
with socket.socket() as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
port: Final = probe.getsockname()[1]
|
||||
|
|
@ -401,6 +405,20 @@ def test_old_lease_cannot_overwrite_new_owner(store: RedisResponseStore) -> None
|
|||
assert isinstance(hit, CacheHit) and hit.payload == b"new"
|
||||
|
||||
|
||||
def test_separate_namespaces_keep_their_own_response(store: RedisResponseStore) -> None:
|
||||
other: Final = replace(store, namespace=store.namespace + "-other")
|
||||
first_lease: Final = store.lookup("shared-key")
|
||||
assert isinstance(first_lease, CaptureLease)
|
||||
assert store.publish("shared-key", first_lease, b"first namespace")
|
||||
other_lease: Final = other.lookup("shared-key")
|
||||
assert isinstance(other_lease, CaptureLease)
|
||||
assert other.publish("shared-key", other_lease, b"other namespace")
|
||||
first_hit: Final = store.lookup("shared-key")
|
||||
other_hit: Final = other.lookup("shared-key")
|
||||
assert isinstance(first_hit, CacheHit) and first_hit.payload == b"first namespace"
|
||||
assert isinstance(other_hit, CacheHit) and other_hit.payload == b"other namespace"
|
||||
|
||||
|
||||
def test_identity_preserves_values_and_never_contains_credentials() -> None:
|
||||
variants: Final = (b'{}', b'{"a":null}', b'{"a":false}', b'{"a":0}', b'{"a":0.0}', b'{"a":"0"}', b' { }', None, b'')
|
||||
url: Final = "https://example.invalid/v1/chat/completions"
|
||||
|
|
|
|||
|
|
@ -32,10 +32,10 @@ Each subdirectory under `tests/e2e/` is one suite, scoped to an endpoint family
|
|||
|
||||
- `migrations/` - isolated Docker startup, concurrent migration, crash recovery, and legacy database compatibility. The CircleCI migration workflow enables `LITELLM_MIGRATION_TESTS=1`. These tests own their proxy containers and databases, so they do not use the shared proxy preflight or shared database cleanup
|
||||
|
||||
- `llm_translation/` - LLM endpoint and provider-translation behavior: passthrough, custom pricing, OCR, and the non-chat inference endpoints (`/v1/responses`, `/v1/messages`, `/embeddings`, `/v1/rerank`, `/v1/audio/speech`, `/v1/images/generations`), each against a deployment the test creates via `/model/new` and deletes on teardown
|
||||
- `llm_translation/` - Azure realtime translation is explicitly skipped pending investigation of intermittent missing transcripts and remains unqualified. OpenAI translation and other Azure realtime cases remain enabled. LLM endpoint and provider-translation behavior: passthrough, custom pricing, OCR, and the non-chat inference endpoints (`/v1/responses`, `/v1/messages`, `/embeddings`, `/v1/rerank`, `/v1/audio/speech`, `/v1/images/generations`), each against a deployment the test creates via `/model/new` and deletes on teardown
|
||||
- `access_control/` - the gateway's authorization and error-shape contract: per-key model allow-lists, route-group permissions (`allowed_routes`), and unknown-model validation
|
||||
- `embeddings/` - the `/embeddings` endpoint across providers
|
||||
- `batches/` - the `/batches` endpoint (placeholder until the first test lands)
|
||||
- `batches/` - the `/batches` endpoint. GovCloud batch coverage is explicitly skipped until its infrastructure is provisioned. Other missing credentials and provider errors remain failures
|
||||
- `realtime/` - realtime websocket sessions, including the pipecat audio path
|
||||
- `quota_management/` - quota enforcement and accounting, one subfolder per behavior: `ratelimit/` (rpm/tpm blocks, window reset, pacing headers on live traffic), `budgets/` (budget definition, enforcement, and reset windows: key, team, tag, soft, multi-window), and `spend_tracking/` (spend logging and cost attribution on `/spend/*`)
|
||||
- `management/` - key/team/user/organization management routes: create/update/delete persistence via the info routes, team membership, and llm-only-key route denials (API surface, not Playwright)
|
||||
|
|
@ -50,7 +50,7 @@ Each subdirectory under `tests/e2e/` is one suite, scoped to an endpoint family
|
|||
- `gateway/` - proxy configuration only (`litellm-config.yml`). No tests
|
||||
- `claude_code/` - the Claude Code compatibility matrix: drives the real `claude` CLI (and HTTP probes) against a proxy for each feature x provider cell, reporting tagged-union outcomes via the `compat_result` fixture. Ships its own driver/builder/publisher, covered by `tests/e2e_harness/claude_code/`. The HTTP probes ride the shared transport (`ProxyClient.count_tokens` / `ProxyClient.messages`). The CLI-driving path stays bespoke. `pr_gate_version_resolver.py` only forwards to `cli_harness/npm_version_resolver.py`, because the Buildkite runner images still run that path and install @latest without it
|
||||
- `cli_harness/` - no tests, only the plumbing every suite that drives a real coding-agent CLI shares: `npm_version_resolver.py` picks the CLI release to install (the newest npm release at least three days old, run by CI as `python tests/e2e/cli_harness/npm_version_resolver.py <npm package>`, stdlib only), `lockdown.py` gives the CLI subprocess an allowlisted env and a fresh empty HOME so it never sees the runner's credentials or dotfiles, and `rate_limiter.py` is the cross-process token bucket per upstream that keeps parallel CLI runs under one request rate. Covered by `tests/e2e_harness/cli_harness/`. Code only one CLI suite needs stays in that suite
|
||||
- `codex/` - the Codex compatibility suite: drives the real `codex exec --json` CLI through the gateway, one class per row (the command round trip, a file edit with the patch tool, a long session that follows a chain of files one command at a time, and auto-compaction, where Codex reads a random value, compacts through the gateway and is asked for the value after the file is deleted), each row parametrized over `SETUPS` in `setups.py`, one `pytest.param` per setup (GPT on Bedrock Mantle, Bedrock Runtime, Azure OpenAI and OpenAI, plus Claude on Bedrock). A row with one setup broken by a known bug parametrizes over `with_expected_failure(<setup id>, reason=...)`, which is `SETUPS` with a strict xfail on that one setup. The `setup` fixture registers that setup's deployment under a fresh name via `/model/new` and the `codex` fixture makes a key scoped to it, and both are deleted on teardown. `harness.py` gives each test a throwaway HOME and CODEX_HOME whose `config.toml` points a custom `responses` provider at the gateway and has Codex read its model list from the gateway's `/v1/models` (`model_catalog_url` on the provider), with the sandbox on (`workspace-write`) and approvals off, and runs the CLI under `cli_harness`'s env lockdown and rate limiter. `run.py` reads the JSONL events into a `CodexRun`: the commands with their output, the patched files, the last agent message as `answer`, the usage and the errors, where an error's `status_code` is what lets the suite's 5xx rerun rule catch a turn Codex gave up on. `codex exec --json` prints nothing when Codex compacts, so `rollout.py` reads the compactions from the thread's rollout file under CODEX_HOME/sessions, and a compaction is remote when its replacement history kept the provider's `compaction` item. `exec` and `resume` take `compact_after_tokens`, which forces Codex's remote compaction (`compaction_trigger` items over `/v1/responses`, the path Codex otherwise only takes for OpenAI and Azure) on the gateway provider and compacts once the context grows past that many tokens. A test passes on a random value the model can only learn through the gateway, never on the model's wording or the exact command it typed. It needs `codex` on PATH and fails without it. Covered by `tests/e2e_harness/codex/`
|
||||
- `codex/` - the Codex compatibility suite: drives the real `codex exec --json` CLI through the gateway, one class per row (the command round trip, a file edit with the patch tool, a long session that follows a chain of files one command at a time, and auto-compaction, where Codex reads a random value, compacts through the gateway and is asked for the value after the file is deleted), each row parametrized over `SETUPS` in `setups.py`, one `pytest.param` per setup (GPT on Bedrock Mantle, Bedrock Runtime, Azure OpenAI and OpenAI, plus Claude on Bedrock). A row with one setup broken by a known bug parametrizes over `with_expected_failure(<setup id>, reason=...)`, which is `SETUPS` with a strict xfail on that one setup. The `setup` fixture registers that setup's deployment under a fresh name via `/model/new` and the `codex` fixture makes a key scoped to it, and both are deleted on teardown. `harness.py` gives each test a throwaway HOME and CODEX_HOME whose `config.toml` points a custom `responses` provider at the gateway and has Codex read its model list from the gateway's `/v1/models` (`model_catalog_url` on the provider), with the sandbox on (`workspace-write`) and approvals off, and runs the CLI under `cli_harness`'s env lockdown and rate limiter. `run.py` reads the JSONL events into a `CodexRun`: the commands with their output, the patched files, the last agent message as `answer`, the usage and the errors, where an error's `status_code` is what lets the suite's 5xx rerun rule catch a turn Codex gave up on. `codex exec --json` prints nothing when Codex compacts, so `rollout.py` reads the compactions from the thread's rollout file under CODEX_HOME/sessions, and a compaction is remote when its replacement history kept the provider's `compaction` item. `exec` and `resume` take `compact_after_tokens`, which forces Codex's remote compaction (`compaction_trigger` items over `/v1/responses`, the path Codex otherwise only takes for OpenAI and Azure) on the gateway provider and compacts once the context grows past that many tokens. A test passes on a random value the model can only learn through the gateway, never on the model's wording or the exact command it typed. It needs `codex` on PATH and fails without it. The Bedrock Runtime setup is explicitly skipped until stage IAM permits `bedrock:InvokeModel` on its default project. The other setups still run, and skipped Runtime cases do not qualify that route. Covered by `tests/e2e_harness/codex/`
|
||||
- `ui/` - the Admin UI browser suite: Playwright in TypeScript, driving the dashboard served by a live proxy on port 4000 (seeded postgres + mock LLM upstream, see its `run_e2e.sh`). It is a self-contained npm package with its own lockfile and does not use the Python harness, pytest markers, or the shared transport. The Python rules in this file (typed models, `Result` unions, basedpyright zero-error gate) do not apply inside it. Its only Python file, `fixtures/mock_llm_server/server.py`, is excluded from the e2e basedpyright gate via the root `pyrightconfig.json`. Its specs follow the dashboard's rule against testing what shadcn and Base UI already guarantee, in `ui/litellm-dashboard/AGENTS.md`
|
||||
|
||||
## MCP suite: real Datadog only
|
||||
|
|
@ -329,3 +329,7 @@ other.<area>.<case>.<assertion>
|
|||
- the `codex/` suite is the one exception: a single setup that a LiteLLM or provider bug breaks may carry `pytest.mark.xfail(strict=True, reason=...)` with a plain one-sentence reason on that setup's `pytest.param`, so it keeps running and goes red once the bug is fixed. Strict only, and never on a flaky setup (one that passes some runs and fails others)
|
||||
|
||||
- a cell addresses the stack through its front door (`PROXY_BASE_URL`) the way a customer does, never a gateway pod by address, and proves a cross-replica property with N independent calls through that door, naming the miss odds (2^-N at two pods) in its docstring. `PROXY_REPLICA_URLS` is for read-backs that are per pod by nature (polling until a management write has converged on every replica, `/metrics`, RSS), never for steering the scenario a cell asserts on at a chosen pod
|
||||
|
||||
## Provider response cache
|
||||
|
||||
The provider response cache can share the stage Valkey instance through a `rediss://` URL. Set `E2E_PROVIDER_CACHE_REDIS_CLUSTER=1` for a cluster-aware client, including ElastiCache Serverless. Keep the dedicated HMAC key and namespace, and restrict test cleanup to keys in the test's own namespace
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ cost write-back via a cross-run marker baton (design below).
|
|||
|
||||
Only supported cells are tested. The capability table in `capabilities.py` holds one
|
||||
row per supported (provider, scenario) pair, so there are no skipped cells in the
|
||||
parametrized run. The batches suite never skips: missing provider creds or upstream
|
||||
failures are hard test failures (see `tests/e2e/AGENTS.md`).
|
||||
parametrized run. Missing provider creds or upstream failures are hard test failures
|
||||
(see `tests/e2e/AGENTS.md`). GovCloud is explicitly skipped until its infrastructure
|
||||
is provisioned and does not count as qualified coverage.
|
||||
|
||||
| Provider | create | retrieve | cancel | list | content download | file backing |
|
||||
|-----------|--------|----------|--------|------|------------------|--------------|
|
||||
|
|
|
|||
|
|
@ -1210,6 +1210,7 @@ def _govcloud_params() -> LiteLLMParamsBody:
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="GovCloud E2E infrastructure is not provisioned")
|
||||
class TestBedrockBatchGovCloud:
|
||||
"""Bedrock batch lifecycle in the AWS GovCloud partition (us-gov-west-1).
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from e2e_metadata import Domain, Mode, Provider, Route, Subject, meta
|
|||
from models import LiteLLMParamsBody
|
||||
|
||||
GPT_ON_BEDROCK_MANTLE: Final = "bedrock_mantle/openai.gpt-6.1-sol"
|
||||
GPT_ON_BEDROCK_RUNTIME: Final = "bedrock/us.openai.gpt-6.1-sol"
|
||||
GPT_ON_BEDROCK_RUNTIME: Final = "bedrock/global.openai.gpt-6.1-sol"
|
||||
GPT_ON_AZURE_OPENAI: Final = "azure/gpt-6.1-sol"
|
||||
GPT_ON_OPENAI: Final = "openai/gpt-6.1-sol"
|
||||
CLAUDE_ON_BEDROCK: Final = "bedrock/us.anthropic.claude-sonnet-5-5"
|
||||
|
|
@ -60,7 +60,10 @@ SETUPS: Final = (
|
|||
RateBucket.BEDROCK_CONVERSE,
|
||||
),
|
||||
id="bedrock_runtime",
|
||||
marks=_drives(Provider.BEDROCK, GPT_ON_BEDROCK_RUNTIME),
|
||||
marks=(
|
||||
_drives(Provider.BEDROCK, GPT_ON_BEDROCK_RUNTIME),
|
||||
pytest.mark.skip(reason="Stage IAM lacks bedrock:InvokeModel on the default Bedrock Runtime project"),
|
||||
),
|
||||
),
|
||||
pytest.param(
|
||||
Deployment(
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ from models import (
|
|||
from proxy_client import ProxyClient
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
GUARDRAIL_BACKEND: Final = "gemini/gemini-2.5-flash"
|
||||
GUARDRAIL_BACKEND: Final = "gemini/gemini-3.8-flash"
|
||||
|
||||
GuardrailMode = Literal["pre_call", "post_call", "during_call", "logging_only"]
|
||||
BlockedWordAction = Literal["BLOCK", "MASK"]
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ class TestOpenAIModerationGuardrail:
|
|||
)
|
||||
|
||||
allowed = unwrap(
|
||||
client.messages(scoped_key, model, BENIGN_PROMPT, guardrails=[name], max_tokens=64)
|
||||
client.messages(scoped_key, model, BENIGN_PROMPT, guardrails=[name], max_tokens=512)
|
||||
)
|
||||
assert allowed.content or allowed.choices, (
|
||||
"the same moderation guardrail must let a benign /v1/messages prompt through, but "
|
||||
|
|
|
|||
|
|
@ -57,8 +57,9 @@ Bedrock and xai (`xai/grok-4-1-fast-non-reasoning`) are supported by the proxy b
|
|||
kept commented out in `PROVIDERS` until they pass end-to-end here; re-enable them by
|
||||
uncommenting their entry.
|
||||
|
||||
Every provider is provisioned and asserted; the suite never skips a provider. Per
|
||||
`tests/e2e/AGENTS.md` there is no sanctioned skip: the whole-suite proxy-liveness
|
||||
Azure realtime translation is explicitly skipped pending investigation of intermittent
|
||||
missing transcripts and does not count as qualified coverage. OpenAI translation and
|
||||
other Azure realtime cases remain enabled. Per `tests/e2e/AGENTS.md`, the whole-suite proxy-liveness
|
||||
probe hard-fails when no proxy answers, and a provider whose credentials or upstream
|
||||
realtime model are missing on the gateway is likewise a hard failure, not a skip.
|
||||
Give the gateway each provider's credentials to turn its tests green.
|
||||
|
|
|
|||
|
|
@ -360,7 +360,18 @@ LIVE_TRANSCRIPTION_PROVIDERS: Final = (
|
|||
)
|
||||
|
||||
TRANSLATION_PARAMS: Final = tuple(
|
||||
pytest.param(p, id=p.id, marks=audio_marks(p, "translation")) for p in TRANSLATION_PROVIDERS
|
||||
pytest.param(
|
||||
p,
|
||||
id=p.id,
|
||||
marks=(
|
||||
*audio_marks(p, "translation"),
|
||||
pytest.mark.skipif(
|
||||
p.id == "azure",
|
||||
reason="Azure realtime translation intermittently returns no transcript; qualification deferred",
|
||||
),
|
||||
),
|
||||
)
|
||||
for p in TRANSLATION_PROVIDERS
|
||||
)
|
||||
LIVE_TRANSCRIPTION_PARAMS: Final = tuple(
|
||||
pytest.param(p, id=p.id, marks=audio_marks(p, "transcription")) for p in LIVE_TRANSCRIPTION_PROVIDERS
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ from passthrough_client import PassthroughClient
|
|||
pytestmark = pytest.mark.e2e
|
||||
|
||||
COHERE_BACKEND = "cohere/command-r-08-2024"
|
||||
GEMINI_BACKEND = "gemini/gemini-2.5-flash"
|
||||
GEMINI_BACKEND = "gemini/gemini-3.8-flash"
|
||||
VERTEX_BACKEND: Final = "vertex_ai/gemini-2.5-flash"
|
||||
AZURE_OPENAI_BACKEND: Final = "azure/gpt-5.4-nano"
|
||||
AZURE_OPENAI_API_VERSION: Final = "v1"
|
||||
|
|
@ -243,7 +243,7 @@ _PERSON_SCHEMA: dict[str, object] = {
|
|||
|
||||
OPENAI_CHAT_MODEL: Final = "gpt-5.5"
|
||||
ANTHROPIC_CHAT_MODEL: Final = "claude-haiku-4-5"
|
||||
GEMINI_FLASH_MODEL: Final = "gemini-2.5-flash"
|
||||
GEMINI_FLASH_MODEL: Final = "gemini-3.8-flash"
|
||||
|
||||
CHAT_MODELS: Final = (
|
||||
pytest.param(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ teardown) instead of relying on a statically configured model, so the check is
|
|||
self-contained and never inherits pricing another suite or a stale config left on
|
||||
the shared proxy. custom-priced-flash sets input/output rates deliberately far
|
||||
above the canonical gemini price; the isolation sibling shares the same
|
||||
gemini/gemini-2.5-flash backend but sets no override. Three behaviors are checked
|
||||
gemini/gemini-3.8-flash backend but sets no override. Three behaviors are checked
|
||||
independently:
|
||||
|
||||
- billing: a real call's logged cost breakdown charges input and output tokens at
|
||||
|
|
@ -37,10 +37,8 @@ from models import (
|
|||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
BACKEND_MODEL = "gemini/gemini-2.5-flash"
|
||||
BACKEND_MODEL = "gemini/gemini-3.8-flash"
|
||||
GEMINI_API_KEY = "os.environ/GEMINI_API_KEY"
|
||||
# Deliberately ~100x above canonical gemini-2.5-flash (input 3e-7 / output 2.5e-6)
|
||||
# so an override that is ignored or under-applied bills at the base rate and fails.
|
||||
CUSTOM_INPUT_RATE = 5e-05
|
||||
CUSTOM_OUTPUT_RATE = 1e-04
|
||||
|
||||
|
|
@ -78,7 +76,7 @@ def _provision(
|
|||
input_cost_per_token: float | None,
|
||||
output_cost_per_token: float | None,
|
||||
) -> str:
|
||||
"""Register a fresh gemini/gemini-2.5-flash deployment (deleted on teardown) and
|
||||
"""Register a fresh gemini/gemini-3.8-flash deployment (deleted on teardown) and
|
||||
return its model name. With the cost fields set the deployment carries a custom
|
||||
pricing override; with them None it is a plain sibling on the same backend. The
|
||||
marker keeps the name unique so concurrent runs on the shared proxy never
|
||||
|
|
@ -251,7 +249,7 @@ class TestCustomPricing:
|
|||
assert sibling_entry is not None, f"{sibling} absent from /model/info"
|
||||
|
||||
# custom-priced-flash overrides pricing; the sibling shares the same
|
||||
# gemini/gemini-2.5-flash backend but sets no override, so it must keep its
|
||||
# gemini/gemini-3.8-flash backend but sets no override, so it must keep its
|
||||
# own price. Equal rates mean the override leaked into the shared cost map.
|
||||
assert (
|
||||
sibling_entry.model_info.input_cost_per_token
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from pydantic import BaseModel
|
|||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
UPSTREAM_MODEL = "gemini/gemini-2.5-flash"
|
||||
UPSTREAM_MODEL = "gemini/gemini-3.8-flash"
|
||||
|
||||
|
||||
class _GenerateContentPart(BaseModel):
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ from sdk_clients import SdkClients
|
|||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
IMAGE_EDIT_BACKEND: Final = "openai/gpt-image-1"
|
||||
IMAGE_EDIT_BACKEND: Final = "openai/gpt-image-2.5-sunburst"
|
||||
|
||||
_TEST_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAS0lEQVR42u3PMQ0AAAwDoPo3"
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ from proxy_client import ProxyClient
|
|||
from pydantic import BaseModel
|
||||
from sdk_clients import NO_PROXY_CACHE, SdkClients
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.provider_live]
|
||||
|
||||
FLAGGED_INVOKE_MODEL = "bedrock/invoke/us.anthropic.claude-sonnet-5"
|
||||
UNFLAGGED_INVOKE_MODEL = "bedrock/invoke/us.anthropic.claude-haiku-4-5-20251001-v1:0"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ and verifies the proxy still logged a costed SpendLogs row
|
|||
(call_type="pass_through_endpoint"), correlated by the id the caller was served:
|
||||
the x-litellm-call-id header on gemini, the `msg_...` message id on anthropic.
|
||||
|
||||
Covered: gemini ("gemini-2.5-flash") + anthropic ("claude-haiku-4-5"), streaming +
|
||||
Covered: gemini ("gemini-3.8-flash") + anthropic ("claude-haiku-4-5"), streaming +
|
||||
non-streaming, plus native tool calls. See LLM_TRANSLATION_COVERAGE_MATRIX.md.
|
||||
|
||||
A passthrough call returning non-2xx fails hard (never a skip); once it returns
|
||||
|
|
@ -32,7 +32,7 @@ from passthrough_client import (
|
|||
completed_responses_object,
|
||||
)
|
||||
|
||||
GEMINI_MODEL: Final = "gemini-2.5-flash"
|
||||
GEMINI_MODEL: Final = "gemini-3.8-flash"
|
||||
ANTHROPIC_PASSTHROUGH_MODEL: Final = "claude-haiku-4-5"
|
||||
EMBEDDING_MODEL = "text-embedding-3-small"
|
||||
REALTIME_MODEL = "gpt-realtime-2"
|
||||
|
|
@ -291,7 +291,7 @@ class TestPassthroughModelAllowlist:
|
|||
|
||||
result = client.anthropic_message(key, ANTHROPIC_PASSTHROUGH_MODEL, f"say hi {unique_marker()}")
|
||||
assert result.status_code == 403, (
|
||||
"a key restricted to gemini-2.5-flash must be denied a claude passthrough call, "
|
||||
"a key restricted to gemini-3.8-flash must be denied a claude passthrough call, "
|
||||
f"got {result.status_code}: {result.body[:300]}"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ OPENAI_VISION_BACKEND: Final = "openai/gpt-4o"
|
|||
ANTHROPIC_BACKEND: Final = "anthropic/claude-haiku-4-5"
|
||||
BEDROCK_CONVERSE_BACKEND: Final = "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0"
|
||||
VERTEX_BACKEND: Final = "vertex_ai/gemini-2.5-flash"
|
||||
GEMINI_BACKEND: Final = "gemini/gemini-2.5-flash"
|
||||
GEMINI_BACKEND: Final = "gemini/gemini-3.8-flash"
|
||||
OPENAI_RESPONSES_BACKEND: Final = "openai/gpt-5.5"
|
||||
INSTRUCTIONS = "You are a helpful assistant"
|
||||
CAT_IMAGE_URL = "https://upload.wikimedia.org/wikipedia/commons/3/3a/Cat03.jpg"
|
||||
|
|
|
|||
|
|
@ -18,10 +18,11 @@ import json
|
|||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Literal
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Callable, Final, Literal
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, ValidationError
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, ValidationError, field_validator
|
||||
|
||||
from e2e_config import POLL_INTERVAL, POLL_TIMEOUT, settle_propagation
|
||||
from e2e_metadata import step
|
||||
|
|
@ -124,23 +125,39 @@ class CreateGuardrailResponse(BaseModel):
|
|||
guardrail_name: str | None = None
|
||||
|
||||
|
||||
_LANGFUSE_JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
|
||||
|
||||
|
||||
class LangfuseObservation(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True)
|
||||
|
||||
id: str
|
||||
trace_id: str | None = Field(default=None, alias="traceId")
|
||||
name: str | None = None
|
||||
type: str | None = None
|
||||
calculated_total_cost: float | None = Field(default=None, alias="calculatedTotalCost")
|
||||
calculated_total_cost: float | None = Field(
|
||||
default=None, validation_alias=AliasChoices("totalCost", "calculatedTotalCost")
|
||||
)
|
||||
level: str | None = None
|
||||
input: object | None = None
|
||||
output: object | None = None
|
||||
metadata: object | None = None
|
||||
input: JsonValue = None
|
||||
output: JsonValue = None
|
||||
metadata: JsonValue = None
|
||||
usage: object | None = None
|
||||
usage_details: object | None = Field(default=None, alias="usageDetails")
|
||||
model: str | None = None
|
||||
|
||||
|
||||
@field_validator("input", "output", "metadata", mode="before")
|
||||
@classmethod
|
||||
def decode_json_fields(cls, value: object) -> JsonValue:
|
||||
if not isinstance(value, str):
|
||||
return _LANGFUSE_JSON.validate_python(value)
|
||||
try:
|
||||
return _LANGFUSE_JSON.validate_json(value)
|
||||
except ValidationError:
|
||||
return value
|
||||
|
||||
|
||||
class LangfuseObservationList(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
|
@ -171,12 +188,15 @@ def is_otel_v2_generation(obs: LangfuseObservation, *, key_alias: str) -> bool:
|
|||
|
||||
|
||||
class LangfuseListParams(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
model_config = ConfigDict(populate_by_name=True, frozen=True)
|
||||
|
||||
limit: int = 100
|
||||
trace_id: str | None = Field(default=None, alias="traceId")
|
||||
name: str | None = None
|
||||
from_start_time: str | None = Field(default=None, alias="fromStartTime")
|
||||
from_start_time: str = Field(alias="fromStartTime")
|
||||
to_start_time: str = Field(alias="toStartTime")
|
||||
fields: str = "core,basic,io,metadata,model,usage"
|
||||
expand_metadata: str = Field(default="attributes", alias="expandMetadata")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -580,14 +600,16 @@ class LoggingClient:
|
|||
name: str | None = None,
|
||||
from_start_time: str | None = None,
|
||||
) -> list[LangfuseObservation]:
|
||||
result = get(
|
||||
URL(f"{creds.host}/api/public/observations"),
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
result: Final = get(
|
||||
URL(f"{creds.host}/api/public/v2/observations"),
|
||||
headers=creds.auth_headers,
|
||||
params=LangfuseListParams(
|
||||
limit=100,
|
||||
traceId=trace_id,
|
||||
name=name,
|
||||
fromStartTime=from_start_time,
|
||||
fromStartTime=from_start_time or (now - timedelta(minutes=15)).isoformat(),
|
||||
toStartTime=now.isoformat(),
|
||||
),
|
||||
response_type=LangfuseObservationList,
|
||||
timeout=30.0,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ pytestmark = pytest.mark.e2e
|
|||
|
||||
class LangsmithHeaders(Headers):
|
||||
x_api_key: str = Field(serialization_alias="x-api-key")
|
||||
x_tenant_id: str | None = Field(default=None, serialization_alias="x-tenant-id")
|
||||
|
||||
|
||||
class LangsmithRunExtra(BaseModel):
|
||||
|
|
@ -50,6 +51,7 @@ class LangsmithCreds:
|
|||
api_key: str
|
||||
base_url: str
|
||||
project: str
|
||||
tenant_id: str | None
|
||||
|
||||
|
||||
def load_langsmith_creds() -> LangsmithCreds:
|
||||
|
|
@ -62,6 +64,7 @@ def load_langsmith_creds() -> LangsmithCreds:
|
|||
api_key=api_key,
|
||||
base_url=(os.getenv("LANGSMITH_BASE_URL") or "https://api.smith.langchain.com").rstrip("/"),
|
||||
project=os.getenv("LANGSMITH_PROJECT") or "litellm-e2e",
|
||||
tenant_id=os.getenv("LANGSMITH_TENANT_ID"),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -69,7 +72,7 @@ def _fetch_run(creds: LangsmithCreds, run_id: uuid.UUID) -> LangsmithRun | None:
|
|||
result = get_external(
|
||||
f"{creds.base_url}/runs/{run_id}",
|
||||
response_type=LangsmithRun,
|
||||
headers=LangsmithHeaders(x_api_key=creds.api_key),
|
||||
headers=LangsmithHeaders(x_api_key=creds.api_key, x_tenant_id=creds.tenant_id),
|
||||
)
|
||||
match result:
|
||||
case Success(data=run):
|
||||
|
|
@ -102,7 +105,10 @@ class TestLangsmithBatchSerialization:
|
|||
async def test_non_json_native_metadata_reaches_langsmith(self) -> None:
|
||||
creds: Final = load_langsmith_creds()
|
||||
logger: Final = LangsmithLogger(
|
||||
langsmith_api_key=creds.api_key, langsmith_project=creds.project, langsmith_base_url=creds.base_url
|
||||
langsmith_api_key=creds.api_key,
|
||||
langsmith_project=creds.project,
|
||||
langsmith_base_url=creds.base_url,
|
||||
langsmith_tenant_id=creds.tenant_id,
|
||||
)
|
||||
assert not logger.is_mock_mode, "LangsmithLogger initialised in mock mode; this e2e needs the real API"
|
||||
marker: Final = unique_marker()
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ def _chain_reaches(span_id: str, root_id: str, trace: JaegerTrace) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _assert_complete_trace(traces: CallTraces, *, route: str, genai_span: str) -> None:
|
||||
def assert_complete_trace(traces: CallTraces, *, route: str, genai_span: str) -> None:
|
||||
"""The enforced behavior: the destination holds exactly one call-id-tagged
|
||||
trace for the call, rooted at the SERVER span, with auth/db children and
|
||||
the gen-AI span all connected into that one tree - no dangling parent
|
||||
|
|
@ -112,10 +112,9 @@ def _assert_complete_trace(traces: CallTraces, *, route: str, genai_span: str) -
|
|||
roots = [span for span in trace.spans if not span.references]
|
||||
assert len(roots) == 1, f"expected exactly one root span, got {[s.operation_name for s in roots]}; spans: {names}"
|
||||
root = roots[0]
|
||||
assert root.operation_name == f"POST {route}", (
|
||||
f"the root must be the SERVER span 'POST {route}', got {root.operation_name!r}"
|
||||
)
|
||||
assert root.kind == "server", f"the root span must have kind=server, got {root.kind!r}"
|
||||
request_route: Final = root.tag("http.route") or root.tag("url.path") or root.tag("http.target")
|
||||
assert request_route == route, f"the root must identify route {route!r}, got {request_route!r}"
|
||||
|
||||
assert f"auth {route}" in names, f"auth phase span 'auth {route}' missing; spans: {names}"
|
||||
assert any(name.startswith(DB_SPAN_PREFIX) for name in names), (
|
||||
|
|
@ -133,12 +132,12 @@ def _assert_complete_trace(traces: CallTraces, *, route: str, genai_span: str) -
|
|||
def _poll(otel_reader: OtelReader, *, call_id: str, route: str, genai_span: str) -> CallTraces:
|
||||
return otel_reader.poll_traces_for_call(
|
||||
call_id=call_id,
|
||||
settled_names={f"POST {route}", f"auth {route}", genai_span},
|
||||
settled_names={f"auth {route}", genai_span},
|
||||
settled_prefixes={DB_SPAN_PREFIX},
|
||||
)
|
||||
|
||||
|
||||
def _assert_real_ttft(hits: tuple[JaegerTrace, ...], *, genai_span: str) -> None:
|
||||
def assert_real_ttft(hits: tuple[JaegerTrace, ...], *, genai_span: str) -> None:
|
||||
"""The enforced behavior: the gen-AI span for the attempt that served the
|
||||
stream records a TTFT that is a real measurement - present, numeric,
|
||||
positive, and strictly less than that span's own total duration. A TTFT of
|
||||
|
|
@ -282,7 +281,7 @@ class TestOtelTraceCompleteness:
|
|||
assert outcome.call_id is not None, "success response must carry x-litellm-call-id"
|
||||
|
||||
traces = _poll(otel_reader, call_id=outcome.call_id, route=route, genai_span=f"chat {MODEL}")
|
||||
_assert_complete_trace(traces, route=route, genai_span=f"chat {MODEL}")
|
||||
assert_complete_trace(traces, route=route, genai_span=f"chat {MODEL}")
|
||||
|
||||
@pytest.mark.covers("logging.otel.success.exports_metric", exercised_on=["chat_completions"])
|
||||
@meta(
|
||||
|
|
@ -315,7 +314,7 @@ class TestOtelTraceCompleteness:
|
|||
assert outcome.call_id is not None, "success response must carry x-litellm-call-id"
|
||||
|
||||
hits: Final = _poll(otel_reader, call_id=outcome.call_id, route=route, genai_span=f"chat {MODEL}")
|
||||
_assert_complete_trace(hits, route=route, genai_span=f"chat {MODEL}")
|
||||
assert_complete_trace(hits, route=route, genai_span=f"chat {MODEL}")
|
||||
|
||||
@pytest.mark.covers("logging.otel.success.exports_metric", exercised_on=["messages"])
|
||||
@meta(
|
||||
|
|
@ -333,7 +332,7 @@ class TestOtelTraceCompleteness:
|
|||
"""This test verifies that one successful non-streaming /v1/messages request
|
||||
produces exactly one complete OTEL trace.
|
||||
|
||||
The trace must have a single root span named "POST /v1/messages". The
|
||||
The trace must have a single root SERVER span identifying /v1/messages. The
|
||||
authentication, database, and model-call spans must all belong to
|
||||
the same trace and have valid parent relationships leading back to that root.
|
||||
|
||||
|
|
@ -353,7 +352,7 @@ class TestOtelTraceCompleteness:
|
|||
assert outcome.call_id is not None, "success response must carry x-litellm-call-id"
|
||||
|
||||
traces = _poll(otel_reader, call_id=outcome.call_id, route=route, genai_span=f"chat {MODEL}")
|
||||
_assert_complete_trace(traces, route=route, genai_span=f"chat {MODEL}")
|
||||
assert_complete_trace(traces, route=route, genai_span=f"chat {MODEL}")
|
||||
|
||||
@pytest.mark.covers("logging.otel.success.exports_metric", exercised_on=["responses"])
|
||||
@meta(
|
||||
|
|
@ -392,7 +391,7 @@ class TestOtelTraceCompleteness:
|
|||
|
||||
genai_span = f"chat {CHEAP_OPENAI_MODEL}"
|
||||
traces = _poll(otel_reader, call_id=outcome.call_id, route=route, genai_span=genai_span)
|
||||
_assert_complete_trace(traces, route=route, genai_span=genai_span)
|
||||
assert_complete_trace(traces, route=route, genai_span=genai_span)
|
||||
|
||||
@pytest.mark.covers("logging.otel.stream.exports_metric", exercised_on=["chat_completions"])
|
||||
@meta(
|
||||
|
|
@ -443,7 +442,7 @@ class TestOtelTraceCompleteness:
|
|||
|
||||
genai_span = f"chat {MODEL}"
|
||||
traces = _poll(otel_reader, call_id=outcome.call_id, route=route, genai_span=genai_span)
|
||||
_assert_complete_trace(traces, route=route, genai_span=genai_span)
|
||||
assert_complete_trace(traces, route=route, genai_span=genai_span)
|
||||
|
||||
served = one_served_genai_span(traces.hits[0], genai_span)
|
||||
assert served.tag("litellm.request.streaming") is True, (
|
||||
|
|
@ -500,7 +499,7 @@ class TestOtelTraceCompleteness:
|
|||
|
||||
genai_span = f"chat {MODEL}"
|
||||
traces = _poll(otel_reader, call_id=outcome.call_id, route=route, genai_span=genai_span)
|
||||
_assert_complete_trace(traces, route=route, genai_span=genai_span)
|
||||
assert_complete_trace(traces, route=route, genai_span=genai_span)
|
||||
|
||||
served = one_served_genai_span(traces.hits[0], genai_span)
|
||||
assert served.tag("litellm.request.streaming") is True, (
|
||||
|
|
@ -557,7 +556,7 @@ class TestOtelTraceCompleteness:
|
|||
|
||||
genai_span = f"chat {CHEAP_OPENAI_MODEL}"
|
||||
traces = _poll(otel_reader, call_id=outcome.call_id, route=route, genai_span=genai_span)
|
||||
_assert_complete_trace(traces, route=route, genai_span=genai_span)
|
||||
assert_complete_trace(traces, route=route, genai_span=genai_span)
|
||||
|
||||
one_served_genai_span(traces.hits[0], genai_span)
|
||||
|
||||
|
|
@ -614,7 +613,7 @@ class TestOtelTraceCompleteness:
|
|||
|
||||
genai_span = f"chat {MODEL}"
|
||||
traces = _poll(otel_reader, call_id=outcome.call_id, route=route, genai_span=genai_span)
|
||||
_assert_real_ttft(traces.hits, genai_span=genai_span)
|
||||
assert_real_ttft(traces.hits, genai_span=genai_span)
|
||||
|
||||
@pytest.mark.covers("logging.otel.stream.records_ttft", exercised_on=["messages"])
|
||||
@meta(
|
||||
|
|
@ -661,7 +660,7 @@ class TestOtelTraceCompleteness:
|
|||
|
||||
genai_span = f"chat {MODEL}"
|
||||
traces = _poll(otel_reader, call_id=outcome.call_id, route=route, genai_span=genai_span)
|
||||
_assert_real_ttft(traces.hits, genai_span=genai_span)
|
||||
assert_real_ttft(traces.hits, genai_span=genai_span)
|
||||
|
||||
@pytest.mark.covers("logging.otel.stream.records_ttft", exercised_on=["responses"])
|
||||
@meta(
|
||||
|
|
@ -708,7 +707,7 @@ class TestOtelTraceCompleteness:
|
|||
|
||||
genai_span = f"chat {CHEAP_OPENAI_MODEL}"
|
||||
traces = _poll(otel_reader, call_id=outcome.call_id, route=route, genai_span=genai_span)
|
||||
_assert_real_ttft(traces.hits, genai_span=genai_span)
|
||||
assert_real_ttft(traces.hits, genai_span=genai_span)
|
||||
|
||||
@pytest.mark.covers("logging.otel.failure.exports_metric", exercised_on=["chat_completions"])
|
||||
@meta(
|
||||
|
|
@ -763,7 +762,7 @@ class TestOtelTraceCompleteness:
|
|||
|
||||
genai_span = f"chat {model_name}"
|
||||
traces = _poll(otel_reader, call_id=outcome.call_id, route=route, genai_span=genai_span)
|
||||
_assert_complete_trace(traces, route=route, genai_span=genai_span)
|
||||
assert_complete_trace(traces, route=route, genai_span=genai_span)
|
||||
|
||||
root = next(span for span in traces.hits[0].spans if not span.references)
|
||||
assert str(root.tag("http.status_code")) == "401", (
|
||||
|
|
@ -825,7 +824,7 @@ class TestOtelTraceCompleteness:
|
|||
|
||||
genai_span = f"chat {model_name}"
|
||||
traces = _poll(otel_reader, call_id=outcome.call_id, route=route, genai_span=genai_span)
|
||||
_assert_complete_trace(traces, route=route, genai_span=genai_span)
|
||||
assert_complete_trace(traces, route=route, genai_span=genai_span)
|
||||
|
||||
root = next(span for span in traces.hits[0].spans if not span.references)
|
||||
assert str(root.tag("http.status_code")) == "401", (
|
||||
|
|
|
|||
|
|
@ -418,7 +418,7 @@ class TestBudgetListV1:
|
|||
@pytest.mark.covers("mgmt.budget.list_v1.happy_path")
|
||||
@meta(Subject(domain=Domain.SPEND_BUDGETS, route=Route.BUDGET_MANAGEMENT))
|
||||
def test_refuses_a_sort_field_and_a_parameter_it_does_not_support(self, client: ManagementClient) -> None:
|
||||
assert _list_status(client, BudgetPageParams(sort="budget_duration")) == 400
|
||||
assert _list_status(client, BudgetPageParams(sort="not_a_sort_field")) == 400
|
||||
assert _list_status(client, BudgetPageParams(not_a_parameter="b-1")) == 400
|
||||
|
||||
@pytest.mark.covers("mgmt.budget.list_v1.admin_only")
|
||||
|
|
|
|||
|
|
@ -28,11 +28,10 @@ from dataclasses import dataclass
|
|||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from e2e_config import OTEL_QUERY_URL, POLL_INTERVAL, POLL_TIMEOUT
|
||||
from e2e_http import URL, NetworkError, NoBody, Result, Success, get
|
||||
from e2e_metadata import step
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
#: OTEL resource service.name the proxy exports under (OTEL_SERVICE_NAME default).
|
||||
JAEGER_SERVICE = "litellm"
|
||||
|
|
@ -118,9 +117,15 @@ def _ticks() -> Iterator[None]:
|
|||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
|
||||
def _settled(trace: JaegerTrace, names: set[str], prefixes: set[str]) -> bool:
|
||||
present = set(trace.span_names())
|
||||
return names.issubset(present) and all(any(name.startswith(prefix) for name in present) for prefix in prefixes)
|
||||
def trace_settled(trace: JaegerTrace, names: set[str], prefixes: set[str]) -> bool:
|
||||
root: Final = root_span(trace)
|
||||
present: Final = frozenset(trace.span_names())
|
||||
return (
|
||||
root is not None
|
||||
and root.kind == "server"
|
||||
and names.issubset(present)
|
||||
and all(any(name.startswith(prefix) for name in present) for prefix in prefixes)
|
||||
)
|
||||
|
||||
|
||||
def root_span(trace: JaegerTrace) -> JaegerSpan | None:
|
||||
|
|
@ -172,7 +177,7 @@ class _Observation:
|
|||
|
||||
def settled(self, names: set[str], prefixes: set[str]) -> bool:
|
||||
hits: Final = self.traces.hits
|
||||
return self.unreachable is None and len(hits) == 1 and not self.missing and _settled(hits[0], names, prefixes)
|
||||
return self.unreachable is None and len(hits) == 1 and not self.missing and trace_settled(hits[0], names, prefixes)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ from typing import Final, Protocol, cast
|
|||
|
||||
from provider_cache import LIFETIME_SECONDS, CacheBusy, CacheEdge, CacheHit, CacheLookup, CacheUnavailable, CaptureLease
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from redis import Redis
|
||||
from redis.exceptions import RedisError
|
||||
from redis import Redis, RedisCluster
|
||||
from redis.exceptions import RedisClusterException, RedisError
|
||||
|
||||
REDIS_ARRAY: Final[TypeAdapter[list[bytes]]] = TypeAdapter(list[bytes])
|
||||
|
||||
|
|
@ -83,7 +83,7 @@ class RedisResponseStore:
|
|||
started: Final = time.monotonic()
|
||||
try:
|
||||
result: Final = self.client.eval(LOOKUP, 2, *self.keys(key), token, self.lifetime_ms, self.lease_ms)
|
||||
except (RedisError, OSError):
|
||||
except (RedisError, RedisClusterException, OSError):
|
||||
return CacheUnavailable()
|
||||
try:
|
||||
parts: Final = tuple(REDIS_ARRAY.validate_python(result, strict=True))
|
||||
|
|
@ -102,28 +102,46 @@ class RedisResponseStore:
|
|||
result: Final = self.client.eval(
|
||||
PUBLISH, 2, *self.keys(key), lease.token, lease.captured_at_ms, lease.expires_at_ms, payload, self.lifetime_ms,
|
||||
)
|
||||
except (RedisError, OSError):
|
||||
except (RedisError, RedisClusterException, OSError):
|
||||
return False
|
||||
return result == 1
|
||||
|
||||
def release(self, key: str, lease: CaptureLease) -> bool:
|
||||
try:
|
||||
result: Final = self.client.eval(RELEASE, 1, self.keys(key)[1], lease.token)
|
||||
except (RedisError, OSError):
|
||||
except (RedisError, RedisClusterException, OSError):
|
||||
return False
|
||||
return result == 1
|
||||
|
||||
def discard(self, key: str, payload: bytes) -> bool:
|
||||
try:
|
||||
result: Final = self.client.eval(DISCARD, 1, self.keys(key)[0], payload)
|
||||
except (RedisError, OSError):
|
||||
except (RedisError, RedisClusterException, OSError):
|
||||
return False
|
||||
return result == 1
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=16)
|
||||
def _redis_commands(url: str, cluster: bool) -> RedisCommands:
|
||||
if cluster:
|
||||
return cast(
|
||||
RedisCommands, RedisCluster(url=url, socket_timeout=0.25, socket_connect_timeout=0.25, decode_responses=False)
|
||||
)
|
||||
return cast(RedisCommands, Redis.from_url(url, socket_timeout=0.25, socket_connect_timeout=0.25, decode_responses=False))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LazyRedisCommands:
|
||||
url: str
|
||||
cluster: bool
|
||||
|
||||
def eval(self, script: str, numkeys: int, *args: str | bytes | int) -> object:
|
||||
return _redis_commands(self.url, self.cluster).eval(script, numkeys, *args)
|
||||
|
||||
|
||||
def redis_store(url: str, namespace: str) -> RedisResponseStore:
|
||||
client: Final = Redis.from_url(url, socket_timeout=0.25, socket_connect_timeout=0.25, decode_responses=False)
|
||||
return RedisResponseStore(cast(RedisCommands, client), namespace)
|
||||
client: Final = LazyRedisCommands(url, os.environ.get("E2E_PROVIDER_CACHE_REDIS_CLUSTER") == "1")
|
||||
return RedisResponseStore(client, namespace)
|
||||
|
||||
|
||||
def write_metrics(cache: CacheEdge) -> None:
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ from spend_e2e_client import (
|
|||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
CHAT_MODEL: Final = "gemini-2.5-flash"
|
||||
CHAT_MODEL: Final = "gemini-3.8-flash"
|
||||
MESSAGES_MODEL: Final = "claude-haiku-4-5"
|
||||
RESPONSES_MODEL: Final = "openai-responses-codex"
|
||||
EMBED_MODEL: Final = "openai-text-embedding-3-small"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Run against a proxy started with the gateway config. Coverage rationale:
|
||||
SPEND_TRACKING_COVERAGE_MATRIX.md.
|
||||
|
||||
Model names are literals from that config: chat tests hit "gemini-2.5-flash",
|
||||
Model names are literals from that config: chat tests hit "gemini-3.8-flash",
|
||||
embedding tests hit "openai-text-embedding-3-small".
|
||||
|
||||
Every test: fresh scoped key (isolation) -> real provider call -> unwrap (hard
|
||||
|
|
@ -37,7 +37,7 @@ from spend_reconciliation import BACKEND as TRAFFIC_BACKEND
|
|||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
GEMINI_MODEL = "gemini-2.5-flash"
|
||||
GEMINI_MODEL = "gemini-3.8-flash"
|
||||
CLAUDE_MODEL = "claude-haiku-4-5"
|
||||
CODEX_MODEL = "openai-responses-codex"
|
||||
EMBEDDING_MODEL = "openai-text-embedding-3-small"
|
||||
|
|
@ -780,7 +780,7 @@ def test_spend_calculate_returns_nonzero_cost(client: SpendClient) -> None:
|
|||
GEMINI_MODEL, "estimate the cost of this request"
|
||||
)
|
||||
assert cost > 0, (
|
||||
"/spend/calculate returned 0 for gemini-2.5-flash; "
|
||||
"/spend/calculate returned 0 for gemini-3.8-flash; "
|
||||
"cost map may be missing this model"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from models import (
|
|||
from proxy_client import ProxyClient
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.provider_live]
|
||||
|
||||
BEDROCK_INVOKE_BACKEND: Final = "bedrock/invoke/us.anthropic.claude-haiku-4-5-20251001-v1:0"
|
||||
SEARCH_CALL_TYPE: Final = "asearch"
|
||||
|
|
|
|||
|
|
@ -57,11 +57,9 @@ CONTENT_POLICY_PROMPT = (
|
|||
COOLDOWN_SECONDS = 30.0
|
||||
REPLICA_PROPAGATION_SECONDS = 15.0
|
||||
|
||||
# The smallest-context chat model OpenAI still serves (16385 tokens). A prompt
|
||||
# past that limit comes back as a real `context_length_exceeded` 400, which is
|
||||
# what litellm maps to ContextWindowExceededError.
|
||||
SMALL_CONTEXT_MODEL = "openai/gpt-3.5-turbo"
|
||||
SMALL_CONTEXT_LIMIT_TOKENS = 16385
|
||||
SMALL_CONTEXT_MODEL: Final = "openai/gpt-5.4-nano"
|
||||
# model_prices_and_context_window.json, gpt-5.4-nano, verified 2026-11-02
|
||||
SMALL_CONTEXT_LIMIT_TOKENS: Final = 272000
|
||||
|
||||
|
||||
def oversized_prompt(marker: str) -> str:
|
||||
|
|
@ -107,7 +105,7 @@ def create_timeout_deployment(proxy: ProxyClient, name: str) -> str:
|
|||
|
||||
@step(f"Add a deployment named {{name}} that calls the small-context model {SMALL_CONTEXT_MODEL}")
|
||||
def create_small_context_deployment(proxy: ProxyClient, name: str) -> str:
|
||||
"""Register a deployment on the smallest-context model OpenAI still serves, so an
|
||||
"""Register a deployment with a smaller context window than the backup, so an
|
||||
oversized prompt earns a real context-window refusal from the provider."""
|
||||
return proxy.create_model(name, LiteLLMParamsBody(model=SMALL_CONTEXT_MODEL, api_key=REAL_KEY))
|
||||
|
||||
|
|
@ -263,8 +261,7 @@ def spend_only_request_of(proxy: ProxyClient, spent_key: str) -> None:
|
|||
|
||||
@step(f"Add a deployment named {{name}} that calls {SMALL_CONTEXT_MODEL} and takes all of its group's traffic")
|
||||
def create_always_picked_small_context_deployment(proxy: ProxyClient, name: str) -> str:
|
||||
"""The always-picked half of a retry pair on the smallest-context model OpenAI
|
||||
still serves: it holds all of the model group's shuffle weight, so an oversized
|
||||
"""The always-picked half of a retry pair on the smaller-context model: it holds all of the model group's shuffle weight, so an oversized
|
||||
prompt opens on it and earns a real context-window refusal, which never benches
|
||||
a deployment, so only the retry itself can steer the request off it."""
|
||||
return proxy.register_model(
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export async function navigateToPage(page: PlaywrightPage, pageEnum: Page): Prom
|
|||
// assert against the default page.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await page.goto(`/ui?page=${pageEnum}`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.getByRole("complementary")).toBeVisible();
|
||||
const url = new URL(page.url());
|
||||
const onLegacyRoot = url.pathname.replace(/\/+$/, "").endsWith("/ui");
|
||||
if (!onLegacyRoot || url.searchParams.get("page") === pageEnum) {
|
||||
|
|
|
|||
|
|
@ -391,7 +391,7 @@ test.describe("Add Model", () => {
|
|||
|
||||
// Navigate to Deployed Models tab
|
||||
await page.getByRole("tab", { name: "Deployed Models" }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.getByPlaceholder("Search model names")).toBeVisible();
|
||||
|
||||
// Search for the model we just added
|
||||
await page.getByPlaceholder("Search model names").fill("claude-haiku-4-5");
|
||||
|
|
@ -470,7 +470,7 @@ test.describe("Add Model", () => {
|
|||
|
||||
// The Models table renders team-scoped models with the team id in the row.
|
||||
await page.getByRole("tab", { name: "Deployed Models" }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.getByPlaceholder("Search model names")).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder("Search model names").fill("cohere");
|
||||
|
||||
|
|
@ -515,7 +515,7 @@ test.describe("Add Model", () => {
|
|||
|
||||
// Navigate to Deployed Models tab
|
||||
await page.getByRole("tab", { name: "Deployed Models" }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.getByPlaceholder("Search model names")).toBeVisible();
|
||||
|
||||
// Search for the wildcard model
|
||||
await page.getByPlaceholder("Search model names").fill("cohere");
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ async function readStoredParams(
|
|||
}
|
||||
|
||||
function paramsEditor(page: PlaywrightPage) {
|
||||
return page.getByPlaceholder('"rpm": 100');
|
||||
return page.getByRole("textbox", { name: "LiteLLM Params", exact: true });
|
||||
}
|
||||
|
||||
async function editParams(
|
||||
|
|
|
|||
49
tests/e2e_harness/logging/test_logging_client.py
Normal file
49
tests/e2e_harness/logging/test_logging_client.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import json
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from pydantic import JsonValue
|
||||
|
||||
from logging_client import LangfuseObservation, is_otel_v2_generation, observation_spend
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encoded", [False, True])
|
||||
@pytest.mark.parametrize("cost", [0.0, 0.000023])
|
||||
def test_observation_preserves_v2_content_usage_and_cost(encoded: bool, cost: float) -> None:
|
||||
content: Final[JsonValue] = [{"role": "user", "content": "unique-request-marker"}]
|
||||
output: Final[JsonValue] = [{"role": "assistant", "content": "response"}]
|
||||
metadata: Final[JsonValue] = {
|
||||
"attributes": {
|
||||
"langfuse.observation.type": "generation",
|
||||
"litellm.metadata.user_api_key_alias": "test-key-alias",
|
||||
}
|
||||
}
|
||||
usage: Final[JsonValue] = {"input": 17, "output": 3}
|
||||
observation: Final = LangfuseObservation.model_validate(
|
||||
{
|
||||
"id": "observation",
|
||||
"input": json.dumps(content) if encoded else content,
|
||||
"output": json.dumps(output) if encoded else output,
|
||||
"metadata": json.dumps(metadata) if encoded else metadata,
|
||||
"usageDetails": usage,
|
||||
"totalCost": cost,
|
||||
}
|
||||
)
|
||||
|
||||
assert observation.input == content
|
||||
assert observation.output == output
|
||||
assert observation.usage_details == usage
|
||||
assert observation_spend(observation) == cost
|
||||
assert is_otel_v2_generation(observation, key_alias="test-key-alias")
|
||||
assert not is_otel_v2_generation(observation, key_alias="another-key")
|
||||
|
||||
|
||||
def test_observation_preserves_plain_text_and_legacy_cost() -> None:
|
||||
cost: Final = 0.000031
|
||||
observation: Final = LangfuseObservation.model_validate(
|
||||
{"id": "observation", "input": "plain prompt", "output": "plain reply", "calculatedTotalCost": cost}
|
||||
)
|
||||
|
||||
assert observation.input == "plain prompt"
|
||||
assert observation.output == "plain reply"
|
||||
assert observation_spend(observation) == cost
|
||||
|
|
@ -8,8 +8,21 @@ helper is worth pinning here.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from otel_client import TTFT_TAG, JaegerTrace, one_served_genai_span, served_genai_spans
|
||||
from otel_client import (
|
||||
TTFT_TAG,
|
||||
CallTraces,
|
||||
JaegerReference,
|
||||
JaegerSpan,
|
||||
JaegerTag,
|
||||
JaegerTrace,
|
||||
one_served_genai_span,
|
||||
served_genai_spans,
|
||||
trace_settled,
|
||||
)
|
||||
from test_otel_trace_e2e import assert_complete_trace, assert_real_ttft
|
||||
|
||||
GENAI_SPAN = "chat claude-haiku-4-5"
|
||||
|
||||
|
|
@ -79,3 +92,73 @@ def test_other_operations_are_not_counted() -> None:
|
|||
trace = _trace(_span("chat gpt-5.5", ttft=0.3), _span(GENAI_SPAN, ttft=0.3))
|
||||
|
||||
assert len(served_genai_spans(trace, GENAI_SPAN)) == 1
|
||||
|
||||
|
||||
def _request_trace(
|
||||
*,
|
||||
root_name: str = "POST",
|
||||
root_kind: str = "server",
|
||||
route: str = "/v1/messages",
|
||||
genai_parent: str = "root",
|
||||
ttft: float = 0.2,
|
||||
) -> JaegerTrace:
|
||||
parent: Final = JaegerReference(refType="CHILD_OF", traceID="request", spanID="root")
|
||||
return JaegerTrace(
|
||||
traceID="request",
|
||||
spans=[
|
||||
JaegerSpan(
|
||||
spanID="root",
|
||||
operationName=root_name,
|
||||
tags=[JaegerTag(key="span.kind", value=root_kind), JaegerTag(key="http.target", value=route)],
|
||||
),
|
||||
JaegerSpan(spanID="auth", operationName="auth /v1/messages", references=[parent]),
|
||||
JaegerSpan(spanID="db", operationName="postgres.select tokens", references=[parent]),
|
||||
JaegerSpan(
|
||||
spanID="genai",
|
||||
operationName=GENAI_SPAN,
|
||||
duration=1_000_000,
|
||||
references=[JaegerReference(refType="CHILD_OF", traceID="request", spanID=genai_parent)],
|
||||
tags=[JaegerTag(key="span.kind", value="client"), JaegerTag(key=TTFT_TAG, value=ttft)],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("root_name", ("POST", "POST /v1/messages"))
|
||||
def test_connected_request_trace_is_independent_of_server_display_name(root_name: str) -> None:
|
||||
trace: Final = _request_trace(root_name=root_name)
|
||||
assert_complete_trace(CallTraces((trace,), ()), route="/v1/messages", genai_span=GENAI_SPAN)
|
||||
assert_real_ttft((trace,), genai_span=GENAI_SPAN)
|
||||
assert trace_settled(trace, {"auth /v1/messages", GENAI_SPAN}, {"postgres."})
|
||||
|
||||
|
||||
def test_incomplete_trace_cannot_settle_before_the_server_span_arrives() -> None:
|
||||
trace: Final = _request_trace()
|
||||
incomplete: Final = trace.model_copy(update={"spans": trace.spans[1:]})
|
||||
assert not trace_settled(incomplete, {"auth /v1/messages", GENAI_SPAN}, {"postgres."})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ("/v1/responses", ""))
|
||||
def test_request_trace_must_still_identify_the_requested_route(route: str) -> None:
|
||||
trace: Final = _request_trace(root_name="POST /v1/messages", route=route)
|
||||
with pytest.raises(AssertionError, match="route"):
|
||||
assert_complete_trace(CallTraces((trace,), ()), route="/v1/messages", genai_span=GENAI_SPAN)
|
||||
|
||||
|
||||
def test_request_trace_must_still_be_rooted_at_a_server_span() -> None:
|
||||
trace: Final = _request_trace(root_name="POST /v1/messages", root_kind="client")
|
||||
with pytest.raises(AssertionError, match="kind=server"):
|
||||
assert_complete_trace(CallTraces((trace,), ()), route="/v1/messages", genai_span=GENAI_SPAN)
|
||||
|
||||
|
||||
def test_request_trace_must_still_connect_the_model_call_to_the_request() -> None:
|
||||
trace: Final = _request_trace(genai_parent="absent")
|
||||
with pytest.raises(AssertionError, match="orphaned trace"):
|
||||
assert_complete_trace(CallTraces((trace,), ()), route="/v1/messages", genai_span=GENAI_SPAN)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ttft", (0.0, 1.0))
|
||||
def test_server_name_tolerance_does_not_accept_invalid_first_token_timing(ttft: float) -> None:
|
||||
trace: Final = _request_trace(ttft=ttft)
|
||||
with pytest.raises(AssertionError, match="TTFT"):
|
||||
assert_real_ttft((trace,), genai_span=GENAI_SPAN)
|
||||
|
|
|
|||
|
|
@ -5,5 +5,8 @@
|
|||
addopts = --strict-markers --strict-config -p no:cacheprovider
|
||||
pythonpath = ../e2e ../e2e/batches ../e2e/guardrails ../e2e/logging ../e2e/perf
|
||||
markers =
|
||||
e2e: live assertion modules imported by harness regression tests
|
||||
meta(subject): metadata on live assertion modules imported by harness regression tests
|
||||
otel_tls: TLS coverage in live assertion modules imported by harness regression tests
|
||||
covers(cell_id, *, exercised_on=()): coverage-registry cell(s) a test covers; exercised here only to prove the collector and the JUnit properties read it
|
||||
cli_determinism: drives the real claude CLI for several seconds
|
||||
|
|
|
|||
|
|
@ -25,9 +25,16 @@ _GEO: Final = f"au.{_ROW}"
|
|||
_FINETUNE: Final = "ft:gpt-audit-reload-2025-01-01"
|
||||
_CLAUDE: Final = "claude-audit-reload-sonnet"
|
||||
_MINI: Final = "gpt-audit-reload-mini"
|
||||
_FREE: Final = "gpt-audit-reload-free"
|
||||
_RULE_FREE: Final = "gpt-audit-rule-free"
|
||||
_FILL_FREE: Final = "gpt-audit-fill-free"
|
||||
_RULE_UNPRICED: Final = "gpt-audit-rule-unpriced"
|
||||
_REPORTED_FREE: Final = "gpt-audit-reported-free"
|
||||
_USAGE: Final = re.compile(r"bill (\d+) (\d+) (\d+)")
|
||||
_CUSTOM: Final = MappingProxyType({"input_cost_per_token": 1.1e-06, "output_cost_per_token": 2.3e-06})
|
||||
_COST_MAP_LIMITS: Final = MappingProxyType({"MODEL_COST_MAP_MIN_MODEL_COUNT": "1", "MODEL_COST_MAP_MAX_SHRINK_RATIO": "0"})
|
||||
_COST_MAP_LIMITS: Final = MappingProxyType(
|
||||
{"MODEL_COST_MAP_MIN_MODEL_COUNT": "1", "MODEL_COST_MAP_MAX_SHRINK_RATIO": "0"}
|
||||
)
|
||||
_PUBLISHED_SCALE: Final = 2.0
|
||||
_CATALOG: Final[Mapping[str, Mapping[str, JsonValue]]] = MappingProxyType(
|
||||
{
|
||||
|
|
@ -60,6 +67,13 @@ _CATALOG: Final[Mapping[str, Mapping[str, JsonValue]]] = MappingProxyType(
|
|||
"output_cost_per_token": 1.5e-05,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
},
|
||||
_FILL_FREE: {"litellm_provider": "openai", "mode": "chat"},
|
||||
_FREE: {
|
||||
"litellm_provider": "openai",
|
||||
"mode": "chat",
|
||||
"input_cost_per_token": 0.0,
|
||||
"output_cost_per_token": 0.0,
|
||||
},
|
||||
_MINI: {
|
||||
"litellm_provider": "openai",
|
||||
"mode": "chat",
|
||||
|
|
@ -99,6 +113,8 @@ class _Deployment:
|
|||
prices: Mapping[str, float]
|
||||
usage: _Usage
|
||||
rates: Callable[[float], _Rates | None]
|
||||
reported_cost: float | None = None
|
||||
listed_rates: Callable[[float], _Rates | None] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -107,10 +123,23 @@ class _Charge:
|
|||
expected: float
|
||||
|
||||
|
||||
def _catalog(scale: float) -> dict[str, dict[str, JsonValue]]:
|
||||
def _catalog(scale: float) -> dict[str, JsonValue]:
|
||||
return {
|
||||
model: {name: value * scale if isinstance(value, float) else value for name, value in row.items()}
|
||||
for model, row in _CATALOG.items()
|
||||
**{
|
||||
model: {name: value * scale if isinstance(value, float) else value for name, value in row.items()}
|
||||
for model, row in _CATALOG.items()
|
||||
},
|
||||
"fallback_generalizations": {
|
||||
"rules": [
|
||||
{
|
||||
"name": "free-pricing",
|
||||
"pattern": f"({_RULE_FREE}|{_FILL_FREE})",
|
||||
"fill_missing_for_providers": ["openai"],
|
||||
"model_info": {"mode": "chat", "input_cost_per_token": 0.0, "output_cost_per_token": 0.0},
|
||||
},
|
||||
{"name": "unpriced-capabilities", "pattern": _RULE_UNPRICED, "model_info": {"mode": "chat"}},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -240,6 +269,7 @@ def _reply(request: Request) -> Reply:
|
|||
"completion_tokens": usage.completion,
|
||||
"total_tokens": usage.prompt + usage.completion,
|
||||
"prompt_tokens_details": {"cached_tokens": usage.cached},
|
||||
**({"cost": 0.0} if object_value(json.loads(request.body))["model"] == _REPORTED_FREE else {}),
|
||||
},
|
||||
}
|
||||
).encode()
|
||||
|
|
@ -281,10 +311,16 @@ def _billed(client: httpx.Client, key: str, deployment: _Deployment) -> tuple[st
|
|||
|
||||
def _charged_as_listed(proxy: Gateway, deployment: _Deployment, scale: float) -> _Charge:
|
||||
rates: Final = deployment.rates(scale)
|
||||
assert _listed_rates(proxy, deployment.name) == _expected_listing(rates), deployment.name
|
||||
assert _listed_rates(proxy, deployment.name) == _expected_listing(
|
||||
deployment.listed_rates(scale) if deployment.listed_rates is not None else rates
|
||||
), deployment.name
|
||||
request_id, charged = _billed(proxy.client, proxy.key, deployment)
|
||||
expected: Final = _expected_cost(rates, deployment.usage)
|
||||
assert charged == pytest.approx(expected, rel=1e-6), deployment.name
|
||||
expected: Final = (
|
||||
deployment.reported_cost if deployment.reported_cost is not None else _expected_cost(rates, deployment.usage)
|
||||
)
|
||||
assert charged == (
|
||||
None if rates is None and deployment.reported_cost is None else pytest.approx(expected, rel=1e-6)
|
||||
), deployment.name
|
||||
return _Charge(request_id=request_id, expected=expected)
|
||||
|
||||
|
||||
|
|
@ -352,8 +388,31 @@ def test_price_data_reload_reprices_catalog_deployments_and_keeps_custom_prices(
|
|||
_Usage(2000, 1000, 1000),
|
||||
_catalog_rates(_FINETUNE),
|
||||
),
|
||||
_Deployment("finetune-custom", f"openai/{_FINETUNE}:org:suffix:z", _CUSTOM, _Usage(2000, 0, 1000), _custom_rates),
|
||||
_Deployment(
|
||||
"finetune-custom", f"openai/{_FINETUNE}:org:suffix:z", _CUSTOM, _Usage(2000, 0, 1000), _custom_rates
|
||||
),
|
||||
_Deployment("cross-provider-unpriced", f"azure/{_CLAUDE}", {}, _Usage(2000, 1000, 1000), _unpriced),
|
||||
_Deployment("catalog-free", f"openai/{_FREE}", {}, _Usage(2000, 1000, 1000), _catalog_rates(_FREE)),
|
||||
_Deployment("rule-free", f"openai/{_RULE_FREE}", {}, _Usage(2000, 1000, 1000), _catalog_rates(_FREE)),
|
||||
_Deployment(
|
||||
"fill-rule-free",
|
||||
f"openai/{_FILL_FREE}",
|
||||
{},
|
||||
_Usage(2000, 1000, 1000),
|
||||
_catalog_rates(_FREE),
|
||||
listed_rates=_unpriced,
|
||||
),
|
||||
_Deployment("rule-unpriced", f"openai/{_RULE_UNPRICED}", {}, _Usage(2000, 1000, 1000), _unpriced),
|
||||
_Deployment(
|
||||
"provider-reported-free", f"openrouter/{_REPORTED_FREE}", {}, _Usage(2000, 1000, 1000), _unpriced, 0.0
|
||||
),
|
||||
_Deployment(
|
||||
"cross-provider-free",
|
||||
f"azure/{_CLAUDE}",
|
||||
{"input_cost_per_token": 0.0, "output_cost_per_token": 0.0},
|
||||
_Usage(2000, 1000, 1000),
|
||||
_catalog_rates(_FREE),
|
||||
),
|
||||
_Deployment(
|
||||
"region-custom-above-threshold",
|
||||
f"bedrock/ap-northeast-1.{_ROW}",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
|||
schema_has_unresolvable_ref,
|
||||
schema_property_count,
|
||||
split_concatenated_json_objects,
|
||||
split_embedding_media_for_tpm,
|
||||
split_media_for_tpm,
|
||||
strip_encrypted_reasoning_from_messages,
|
||||
strip_litellm_response_only_fields,
|
||||
|
|
@ -2709,3 +2710,22 @@ def test_content_list_flattening_returns_new_messages_and_leaves_the_callers_unt
|
|||
|
||||
assert flattened == [{"role": "user", "content": "Hello world"}, {"role": "assistant", "content": "Hi"}]
|
||||
assert messages == messages_before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mime", ["application/pdf", "video/mp4", "audio/wav", "image/png"])
|
||||
def test_embedding_media_split_holds_at_most_one_copy_of_a_large_payload(mime: str) -> None:
|
||||
import gc
|
||||
import tracemalloc
|
||||
|
||||
uri: Final = f"data:{mime};base64," + base64.b64encode(bytes(3 * 1024 * 1024)).decode("ascii")
|
||||
split_embedding_media_for_tpm(f"data:{mime};base64,AAAA")
|
||||
gc.collect()
|
||||
tracemalloc.start()
|
||||
try:
|
||||
_, media = split_embedding_media_for_tpm(uri)
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
|
||||
assert media
|
||||
assert peak < 1.5 * len(uri)
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ def test_bedrock_embedding_with_api_key_bearer_token(model, input_type, embed_re
|
|||
with patch.object(client, "post") as mock_post:
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = httpx.Headers()
|
||||
mock_response.text = json.dumps(embed_response)
|
||||
mock_response.json = lambda: json.loads(mock_response.text)
|
||||
mock_post.return_value = mock_response
|
||||
|
|
@ -774,6 +775,7 @@ def test_bedrock_embedding_header_forwarding(model, embed_response):
|
|||
with patch.object(client, "post") as mock_post:
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = httpx.Headers()
|
||||
mock_response.text = json.dumps(embed_response)
|
||||
mock_response.json = lambda: json.loads(mock_response.text)
|
||||
mock_post.return_value = mock_response
|
||||
|
|
@ -909,6 +911,7 @@ def test_bedrock_cohere_v4_embedding_response_parsing():
|
|||
with patch.object(client, "post") as mock_post:
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = httpx.Headers()
|
||||
mock_response.text = json.dumps(cohere_v4_response)
|
||||
mock_response.json = lambda: json.loads(mock_response.text)
|
||||
mock_post.return_value = mock_response
|
||||
|
|
@ -1317,6 +1320,7 @@ def test_bedrock_cohere_embedding_types_wrapped_as_list(
|
|||
with patch.object(client, "post") as mock_post:
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = httpx.Headers()
|
||||
mock_response.text = json.dumps(cohere_embedding_response)
|
||||
mock_response.json = lambda: json.loads(mock_response.text)
|
||||
mock_post.return_value = mock_response
|
||||
|
|
@ -1848,6 +1852,7 @@ def test_bedrock_embedding_models(model, input_type, embed_response):
|
|||
with patch.object(client, "post") as mock_post:
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = httpx.Headers()
|
||||
mock_response.text = json.dumps(embed_response)
|
||||
mock_response.json = lambda: json.loads(mock_response.text)
|
||||
mock_post.return_value = mock_response
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from litellm.llms.cohere.embed.v1_transformation import CohereEmbeddingConfig
|
||||
from litellm.types.utils import EmbeddingResponse
|
||||
|
|
@ -183,6 +185,7 @@ class TestCohereEmbeddingV1Transform:
|
|||
"meta": {}, # No billed_units
|
||||
}
|
||||
mock_response.json = MagicMock(return_value=response_json)
|
||||
mock_response.headers = httpx.Headers()
|
||||
|
||||
input_data = ["test text"]
|
||||
data = {"texts": input_data, "input_type": "search_query"}
|
||||
|
|
@ -208,3 +211,31 @@ class TestCohereEmbeddingV1Transform:
|
|||
|
||||
# Verify encoding was called
|
||||
self.encoding.encode.assert_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body_key", "item", "expected_tokens"),
|
||||
[("images", "data:image/png;base64," + "iVBORw0KGgo" * 400, 2047), ("texts", "a short sentence", 5)],
|
||||
)
|
||||
def test_transform_response_counts_bedrock_billed_tokens_only_for_images(
|
||||
self, body_key: str, item: str, expected_tokens: int
|
||||
):
|
||||
response = httpx.Response(
|
||||
200,
|
||||
json={"id": "e1", body_key: [{}], "embeddings": [[0.1, 0.2]], "response_type": "embeddings_floats"},
|
||||
headers={"x-amzn-bedrock-input-token-count": "2047"},
|
||||
)
|
||||
|
||||
result = self.config.transform_response(
|
||||
response=response,
|
||||
api_key=None,
|
||||
logging_obj=self.logging_obj,
|
||||
data={body_key: [item], "input_type": "image" if body_key == "images" else "search_document"},
|
||||
model_response=EmbeddingResponse(),
|
||||
model="cohere.embed-v4:0",
|
||||
encoding=self.encoding,
|
||||
input=[item],
|
||||
)
|
||||
|
||||
assert result.usage is not None
|
||||
assert result.usage.prompt_tokens == expected_tokens
|
||||
assert result.usage.total_tokens == expected_tokens
|
||||
|
|
|
|||
|
|
@ -953,6 +953,21 @@ def test_is_lock_timeout_error_excludes_other_failures(error: Exception) -> None
|
|||
assert PrismaDBExceptionHandler.is_lock_timeout_error(error) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "expected"),
|
||||
[
|
||||
(_lock_timeout_error(message="canceling statement due to statement timeout", code="57014"), True),
|
||||
(_lock_timeout_error(message="canceling statement due to statement timeout", code=None), True),
|
||||
(_lock_timeout_error(), False),
|
||||
(_capacity_error("sorry, too many clients already"), False),
|
||||
(RuntimeError("canceling statement due to statement timeout"), False),
|
||||
],
|
||||
ids=["meta_57014", "message_only", "lock_timeout_55P03", "capacity_53300", "not_prisma"],
|
||||
)
|
||||
def test_is_statement_timeout_error(error: Exception, expected: bool) -> None:
|
||||
assert PrismaDBExceptionHandler.is_statement_timeout_error(error) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "retryable"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import copy
|
|||
import io
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Dict, Final
|
||||
|
||||
import pytest
|
||||
|
|
@ -27,7 +28,7 @@ import soundfile as sf
|
|||
from fastapi import HTTPException
|
||||
from PIL import Image
|
||||
|
||||
from litellm import token_counter
|
||||
from litellm import get_model_info, token_counter
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.litellm_core_utils.ptu_shares import PTUTeamCeiling
|
||||
from litellm.litellm_core_utils.token_counter import UnsupportedContentTypeError, estimate_image_tokens
|
||||
|
|
@ -3971,6 +3972,334 @@ async def test_media_pre_call_rejects_tight_quota_and_reserves_sufficient_quota(
|
|||
assert expected[0] > 1000 and expected[1] >= expected[0]
|
||||
|
||||
|
||||
_EMBEDDING_MODEL: Final = "gemini-embedding-2"
|
||||
_EMBEDDING_CAPTION: Final = "a red bicycle leaning on a wall"
|
||||
|
||||
|
||||
def _embedding_media_uri(kind: str, compressed: bool) -> str:
|
||||
mime: Final = "image/png" if kind == "image" else "audio/wav"
|
||||
return f"data:{mime};base64,{_quota_media_payload(kind, compressed)}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["image", "audio"])
|
||||
@pytest.mark.parametrize(
|
||||
"shape",
|
||||
["string", "list", "file_block", "with_caption", "combined"],
|
||||
)
|
||||
def test_embedding_media_input_is_estimated_independently_of_encoding(
|
||||
rate_limiter: tuple[RateLimitHandler, DualCache], kind: str, shape: str
|
||||
) -> None:
|
||||
handler, _ = rate_limiter
|
||||
|
||||
def embedding_request(uri: str) -> dict[str, object]:
|
||||
shapes: Final[dict[str, object]] = {
|
||||
"string": uri,
|
||||
"list": [uri],
|
||||
"file_block": [{"type": "file", "file": {"file_data": uri}}],
|
||||
"with_caption": [_EMBEDDING_CAPTION, uri],
|
||||
"combined": [[_EMBEDDING_CAPTION, uri]],
|
||||
}
|
||||
return {"model": _EMBEDDING_MODEL, "input": shapes[shape]}
|
||||
|
||||
uncompressed: Final = embedding_request(_embedding_media_uri(kind, False))
|
||||
original: Final = copy.deepcopy(uncompressed)
|
||||
estimate: Final = handler._estimate_input_and_output_tokens(uncompressed, call_type="aembedding")
|
||||
assert estimate == handler._estimate_input_and_output_tokens(
|
||||
embedding_request(_embedding_media_uri(kind, True)), call_type="aembedding"
|
||||
)
|
||||
assert 0 < estimate[0] < 2_000 and estimate[1] == 0
|
||||
assert uncompressed == original
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["image", "audio"])
|
||||
def test_embedding_media_input_costs_the_same_as_chat_media(
|
||||
rate_limiter: tuple[RateLimitHandler, DualCache], kind: str
|
||||
) -> None:
|
||||
handler, _ = rate_limiter
|
||||
uri: Final = _embedding_media_uri(kind, False)
|
||||
chat_block: Final = (
|
||||
{"type": "image_url", "image_url": {"url": uri}}
|
||||
if kind == "image"
|
||||
else {"type": "input_audio", "input_audio": {"data": _quota_media_payload(kind, False), "format": "wav"}}
|
||||
)
|
||||
chat_input_tokens, _ = handler._estimate_input_and_output_tokens(
|
||||
{"model": _EMBEDDING_MODEL, "messages": [{"role": "user", "content": [chat_block]}]},
|
||||
call_type="acompletion",
|
||||
)
|
||||
caption_tokens, _ = handler._estimate_input_and_output_tokens(
|
||||
{"model": _EMBEDDING_MODEL, "input": [_EMBEDDING_CAPTION]}, call_type="aembedding"
|
||||
)
|
||||
|
||||
assert handler._estimate_input_and_output_tokens(
|
||||
{"model": _EMBEDDING_MODEL, "input": uri}, call_type="aembedding"
|
||||
) == (chat_input_tokens, 0)
|
||||
assert handler._estimate_input_and_output_tokens(
|
||||
{"model": _EMBEDDING_MODEL, "input": [_EMBEDDING_CAPTION, uri]}, call_type="aembedding"
|
||||
) == (caption_tokens + chat_input_tokens, 0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("limit_field", ["model_tpm_limit", "model_itpm_limit"])
|
||||
async def test_project_token_limit_admits_image_embedding_and_still_rejects_long_text(
|
||||
rate_limiter: tuple[RateLimitHandler, DualCache], limit_field: str
|
||||
) -> None:
|
||||
handler, cache = rate_limiter
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key=hash_token(f"sk-embedding-image-{limit_field}"),
|
||||
project_id=f"proj-embedding-image-{limit_field}",
|
||||
project_metadata={limit_field: {_EMBEDDING_MODEL: 75_000}},
|
||||
)
|
||||
image_uri: Final = _embedding_media_uri("image", False)
|
||||
long_text: Final = "word " * 80_000
|
||||
|
||||
await handler.async_pre_call_hook(
|
||||
user_api_key_dict=auth,
|
||||
cache=cache,
|
||||
data={"model": _EMBEDDING_MODEL, "input": image_uri},
|
||||
call_type="aembedding",
|
||||
)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await handler.async_pre_call_hook(
|
||||
user_api_key_dict=auth,
|
||||
cache=cache,
|
||||
data={"model": _EMBEDDING_MODEL, "input": long_text},
|
||||
call_type="aembedding",
|
||||
)
|
||||
assert error.value.status_code == 429
|
||||
assert len(image_uri) > len(long_text)
|
||||
|
||||
|
||||
_EMBEDDING_FILE_MIMES: Final = MappingProxyType({"pdf": "application/pdf", "video": "video/mp4"})
|
||||
|
||||
|
||||
def _embedding_file_uri(kind: str) -> str:
|
||||
return f"data:{_EMBEDDING_FILE_MIMES[kind]};base64,{base64.b64encode(bytes(range(256)) * 1200).decode()}"
|
||||
|
||||
|
||||
def _embedding_uri(kind: str) -> str:
|
||||
return _embedding_file_uri(kind) if kind in _EMBEDDING_FILE_MIMES else _embedding_media_uri(kind, False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("limit_field", ["model_tpm_limit", "model_itpm_limit"])
|
||||
@pytest.mark.parametrize("kind", ["pdf", "video"])
|
||||
@pytest.mark.parametrize("shape", ["data_uri", "file_block"])
|
||||
async def test_project_token_limit_admits_document_embedding_up_to_the_model_input_limit(
|
||||
rate_limiter: tuple[RateLimitHandler, DualCache], limit_field: str, kind: str, shape: str
|
||||
) -> None:
|
||||
handler, cache = rate_limiter
|
||||
uri: Final = _embedding_file_uri(kind)
|
||||
model_input_limit: Final = get_model_info(_EMBEDDING_MODEL)["max_input_tokens"]
|
||||
assert isinstance(model_input_limit, int) and len(uri) // 4 > 75_000 > model_input_limit
|
||||
|
||||
def project_auth(limit: int) -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(
|
||||
api_key=hash_token(f"sk-embedding-{kind}-{shape}-{limit_field}-{limit}"),
|
||||
project_id=f"proj-embedding-{kind}-{shape}-{limit_field}-{limit}",
|
||||
project_metadata={limit_field: {_EMBEDDING_MODEL: limit}},
|
||||
)
|
||||
|
||||
item: Final = uri if shape == "data_uri" else {"type": "file", "file": {"file_data": uri}}
|
||||
request: Final = {"model": _EMBEDDING_MODEL, "input": [_EMBEDDING_CAPTION, item]}
|
||||
await handler.async_pre_call_hook(
|
||||
user_api_key_dict=project_auth(75_000), cache=cache, data=dict(request), call_type="aembedding"
|
||||
)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await handler.async_pre_call_hook(
|
||||
user_api_key_dict=project_auth(model_input_limit // 2),
|
||||
cache=cache,
|
||||
data=dict(request),
|
||||
call_type="aembedding",
|
||||
)
|
||||
assert error.value.status_code == 429
|
||||
|
||||
|
||||
_TEXT_ONLY_EMBEDDING_MODEL: Final = "text-embedding-3-small"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["image", "audio", "pdf", "video"])
|
||||
def test_text_only_embedding_model_counts_data_uri_as_text(
|
||||
rate_limiter: tuple[RateLimitHandler, DualCache], kind: str
|
||||
) -> None:
|
||||
handler, _ = rate_limiter
|
||||
uri: Final = _embedding_uri(kind)
|
||||
|
||||
assert handler._estimate_input_and_output_tokens(
|
||||
{"model": _TEXT_ONLY_EMBEDDING_MODEL, "input": [_EMBEDDING_CAPTION, uri]}, call_type="aembedding"
|
||||
) == handler._estimate_input_and_output_tokens(
|
||||
{"model": _TEXT_ONLY_EMBEDDING_MODEL, "input": [_EMBEDDING_CAPTION, "x" * len(uri)]},
|
||||
call_type="aembedding",
|
||||
)
|
||||
|
||||
|
||||
def _project_limited_embedding_call(
|
||||
limit_field: str, backends: tuple[str, ...], kind: str = "image"
|
||||
) -> tuple[RateLimitHandler, DualCache, UserAPIKeyAuth, dict[str, object]]:
|
||||
cache: Final = DualCache()
|
||||
handler: Final = RateLimitHandler(
|
||||
internal_usage_cache=InternalUsageCache(cache), media_model_resolver=lambda _model, _team_id: backends
|
||||
)
|
||||
scope: Final = f"{limit_field}-{kind}-{'-'.join(backends)}"
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key=hash_token(f"sk-embedding-backends-{scope}"),
|
||||
project_id=f"proj-embedding-backends-{scope}",
|
||||
project_metadata={limit_field: {"team-embedder": 75_000}},
|
||||
)
|
||||
return handler, cache, auth, {"model": "team-embedder", "input": _embedding_uri(kind)}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("limit_field", ["model_tpm_limit", "model_itpm_limit"])
|
||||
@pytest.mark.parametrize(
|
||||
("backends", "kind"),
|
||||
[
|
||||
((_TEXT_ONLY_EMBEDDING_MODEL,), "image"),
|
||||
((_EMBEDDING_MODEL, _TEXT_ONLY_EMBEDDING_MODEL), "image"),
|
||||
(("voyage/voyage-multimodal-3.5",), "image"),
|
||||
(("oci/cohere.embed-v4.0",), "image"),
|
||||
(("bedrock/us.twelvelabs.marengo-embed-2-7-v1:0",), "image"),
|
||||
(("bedrock/twelvelabs.marengo-embed-3-0-v1:0",), "image"),
|
||||
(("bedrock/amazon.nova-2-multimodal-embeddings-v1:0",), "pdf"),
|
||||
],
|
||||
ids=["text", "mixed", "voyage", "oci", "marengo_2_7", "marengo_3", "nova_pdf"],
|
||||
)
|
||||
async def test_project_token_limit_rejects_embedding_media_a_backend_reads_as_text(
|
||||
limit_field: str, backends: tuple[str, ...], kind: str
|
||||
) -> None:
|
||||
handler, cache, auth, request = _project_limited_embedding_call(limit_field, backends, kind)
|
||||
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await handler.async_pre_call_hook(user_api_key_dict=auth, cache=cache, data=request, call_type="aembedding")
|
||||
assert error.value.status_code == 429
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("limit_field", ["model_tpm_limit", "model_itpm_limit"])
|
||||
@pytest.mark.parametrize(
|
||||
"backend",
|
||||
[
|
||||
_EMBEDDING_MODEL,
|
||||
"bedrock/amazon.titan-embed-image-v1",
|
||||
"bedrock/async_invoke/amazon.nova-2-multimodal-embeddings-v1:0",
|
||||
],
|
||||
ids=["gemini", "titan", "nova_async_invoke"],
|
||||
)
|
||||
async def test_project_token_limit_admits_image_embedding_through_an_alias_of_an_image_backend(
|
||||
limit_field: str, backend: str
|
||||
) -> None:
|
||||
handler, cache, auth, request = _project_limited_embedding_call(limit_field, (backend,))
|
||||
|
||||
await handler.async_pre_call_hook(user_api_key_dict=auth, cache=cache, data=request, call_type="aembedding")
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await handler.async_pre_call_hook(
|
||||
user_api_key_dict=auth,
|
||||
cache=cache,
|
||||
data={**request, "input": "word " * 100_000},
|
||||
call_type="aembedding",
|
||||
)
|
||||
assert error.value.status_code == 429
|
||||
|
||||
|
||||
def _malformed_embedding_uri(shape: str) -> str:
|
||||
payload: Final = _quota_media_payload("image", False)
|
||||
whole_quanta: Final = payload.rstrip("=")[: len(payload.rstrip("=")) // 4 * 4]
|
||||
words: Final = "word " * 100_000
|
||||
return {
|
||||
"no_comma": f"data:audio/wav;base64{words}",
|
||||
"text_payload": f"data:image/png;base64,{words}",
|
||||
"no_base64_marker": f"data:image/png,{payload}",
|
||||
"header_past_256_chars": f"data:image/png;{'x' * 300};base64,{payload}",
|
||||
"noncanonical_padding": f"data:image/png;base64,{whole_quanta}AB==",
|
||||
"padding_mid_payload": f"data:image/png;base64,{whole_quanta}AA==AAAA",
|
||||
"length_not_multiple_of_4": f"data:image/png;base64,{whole_quanta}A",
|
||||
}[shape]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"shape",
|
||||
[
|
||||
"no_comma",
|
||||
"text_payload",
|
||||
"no_base64_marker",
|
||||
"header_past_256_chars",
|
||||
"noncanonical_padding",
|
||||
"padding_mid_payload",
|
||||
"length_not_multiple_of_4",
|
||||
],
|
||||
)
|
||||
async def test_project_token_limit_counts_a_malformed_data_uri_as_text(
|
||||
rate_limiter: tuple[RateLimitHandler, DualCache], shape: str
|
||||
) -> None:
|
||||
handler, cache = rate_limiter
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key=hash_token(f"sk-embedding-malformed-{shape}"),
|
||||
project_id=f"proj-embedding-malformed-{shape}",
|
||||
project_metadata={"model_tpm_limit": {_EMBEDDING_MODEL: 75_000}},
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await handler.async_pre_call_hook(
|
||||
user_api_key_dict=auth,
|
||||
cache=cache,
|
||||
data={"model": _EMBEDDING_MODEL, "input": _malformed_embedding_uri(shape)},
|
||||
call_type="aembedding",
|
||||
)
|
||||
assert error.value.status_code == 429
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("backend", ["cohere/embed-v4.0", "cohere.embed-v4:0"], ids=["cohere", "bedrock_cohere"])
|
||||
async def test_project_token_limit_counts_a_mixed_batch_as_text_when_the_backend_types_the_whole_batch(
|
||||
backend: str,
|
||||
) -> None:
|
||||
handler, cache, auth, request = _project_limited_embedding_call("model_tpm_limit", (backend,))
|
||||
image_uri: Final = request["input"]
|
||||
|
||||
await handler.async_pre_call_hook(
|
||||
user_api_key_dict=auth, cache=cache, data={**request, "input": [image_uri, image_uri]}, call_type="aembedding"
|
||||
)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await handler.async_pre_call_hook(
|
||||
user_api_key_dict=auth,
|
||||
cache=cache,
|
||||
data={**request, "input": [image_uri, _EMBEDDING_CAPTION]},
|
||||
call_type="aembedding",
|
||||
)
|
||||
assert error.value.status_code == 429
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_itpm_limit_reserves_media_tokens_for_an_image_embedding(
|
||||
rate_limiter: tuple[RateLimitHandler, DualCache],
|
||||
) -> None:
|
||||
handler, cache = rate_limiter
|
||||
image_uri: Final = _embedding_media_uri("image", False)
|
||||
image_tokens, _ = handler._estimate_input_and_output_tokens(
|
||||
{"model": _EMBEDDING_MODEL, "input": image_uri}, call_type="aembedding"
|
||||
)
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key=hash_token("sk-embedding-image-itpm-media"),
|
||||
project_id="proj-embedding-image-itpm-media",
|
||||
project_metadata={"model_itpm_limit": {_EMBEDDING_MODEL: image_tokens // 2}},
|
||||
)
|
||||
|
||||
await handler.async_pre_call_hook(
|
||||
user_api_key_dict=auth,
|
||||
cache=cache,
|
||||
data={"model": _EMBEDDING_MODEL, "input": _EMBEDDING_CAPTION},
|
||||
call_type="aembedding",
|
||||
)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await handler.async_pre_call_hook(
|
||||
user_api_key_dict=auth,
|
||||
cache=cache,
|
||||
data={"model": _EMBEDDING_MODEL, "input": image_uri},
|
||||
call_type="aembedding",
|
||||
)
|
||||
assert error.value.status_code == 429
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ptu_pre_call_weights_media_input_and_output() -> None:
|
||||
cache: Final = DualCache()
|
||||
|
|
|
|||
|
|
@ -3352,6 +3352,8 @@ async def test_user_info_v2_internal_user_cannot_query_other(mocker):
|
|||
|
||||
mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(side_effect=mock_find_unique)
|
||||
|
||||
mock_prisma_client.db.litellm_organizationmembership.find_many = mocker.AsyncMock(return_value=[])
|
||||
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
mock_request = mocker.MagicMock(spec=Request)
|
||||
|
|
@ -3456,6 +3458,40 @@ async def test_user_info_v2_nonexistent_user_returns_404(mocker):
|
|||
assert "nonexistent-user-id" in str(exc_info.value.message)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("find_unique", "expected_code", "logs_traceback"),
|
||||
[
|
||||
(AsyncMock(return_value=None), "404", False),
|
||||
(AsyncMock(side_effect=RuntimeError("db down")), "500", True),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_v2_logs_traceback_only_for_server_errors(
|
||||
mocker: MockerFixture,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
find_unique: AsyncMock,
|
||||
expected_code: str,
|
||||
logs_traceback: bool,
|
||||
) -> None:
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2
|
||||
|
||||
prisma: Final = mocker.MagicMock()
|
||||
prisma.db.litellm_usertable.find_unique = find_unique
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", prisma)
|
||||
|
||||
with caplog.at_level(logging.ERROR), pytest.raises(ProxyException) as exc_info:
|
||||
await user_info_v2(
|
||||
request=mocker.MagicMock(spec=Request),
|
||||
user_id="some-user",
|
||||
user_api_key_dict=UserAPIKeyAuth(user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN),
|
||||
)
|
||||
|
||||
assert exc_info.value.code == expected_code
|
||||
assert any(record.exc_info for record in caplog.records) is logs_traceback
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_v2_response_shape(mocker):
|
||||
"""
|
||||
|
|
@ -3674,6 +3710,8 @@ async def test_user_info_v2_team_admin_cannot_query_non_team_member(mocker):
|
|||
|
||||
mock_prisma_client.db.litellm_teamtable.find_many = mocker.AsyncMock(side_effect=mock_find_many_teams)
|
||||
|
||||
mock_prisma_client.db.litellm_organizationmembership.find_many = mocker.AsyncMock(return_value=[])
|
||||
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
mock_request = mocker.MagicMock(spec=Request)
|
||||
|
|
@ -3690,6 +3728,80 @@ async def test_user_info_v2_team_admin_cannot_query_non_team_member(mocker):
|
|||
assert exc_info.value.code == "404"
|
||||
|
||||
|
||||
_ORG_MEMBERSHIPS: Final = (
|
||||
SimpleNamespace(user_id="org-admin-user", organization_id="org-a", user_role="org_admin"),
|
||||
SimpleNamespace(user_id="org-admin-user", organization_id="org-c", user_role="internal_user"),
|
||||
SimpleNamespace(user_id="member-of-a", organization_id="org-a", user_role="internal_user"),
|
||||
SimpleNamespace(user_id="member-of-b", organization_id="org-b", user_role="internal_user"),
|
||||
SimpleNamespace(user_id="member-of-c", organization_id="org-c", user_role="internal_user"),
|
||||
)
|
||||
|
||||
|
||||
def _membership_matches(membership: SimpleNamespace, where: Mapping[str, object]) -> bool:
|
||||
return membership.user_id == where["user_id"] and (
|
||||
"user_role" not in where or membership.user_role == where["user_role"]
|
||||
)
|
||||
|
||||
|
||||
def _org_admin_prisma(mocker: MockerFixture) -> MagicMock:
|
||||
def user_row(user_id: str) -> MagicMock:
|
||||
row: Final = mocker.MagicMock()
|
||||
row.teams = []
|
||||
row.model_dump.return_value = {"user_id": user_id, "user_email": f"{user_id}@example.com", "teams": []}
|
||||
return row
|
||||
|
||||
async def find_unique(**kwargs: dict[str, str]) -> MagicMock | None:
|
||||
user_id: Final = kwargs["where"]["user_id"]
|
||||
return user_row(user_id) if user_id in {m.user_id for m in _ORG_MEMBERSHIPS} else None
|
||||
|
||||
async def find_many(**kwargs: Mapping[str, object]) -> list[SimpleNamespace]:
|
||||
return [m for m in _ORG_MEMBERSHIPS if _membership_matches(m, kwargs["where"])]
|
||||
|
||||
prisma: Final = mocker.MagicMock()
|
||||
prisma.db.litellm_usertable.find_unique = mocker.AsyncMock(side_effect=find_unique)
|
||||
prisma.db.litellm_organizationmembership.find_many = mocker.AsyncMock(side_effect=find_many)
|
||||
return prisma
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_v2_org_admin_can_query_member_of_their_org(mocker: MockerFixture) -> None:
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2
|
||||
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", _org_admin_prisma(mocker))
|
||||
|
||||
response: Final = await user_info_v2(
|
||||
request=mocker.MagicMock(spec=Request),
|
||||
user_id="member-of-a",
|
||||
user_api_key_dict=UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER),
|
||||
)
|
||||
|
||||
assert response.user_id == "member-of-a"
|
||||
assert response.user_email == "member-of-a@example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target_user_id", ["member-of-b", "member-of-c"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_v2_org_admin_cannot_query_user_outside_their_admin_orgs(
|
||||
mocker: MockerFixture, target_user_id: str
|
||||
) -> None:
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2
|
||||
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", _org_admin_prisma(mocker))
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await user_info_v2(
|
||||
request=mocker.MagicMock(spec=Request),
|
||||
user_id=target_user_id,
|
||||
user_api_key_dict=UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER),
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "404"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_v2_url_encoding_plus_character(mocker):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import litellm
|
|||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.testclient import TestClient
|
||||
from prisma.errors import DataError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from litellm._uuid import uuid
|
||||
|
|
@ -17278,6 +17279,28 @@ def _team_spend_by_user_db_row(team_id: str, user_id: str, spend: float, request
|
|||
}
|
||||
|
||||
|
||||
class _RecordingTransaction:
|
||||
def __init__(self, query_raw: AsyncMock) -> None:
|
||||
self.statements: tuple[str, ...] = ()
|
||||
self.query_raw: AsyncMock = query_raw
|
||||
|
||||
async def execute_raw(self, sql: str) -> int:
|
||||
self.statements = (*self.statements, sql)
|
||||
return 0
|
||||
|
||||
async def __aenter__(self) -> "_RecordingTransaction":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _team_spend_by_user_query(mock_db_client: MagicMock, query_raw: AsyncMock) -> _RecordingTransaction:
|
||||
transaction: Final = _RecordingTransaction(query_raw)
|
||||
mock_db_client.read_db.tx = MagicMock(return_value=transaction)
|
||||
return transaction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_spend_by_user_admin_groups_spend_logs_by_team_and_user(mock_db_client):
|
||||
from litellm.proxy.management_endpoints.team_endpoints import get_team_spend_by_user
|
||||
|
|
@ -17286,12 +17309,15 @@ async def test_get_team_spend_by_user_admin_groups_spend_logs_by_team_and_user(m
|
|||
alpha = _team_spend_by_user_team("team-alpha", "Team Alpha", Member(user_id="alice", role="admin"), [])
|
||||
beta = _team_spend_by_user_team("team-beta", "Team Beta", Member(user_id="alice", role="user"), [])
|
||||
mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[alpha, beta])
|
||||
mock_db_client.db.query_raw = AsyncMock(
|
||||
return_value=[
|
||||
_team_spend_by_user_db_row("team-alpha", "alice", 0.5, 3),
|
||||
_team_spend_by_user_db_row("team-alpha", "bob", 0.25, 2),
|
||||
_team_spend_by_user_db_row("team-beta", "alice", 0.1, 1),
|
||||
]
|
||||
transaction = _team_spend_by_user_query(
|
||||
mock_db_client,
|
||||
AsyncMock(
|
||||
return_value=[
|
||||
_team_spend_by_user_db_row("team-alpha", "alice", 0.5, 3),
|
||||
_team_spend_by_user_db_row("team-alpha", "bob", 0.25, 2),
|
||||
_team_spend_by_user_db_row("team-beta", "alice", 0.1, 1),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
response = await get_team_spend_by_user(
|
||||
|
|
@ -17301,7 +17327,9 @@ async def test_get_team_spend_by_user_admin_groups_spend_logs_by_team_and_user(m
|
|||
end_date="2026-09-04",
|
||||
)
|
||||
|
||||
sql, *params = mock_db_client.db.query_raw.call_args.args
|
||||
assert any("statement_timeout = 30000" in statement for statement in transaction.statements)
|
||||
mock_db_client.db.tx.assert_not_called()
|
||||
sql, *params = transaction.query_raw.call_args.args
|
||||
assert params == ["2026-09-01", "2026-09-04", "team-alpha", "team-beta"]
|
||||
assert 'FROM "LiteLLM_SpendLogs" sl' in sql
|
||||
assert 'sl."startTime" >= $1::timestamp' in sql
|
||||
|
|
@ -17328,7 +17356,7 @@ async def test_get_team_spend_by_user_team_admin_sees_every_member(mock_db_clien
|
|||
caller = UserAPIKeyAuth(user_id="alice", user_role=LitellmUserRoles.INTERNAL_USER)
|
||||
alpha = _team_spend_by_user_team("team-alpha", "Team Alpha", Member(user_id="alice", role="admin"), [])
|
||||
mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[alpha])
|
||||
mock_db_client.db.query_raw = AsyncMock(return_value=[])
|
||||
transaction = _team_spend_by_user_query(mock_db_client, AsyncMock(return_value=[]))
|
||||
mock_db_client.writer_db.litellm_usertable.find_unique = AsyncMock(
|
||||
return_value=_team_spend_by_user_caller("alice", ["team-alpha"])
|
||||
)
|
||||
|
|
@ -17337,7 +17365,7 @@ async def test_get_team_spend_by_user_team_admin_sees_every_member(mock_db_clien
|
|||
user_api_key_dict=caller, team_ids="team-alpha", start_date="2026-09-01", end_date="2026-09-04"
|
||||
)
|
||||
|
||||
sql, *params = mock_db_client.db.query_raw.call_args.args
|
||||
sql, *params = transaction.query_raw.call_args.args
|
||||
assert params == ["2026-09-01", "2026-09-04", "team-alpha"]
|
||||
assert 'sl."user" = $' not in sql
|
||||
|
||||
|
|
@ -17350,7 +17378,9 @@ async def test_get_team_spend_by_user_plain_member_only_sees_own_row(mock_db_cli
|
|||
alpha = _team_spend_by_user_team("team-alpha", "Team Alpha", Member(user_id="bob", role="user"), ["/key/info"])
|
||||
mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[alpha])
|
||||
mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
|
||||
mock_db_client.db.query_raw = AsyncMock(return_value=[_team_spend_by_user_db_row("team-alpha", "bob", 0.25, 2)])
|
||||
transaction = _team_spend_by_user_query(
|
||||
mock_db_client, AsyncMock(return_value=[_team_spend_by_user_db_row("team-alpha", "bob", 0.25, 2)])
|
||||
)
|
||||
mock_db_client.writer_db.litellm_usertable.find_unique = AsyncMock(
|
||||
return_value=_team_spend_by_user_caller("bob", ["team-alpha"])
|
||||
)
|
||||
|
|
@ -17359,7 +17389,7 @@ async def test_get_team_spend_by_user_plain_member_only_sees_own_row(mock_db_cli
|
|||
user_api_key_dict=caller, team_ids="team-alpha", start_date="2026-09-01", end_date="2026-09-04"
|
||||
)
|
||||
|
||||
sql, *params = mock_db_client.db.query_raw.call_args.args
|
||||
sql, *params = transaction.query_raw.call_args.args
|
||||
assert params == ["2026-09-01", "2026-09-04", "team-alpha", "bob"]
|
||||
assert "sl.team_id IN ($3)" in sql
|
||||
assert 'AND sl."user" = $4' in sql
|
||||
|
|
@ -17371,7 +17401,7 @@ async def test_get_team_spend_by_user_member_of_other_team_gets_404(mock_db_clie
|
|||
from litellm.proxy.management_endpoints.team_endpoints import get_team_spend_by_user
|
||||
|
||||
caller = UserAPIKeyAuth(user_id="bob", user_role=LitellmUserRoles.INTERNAL_USER)
|
||||
mock_db_client.db.query_raw = AsyncMock(return_value=[])
|
||||
transaction = _team_spend_by_user_query(mock_db_client, AsyncMock(return_value=[]))
|
||||
mock_db_client.writer_db.litellm_usertable.find_unique = AsyncMock(
|
||||
return_value=_team_spend_by_user_caller("bob", ["team-alpha"])
|
||||
)
|
||||
|
|
@ -17382,7 +17412,7 @@ async def test_get_team_spend_by_user_member_of_other_team_gets_404(mock_db_clie
|
|||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
mock_db_client.db.query_raw.assert_not_called()
|
||||
transaction.query_raw.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -17400,7 +17430,7 @@ async def test_get_team_spend_by_user_member_of_other_team_gets_404(mock_db_clie
|
|||
async def test_get_team_spend_by_user_rejects_bad_input(mock_db_client, team_ids, start_date, end_date, expected_error):
|
||||
from litellm.proxy.management_endpoints.team_endpoints import get_team_spend_by_user
|
||||
|
||||
mock_db_client.db.query_raw = AsyncMock(return_value=[])
|
||||
transaction = _team_spend_by_user_query(mock_db_client, AsyncMock(return_value=[]))
|
||||
admin = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
|
|
@ -17410,7 +17440,41 @@ async def test_get_team_spend_by_user_rejects_bad_input(mock_db_client, team_ids
|
|||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert expected_error in str(exc_info.value.detail)
|
||||
mock_db_client.db.query_raw.assert_not_called()
|
||||
transaction.query_raw.assert_not_called()
|
||||
|
||||
|
||||
def _statement_timeout_error() -> DataError:
|
||||
message = "canceling statement due to statement timeout"
|
||||
return DataError(
|
||||
data={
|
||||
"user_facing_error": {
|
||||
"is_panic": False,
|
||||
"message": f"Error querying the database: {message}",
|
||||
"meta": {"code": "57014", "message": message},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_spend_by_user_statement_timeout_returns_503(mock_db_client):
|
||||
from litellm.proxy.management_endpoints.team_endpoints import get_team_spend_by_user
|
||||
|
||||
admin = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
alpha = _team_spend_by_user_team("team-alpha", "Team Alpha", Member(user_id="alice", role="admin"), [])
|
||||
mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[alpha])
|
||||
_team_spend_by_user_query(mock_db_client, AsyncMock(side_effect=_statement_timeout_error()))
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_team_spend_by_user(
|
||||
user_api_key_dict=admin,
|
||||
team_ids="team-alpha",
|
||||
start_date="2026-09-01",
|
||||
end_date="2026-09-04",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert "longer than 30s" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
class _TeamRowWithOrganization(LiteLLM_TeamTable):
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Final
|
||||
from typing import Final, Literal
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
|
@ -450,18 +451,17 @@ async def test_completed_call_keeps_its_charge_when_post_call_processing_fails(
|
|||
disable_spend_updates: bool,
|
||||
) -> None:
|
||||
from unittest.mock import create_autospec
|
||||
|
||||
import anyio
|
||||
|
||||
from fastapi import Request, Response
|
||||
|
||||
from litellm.proxy._types import ProxyException
|
||||
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._types import ProxyException
|
||||
from litellm.proxy.db.db_spend_update_writer import DBSpendUpdateWriter
|
||||
from litellm.proxy.hooks.proxy_track_cost_callback import ProxyDBLogger
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
||||
scope: Final = anyio.CancelScope()
|
||||
|
|
@ -598,6 +598,7 @@ async def test_guardrail_json_rejection_retains_completed_provider_usage(
|
|||
logging_obj: Logging, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
import httpx
|
||||
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
|
@ -633,3 +634,222 @@ async def test_guardrail_json_rejection_retains_completed_provider_usage(
|
|||
assert (payload["prompt_tokens"], payload["completion_tokens"]) == (10, 5)
|
||||
assert payload["response_cost"] == 0.5
|
||||
ProxyLogging._callback_capabilities_cache.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("price", [None, 0.0, 0.02], ids=["unpriced", "free", "priced"])
|
||||
@pytest.mark.parametrize("discount", [0.0, 1.0], ids=["no-discount", "fully-discounted"])
|
||||
async def test_finalized_header_distinguishes_missing_prices_from_free_models(
|
||||
monkeypatch: pytest.MonkeyPatch, price: float | None, discount: float
|
||||
) -> None:
|
||||
model: Final = "claude-header-pricing-test"
|
||||
monkeypatch.setattr(litellm, "cost_discount_config", {"azure": discount})
|
||||
monkeypatch.setitem(
|
||||
litellm.model_cost,
|
||||
f"azure/{model}",
|
||||
{
|
||||
"litellm_provider": "azure",
|
||||
"mode": "chat",
|
||||
"regional_endpoint_uplift_multiplier": 1.0,
|
||||
**({"input_cost_per_token": price, "output_cost_per_token": price} if price is not None else {}),
|
||||
},
|
||||
)
|
||||
logging: Final = Logging(
|
||||
model=model,
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="acompletion",
|
||||
start_time=NOW,
|
||||
litellm_call_id="pricing-header",
|
||||
function_id="pricing-header",
|
||||
)
|
||||
logging.update_environment_variables(
|
||||
model=model, custom_llm_provider="azure", litellm_params={}, optional_params={}
|
||||
)
|
||||
result: Final = ModelResponse(model=model, usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15))
|
||||
finalized: Final = await finalize_response_cost(logging, result, None, NOW)
|
||||
assert finalized is not None
|
||||
assert finalized.amount == pytest.approx((price or 0.0) * 15 * (1 - discount))
|
||||
headers: Final = ProxyBaseLLMRequestProcessing.get_custom_headers(
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
response_cost=finalized.amount,
|
||||
litellm_logging_obj=logging,
|
||||
)
|
||||
assert headers.get("x-litellm-response-cost") == (None if price is None else str(finalized.amount))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
["unpriced", "cache", "non-inference", "provider", "provider-header", "per-token", "per-second", "passthrough"],
|
||||
)
|
||||
@pytest.mark.parametrize("guardrail_cost", [0.0, 0.1])
|
||||
async def test_zero_cost_headers_preserve_known_free_calls_and_guardrail_charges(
|
||||
source: Literal[
|
||||
"unpriced", "cache", "non-inference", "provider", "provider-header", "per-token", "per-second", "passthrough"
|
||||
],
|
||||
guardrail_cost: float,
|
||||
) -> None:
|
||||
model: Final = "unmapped-header-cost"
|
||||
logging: Final = Logging(
|
||||
model=model,
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="adelete_responses" if source == "non-inference" else "acompletion",
|
||||
start_time=NOW,
|
||||
litellm_call_id="zero-cost-header",
|
||||
function_id="zero-cost-header",
|
||||
)
|
||||
logging.update_environment_variables(
|
||||
model=model, custom_llm_provider="openai", litellm_params={}, optional_params={}
|
||||
)
|
||||
result: Final = ModelResponse(model=model, usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15))
|
||||
result._hidden_params = {"response_cost": 0.0, "cache_hit": source == "cache"}
|
||||
match source:
|
||||
case "provider":
|
||||
logging._provider_reported_cost = 0.0
|
||||
case "provider-header":
|
||||
result._hidden_params["additional_headers"] = {"llm_provider-x-litellm-response-cost": 0.0}
|
||||
case "per-token":
|
||||
logging.custom_cost_per_token = {"input_cost_per_token": 0.0, "output_cost_per_token": 0.0}
|
||||
case "per-second":
|
||||
logging.custom_cost_per_second = 0.0
|
||||
case "passthrough":
|
||||
logging.model_call_details["passthrough_logging_payload"] = {"cost_per_request": 0.0}
|
||||
case _:
|
||||
pass
|
||||
finalized: Final = await finalize_response_cost(logging, result, None, NOW, guardrail_cost=guardrail_cost)
|
||||
assert finalized is not None
|
||||
assert finalized.amount == guardrail_cost
|
||||
headers: Final = ProxyBaseLLMRequestProcessing.get_custom_headers(
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
response_cost=finalized.amount,
|
||||
litellm_logging_obj=logging,
|
||||
)
|
||||
assert headers.get("x-litellm-response-cost") == (
|
||||
None if source == "unpriced" and guardrail_cost == 0 else str(guardrail_cost)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_free_model_priced_only_per_request_keeps_the_zero_cost_header(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
model: Final = "gpt-free-per-request-header"
|
||||
monkeypatch.setitem(
|
||||
litellm.model_cost, model, {"litellm_provider": "openai", "mode": "chat", "input_cost_per_request": 0.0}
|
||||
)
|
||||
logging: Final = Logging(
|
||||
model=model,
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="acompletion",
|
||||
start_time=NOW,
|
||||
litellm_call_id="per-request-header",
|
||||
function_id="per-request-header",
|
||||
)
|
||||
logging.update_environment_variables(
|
||||
model=model, custom_llm_provider="openai", litellm_params={}, optional_params={}
|
||||
)
|
||||
result: Final = ModelResponse(model=model, usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15))
|
||||
finalized: Final = await finalize_response_cost(logging, result, None, NOW)
|
||||
assert finalized is not None and finalized.amount == 0.0
|
||||
headers: Final = ProxyBaseLLMRequestProcessing.get_custom_headers(
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
response_cost=finalized.amount,
|
||||
litellm_logging_obj=logging,
|
||||
)
|
||||
assert headers["x-litellm-response-cost"] == "0.0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_free_off_peak_pricing_keeps_the_finalized_zero_cost_header(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
model: Final = "gpt-free-off-peak-header"
|
||||
monkeypatch.setitem(
|
||||
litellm.model_cost,
|
||||
model,
|
||||
{
|
||||
"litellm_provider": "openai",
|
||||
"mode": "chat",
|
||||
"off_peak_pricing": {"hours_utc": "00:00-23:59", "input_cost_per_token": 0.0, "output_cost_per_token": 0.0},
|
||||
},
|
||||
)
|
||||
logging: Final = Logging(
|
||||
model=model,
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="acompletion",
|
||||
start_time=NOW,
|
||||
litellm_call_id="off-peak-header",
|
||||
function_id="off-peak-header",
|
||||
)
|
||||
logging.update_environment_variables(
|
||||
model=model, custom_llm_provider="openai", litellm_params={}, optional_params={}
|
||||
)
|
||||
result: Final = ModelResponse(model=model, usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15))
|
||||
result._hidden_params["response_cost"] = 0.0
|
||||
finalized: Final = await finalize_response_cost(logging, result, None, NOW)
|
||||
assert finalized is not None and finalized.amount == 0.0
|
||||
headers: Final = ProxyBaseLLMRequestProcessing.get_custom_headers(
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
response_cost=finalized.amount,
|
||||
litellm_logging_obj=logging,
|
||||
)
|
||||
assert headers["x-litellm-response-cost"] == "0.0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("mapped", [False, True], ids=["unmapped-rule", "fill-missing-rule"])
|
||||
@pytest.mark.parametrize("price", [None, 0.0, 0.02], ids=["unpriced", "free", "priced"])
|
||||
async def test_rule_pricing_preserves_free_headers_without_treating_capabilities_as_prices(
|
||||
monkeypatch: pytest.MonkeyPatch, mapped: bool, price: float | None
|
||||
) -> None:
|
||||
from litellm.litellm_core_utils.fallback_generalizations import (
|
||||
get_fallback_generalization_rules,
|
||||
set_fallback_generalizations,
|
||||
)
|
||||
|
||||
model: Final = "gpt-rule-header-model"
|
||||
previous_rules: Final = TypeAdapter(list[object]).validate_python(get_fallback_generalization_rules())
|
||||
if mapped:
|
||||
monkeypatch.setitem(litellm.model_cost, model, {"litellm_provider": "openai", "mode": "chat"})
|
||||
try:
|
||||
set_fallback_generalizations(
|
||||
[
|
||||
{
|
||||
"name": "header-pricing",
|
||||
"pattern": model,
|
||||
"fill_missing_for_providers": ["openai"],
|
||||
"model_info": {
|
||||
"mode": "chat",
|
||||
**(
|
||||
{"input_cost_per_token": price, "output_cost_per_token": price} if price is not None else {}
|
||||
),
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
logging: Final = Logging(
|
||||
model=model,
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="acompletion",
|
||||
start_time=NOW,
|
||||
litellm_call_id="rule-header",
|
||||
function_id="rule-header",
|
||||
)
|
||||
logging.update_environment_variables(
|
||||
model=model, custom_llm_provider="openai", litellm_params={}, optional_params={}
|
||||
)
|
||||
result: Final = ModelResponse(model=model, usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15))
|
||||
finalized: Final = await finalize_response_cost(logging, result, None, NOW)
|
||||
assert finalized is not None
|
||||
assert finalized.amount == pytest.approx((price or 0.0) * 15)
|
||||
headers: Final = TypeAdapter(dict[str, str]).validate_python(
|
||||
ProxyBaseLLMRequestProcessing.get_custom_headers(
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
response_cost=finalized.amount,
|
||||
litellm_logging_obj=logging,
|
||||
)
|
||||
)
|
||||
assert headers.get("x-litellm-response-cost") == (None if price is None else str(finalized.amount))
|
||||
finally:
|
||||
set_fallback_generalizations(previous_rules)
|
||||
|
|
|
|||
|
|
@ -64,12 +64,79 @@ def test_should_bucket_configured_models_by_urgency(authenticated_client):
|
|||
|
||||
|
||||
def test_should_rebucket_with_warn_within_days_override(authenticated_client):
|
||||
response = authenticated_client.get(
|
||||
"/v1/model/deprecations", params={"warn_within_days": 40000}
|
||||
)
|
||||
response = authenticated_client.get("/v1/model/deprecations", params={"warn_within_days": 40000})
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert [m["model_name"] for m in payload["imminent"]] == ["future-alias"]
|
||||
assert payload["upcoming"] == []
|
||||
assert payload["warn_within_days"] == 40000
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_cost_map_successors(monkeypatch):
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-9876"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"model_cost",
|
||||
{
|
||||
"retiring-model": {
|
||||
"deprecation_date": "2020-01-01",
|
||||
"litellm_provider": "openai",
|
||||
"successor_model": "fresh-model",
|
||||
},
|
||||
"orphan-model": {"deprecation_date": "2020-01-01", "litellm_provider": "openai"},
|
||||
},
|
||||
)
|
||||
router = route_entry_points_to_stubs(MagicMock())
|
||||
router.get_model_list.return_value = [
|
||||
{
|
||||
"model_name": "retiring-alias",
|
||||
"litellm_params": {"model": "retiring-model"},
|
||||
"model_info": {"id": "1"},
|
||||
},
|
||||
{
|
||||
"model_name": "retiring-overridden",
|
||||
"litellm_params": {"model": "retiring-model"},
|
||||
"model_info": {"id": "2", "successor_model": "my-own-pick"},
|
||||
},
|
||||
{
|
||||
"model_name": "shared-name",
|
||||
"litellm_params": {"model": "retiring-model", "api_key": "fake"},
|
||||
"model_info": {"id": "4"},
|
||||
},
|
||||
{
|
||||
"model_name": "shared-name",
|
||||
"litellm_params": {"model": "retiring-model", "api_key": "fake"},
|
||||
"model_info": {"id": "5", "successor_model": "my-own-pick"},
|
||||
},
|
||||
{
|
||||
"model_name": "dated-by-hand",
|
||||
"litellm_params": {"model": "retiring-model", "api_key": "fake"},
|
||||
"model_info": {"id": "6", "deprecation_date": "2020-06-01"},
|
||||
},
|
||||
{
|
||||
"model_name": "orphan-alias",
|
||||
"litellm_params": {"model": "orphan-model"},
|
||||
"model_info": {"id": "3"},
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(proxy_server, "llm_router", router)
|
||||
yield client
|
||||
app.dependency_overrides.pop(user_api_key_auth, None)
|
||||
|
||||
|
||||
def test_cost_map_successor_is_reported_unless_the_deployment_names_its_own(client_with_cost_map_successors):
|
||||
response = client_with_cost_map_successors.get("/model/deprecations")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
successors = {m["model_name"]: m["successor_model"] for m in response.json()["deprecated"]}
|
||||
assert successors == {
|
||||
"retiring-alias": "fresh-model",
|
||||
"retiring-overridden": "my-own-pick",
|
||||
"shared-name": "my-own-pick",
|
||||
"dated-by-hand": "fresh-model",
|
||||
"orphan-alias": None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,27 @@ from litellm.types.proxy.management_endpoints.ui_sso import (
|
|||
client = TestClient(app)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sso_settings_without_prisma_reports_database_not_connected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import runpy
|
||||
import sys
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.ui_crud_endpoints import proxy_setting_endpoints
|
||||
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
||||
with patch.dict(sys.modules, {"prisma": None, "prisma.errors": None}):
|
||||
endpoints: Final = runpy.run_path(proxy_setting_endpoints.__file__)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await endpoints["update_sso_settings"](SSOConfig(), UserAPIKeyAuth())
|
||||
|
||||
assert error.value.status_code == 500
|
||||
assert error.value.detail == {"error": "Database not connected. Please connect a database."}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("config_value,stored_value,expected", [
|
||||
(None, None, False), (None, False, False), (None, True, True),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
# This tests the router's ability to pick deployment with lowest tpm using 'usage-based-routing-v2-v2'
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import importlib
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
|
|
@ -15,6 +17,7 @@ from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outco
|
|||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
import litellm
|
||||
from litellm import Router
|
||||
|
|
@ -23,7 +26,7 @@ from litellm.caching.caching import DualCache
|
|||
from litellm.router_strategy.lowest_tpm_rpm_v2 import (
|
||||
LowestTPMLoggingHandler_v2 as LowestTPMLoggingHandler,
|
||||
)
|
||||
from litellm.types.router import DeploymentTypedDict
|
||||
from litellm.types.router import DeploymentTypedDict, RouterRateLimitError
|
||||
from litellm.types.utils import StandardLoggingPayload
|
||||
from litellm.utils import get_utc_datetime
|
||||
|
||||
|
|
@ -347,6 +350,55 @@ def test_router_skip_rate_limited_deployments():
|
|||
print(f"An exception occurred! {str(e)}")
|
||||
|
||||
|
||||
def _embedding_image_uri() -> str:
|
||||
image: Final = io.BytesIO()
|
||||
Image.new("RGB", (1024, 1024)).save(image, format="PNG", compress_level=0)
|
||||
return f"data:image/png;base64,{base64.b64encode(image.getvalue()).decode()}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"routing_strategy",
|
||||
["usage-based-routing", "usage-based-routing-v2", "latency-based-routing", "cost-based-routing"],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("litellm_params", "model_info", "admitted"),
|
||||
[
|
||||
({"model": "vertex_ai/gemini-embedding-2"}, {}, True),
|
||||
(
|
||||
{"model": "openai/team-embedder", "api_base": "https://embedder.invalid"},
|
||||
{"base_model": "vertex_ai/gemini-embedding-2"},
|
||||
True,
|
||||
),
|
||||
({"model": "text-embedding-3-small"}, {}, False),
|
||||
],
|
||||
ids=["image-model", "image-base-model", "text-model"],
|
||||
)
|
||||
async def test_deployment_tpm_counts_embedding_image_as_media_only_on_image_models(
|
||||
routing_strategy: str, litellm_params: Dict[str, str], model_info: Dict[str, str], admitted: bool
|
||||
) -> None:
|
||||
uri: Final = _embedding_image_uri()
|
||||
assert len(uri) // 4 > 75_000
|
||||
router: Final = Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "embedder",
|
||||
"litellm_params": {**litellm_params, "tpm": 75_000},
|
||||
"model_info": {**model_info, "id": f"embedder-{index}"},
|
||||
}
|
||||
for index in range(2)
|
||||
],
|
||||
routing_strategy=routing_strategy,
|
||||
)
|
||||
|
||||
if not admitted:
|
||||
with pytest.raises((litellm.RateLimitError, RouterRateLimitError)):
|
||||
await router.select("embedder", {}, input=[uri])
|
||||
return
|
||||
deployment: Final = await router.select("embedder", {}, input=[uri])
|
||||
assert deployment["model_info"]["id"] in ("embedder-0", "embedder-1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sync_mode", [True, False])
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_potential_deployments(sync_mode):
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import functools
|
|||
import gc
|
||||
import itertools
|
||||
import io
|
||||
import base64
|
||||
import json
|
||||
import linecache
|
||||
import logging
|
||||
|
|
@ -8174,6 +8175,77 @@ def test_pre_call_checks_count_a_group_mixing_in_another_vendor_by_its_own_name(
|
|||
)
|
||||
|
||||
|
||||
_EMBEDDING_CONTEXT_WINDOW: Final = 8_192
|
||||
_EMBEDDING_IMAGE_INPUT: Final = f"data:image/png;base64,{base64.b64encode(bytes(range(256)) * 1024).decode()}"
|
||||
|
||||
|
||||
def _embedding_context_router(backend: str, window: int = _EMBEDDING_CONTEXT_WINDOW) -> litellm.Router:
|
||||
return litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "embedder",
|
||||
"litellm_params": {"model": backend, "api_key": "sk-test"},
|
||||
"model_info": {"max_input_tokens": window},
|
||||
}
|
||||
],
|
||||
enable_pre_call_checks=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_context_window_pre_call_check_counts_an_embedding_image_as_media_on_an_image_backend():
|
||||
router: Final = _embedding_context_router("vertex_ai/gemini-embedding-2")
|
||||
deployments: Final = router.registry().deployments("embedder")
|
||||
|
||||
kept: Final = router._pre_call_checks(model="embedder", healthy_deployments=deployments, input=_EMBEDDING_IMAGE_INPUT)
|
||||
counted: Final = await router._acount_pre_call_check_tokens(
|
||||
model="embedder", healthy_deployments=deployments, messages=None, input=_EMBEDDING_IMAGE_INPUT, request_kwargs=None
|
||||
)
|
||||
|
||||
assert len(kept) == 1
|
||||
assert counted is not None and 0 < counted < _EMBEDDING_CONTEXT_WINDOW
|
||||
with pytest.raises(litellm.ContextWindowExceededError):
|
||||
router._pre_call_checks(model="embedder", healthy_deployments=deployments, input="word " * 20_000)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("backend", "shape"),
|
||||
[
|
||||
("vertex_ai/gemini-embedding-2", "pdf"),
|
||||
("vertex_ai/gemini-embedding-2", "pdf_file_block"),
|
||||
("vertex_ai/gemini-embedding-2", "two_pdfs"),
|
||||
("bedrock/amazon.titan-embed-image-v1", "image"),
|
||||
("bedrock/amazon.titan-embed-image-v1", "two_images"),
|
||||
],
|
||||
)
|
||||
def test_context_window_pre_call_check_leaves_embedding_media_to_the_backend(backend: str, shape: str):
|
||||
window: Final = litellm.get_model_info(backend)["max_input_tokens"]
|
||||
assert window is not None
|
||||
router: Final = _embedding_context_router(backend, window)
|
||||
pdf: Final = f"data:application/pdf;base64,{base64.b64encode(bytes(window * 8)).decode()}"
|
||||
inputs: Final = {
|
||||
"pdf": pdf,
|
||||
"pdf_file_block": [{"type": "file", "file": {"file_data": pdf}}],
|
||||
"two_pdfs": [pdf, pdf],
|
||||
"image": _EMBEDDING_IMAGE_INPUT,
|
||||
"two_images": [_EMBEDDING_IMAGE_INPUT, _EMBEDDING_IMAGE_INPUT],
|
||||
}
|
||||
|
||||
kept: Final = router._pre_call_checks(
|
||||
model="embedder", healthy_deployments=router.registry().deployments("embedder"), input=inputs[shape]
|
||||
)
|
||||
|
||||
assert len(kept) == 1
|
||||
|
||||
|
||||
def test_context_window_pre_call_check_counts_an_embedding_image_as_text_on_a_text_only_backend():
|
||||
router: Final = _embedding_context_router("text-embedding-3-small")
|
||||
|
||||
with pytest.raises(litellm.ContextWindowExceededError):
|
||||
router._pre_call_checks(
|
||||
model="embedder", healthy_deployments=router.registry().deployments("embedder"), input=_EMBEDDING_IMAGE_INPUT
|
||||
)
|
||||
|
||||
|
||||
async def test_acount_pre_call_check_tokens_counts_a_group_of_one_openai_model_with_that_models_tokenizer():
|
||||
router: Final = _context_limited_router("openai/gpt-6.1-sol")
|
||||
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,7 @@ _COST_MAP_ROW_SCHEMA: Final = {
|
|||
"audio_transcription_config": {"type": "string"},
|
||||
"deprecation_date": {"type": "string"},
|
||||
"successor_model": {"type": "string"},
|
||||
"deprecation_source": {"type": "string"},
|
||||
"input_cost_per_audio_per_second": {"type": "number"},
|
||||
"input_cost_per_audio_per_second_above_128k_tokens": {"type": "number"},
|
||||
"google_maps_grounding_cost_per_query": {"type": "number"},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails";
|
||||
import { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
|
||||
import { useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import { DEFAULT_PROXY_ADMIN_USER_ID } from "@/utils/sentinels";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -7,6 +9,7 @@ import { renderWithProviders } from "../../../../../tests/test-utils";
|
|||
import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails");
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers");
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
vi.mock("./AccessGroupsModal/AccessGroupEditModal", () => ({
|
||||
AccessGroupEditModal: ({ visible, onCancel }: { visible: boolean; onCancel: () => void }) =>
|
||||
|
|
@ -18,6 +21,7 @@ vi.mock("./AccessGroupsModal/AccessGroupEditModal", () => ({
|
|||
}));
|
||||
|
||||
const mockUseAccessGroupDetails = vi.mocked(useAccessGroupDetails);
|
||||
const mockUseUserLookup = vi.mocked(useUserLookup);
|
||||
|
||||
const baseMockReturnValue = {
|
||||
data: undefined,
|
||||
|
|
@ -89,6 +93,7 @@ describe("AccessGroupDetail", () => {
|
|||
...baseMockReturnValue,
|
||||
data: createMockAccessGroup(),
|
||||
} as ReturnType<typeof useAccessGroupDetails>);
|
||||
mockUseUserLookup.mockReturnValue({ data: null } as ReturnType<typeof useUserLookup>);
|
||||
});
|
||||
|
||||
it("should render the component", () => {
|
||||
|
|
@ -144,6 +149,64 @@ describe("AccessGroupDetail", () => {
|
|||
expect(screen.getByText("A test access group")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("user references", () => {
|
||||
it("shows a user's alias as a linked name and reveals the id in a tooltip", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseUserLookup.mockReturnValue({
|
||||
data: { user_alias: "Alice", user_email: "alice@example.com" },
|
||||
} as ReturnType<typeof useUserLookup>);
|
||||
renderWith({ created_by: "user-123" });
|
||||
|
||||
const link = screen.getByRole("link", { name: "Alice" });
|
||||
expect(link).toHaveAttribute("href", expect.stringContaining("/users?user=user-123"));
|
||||
expect(screen.queryByText("user-123")).not.toBeInTheDocument();
|
||||
|
||||
await user.hover(link);
|
||||
|
||||
expect(await screen.findByText("user-123")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the email when the user has no alias", () => {
|
||||
mockUseUserLookup.mockReturnValue({
|
||||
data: { user_alias: null, user_email: "alice@example.com" },
|
||||
} as ReturnType<typeof useUserLookup>);
|
||||
renderWith({ updated_by: "user-456" });
|
||||
|
||||
expect(screen.getByRole("link", { name: "alice@example.com" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/users?user=user-456"),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the email when the alias contains only whitespace", () => {
|
||||
mockUseUserLookup.mockReturnValue({
|
||||
data: { user_alias: " ", user_email: "alice@example.com" },
|
||||
} as ReturnType<typeof useUserLookup>);
|
||||
renderWith({ created_by: "user-789" });
|
||||
|
||||
expect(screen.getByRole("link", { name: "alice@example.com" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/users?user=user-789"),
|
||||
);
|
||||
});
|
||||
|
||||
it("links the user id when lookup has no user details", () => {
|
||||
renderWith({ updated_by: "missing-user" });
|
||||
|
||||
expect(screen.getByRole("link", { name: "missing-user" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/users?user=missing-user"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the default admin badge without looking up its user id", () => {
|
||||
renderWith({ created_by: DEFAULT_PROXY_ADMIN_USER_ID });
|
||||
|
||||
expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument();
|
||||
expect(mockUseUserLookup).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
it("should display em dash when description is empty", () => {
|
||||
renderWith({ description: null });
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import {
|
|||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
|
||||
import { BadgeLink } from "@/components/shared/BadgeLink";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { UserReference } from "@/components/shared/UserReference";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -190,7 +190,7 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
|
|||
{accessGroup.created_by && (
|
||||
<>
|
||||
<span>{t("accessGroups.detail.by")}</span>
|
||||
<DefaultProxyAdminTag userId={accessGroup.created_by} />
|
||||
<UserReference userId={accessGroup.created_by} />
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
|
|
@ -200,7 +200,7 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
|
|||
{accessGroup.updated_by && (
|
||||
<>
|
||||
<span>{t("accessGroups.detail.by")}</span>
|
||||
<DefaultProxyAdminTag userId={accessGroup.updated_by} />
|
||||
<UserReference userId={accessGroup.updated_by} />
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ import { renderHook, waitFor } from "@testing-library/react";
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React, { ReactNode } from "react";
|
||||
import { useInfiniteUsers, useUserEmailLookup, useUserLookup } from "./useUsers";
|
||||
import { userListCall } from "@/components/networking";
|
||||
import type { UserListResponse } from "@/components/networking";
|
||||
import { userGetInfoV2, userListCall } from "@/components/networking";
|
||||
import type { UserInfoV2Response, UserListResponse } from "@/components/networking";
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
userGetInfoV2: vi.fn(),
|
||||
userListCall: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -56,6 +58,23 @@ const buildUserListResponse = (page: number, totalPages: number, userCount = 2):
|
|||
})),
|
||||
});
|
||||
|
||||
const buildUserInfoV2Response = (userId: string): UserInfoV2Response => ({
|
||||
user_id: userId,
|
||||
user_email: "alice@example.com",
|
||||
user_alias: "Alice",
|
||||
user_role: "Internal User",
|
||||
spend: 0,
|
||||
max_budget: null,
|
||||
models: [],
|
||||
budget_duration: null,
|
||||
budget_reset_at: null,
|
||||
metadata: null,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
sso_user_id: null,
|
||||
teams: [],
|
||||
});
|
||||
|
||||
describe("useInfiniteUsers", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
|
|
@ -299,22 +318,47 @@ describe("useUserLookup", () => {
|
|||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
it("fetches exactly the requested user by id", async () => {
|
||||
vi.mocked(userListCall).mockResolvedValue(buildUserListResponse(1, 1, 1));
|
||||
const exactUser = buildUserInfoV2Response("user-1-0");
|
||||
vi.mocked(userGetInfoV2).mockResolvedValue(exactUser);
|
||||
|
||||
const { result } = renderHook(() => useUserLookup("user-1-0"), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(userListCall).toHaveBeenCalledWith("test-access-token", ["user-1-0"], 1, 1);
|
||||
expect(userGetInfoV2).toHaveBeenCalledWith("test-access-token", "user-1-0");
|
||||
expect(userListCall).not.toHaveBeenCalled();
|
||||
expect(result.current.data).toMatchObject(exactUser);
|
||||
expect(result.current.data?.user_id).toBe("user-1-0");
|
||||
});
|
||||
|
||||
it("resolves to null when the proxy returns a different user than requested", async () => {
|
||||
vi.mocked(userListCall).mockResolvedValue(buildUserListResponse(1, 1, 1));
|
||||
it("resolves to no user when user info returns a different user id", async () => {
|
||||
vi.mocked(userGetInfoV2).mockResolvedValue(buildUserInfoV2Response("a+b"));
|
||||
|
||||
const { result } = renderHook(() => useUserLookup("someone-else"), { wrapper });
|
||||
const { result } = renderHook(() => useUserLookup("a b"), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toBeNull();
|
||||
expect(userListCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves to no user when the user info endpoint returns a 404", async () => {
|
||||
vi.mocked(userGetInfoV2).mockRejectedValue(new ApiError("Not Found", 404, {}));
|
||||
|
||||
const { result } = renderHook(() => useUserLookup("bob"), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toBeNull();
|
||||
expect(userListCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rethrows errors other than 404", async () => {
|
||||
const serverError = new ApiError("Internal Server Error", 500, {});
|
||||
vi.mocked(userGetInfoV2).mockRejectedValue(serverError);
|
||||
|
||||
const { result } = renderHook(() => useUserLookup("user-1-0"), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
expect(result.current.error).toBe(serverError);
|
||||
expect(userListCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not query without a user id", async () => {
|
||||
|
|
@ -322,7 +366,7 @@ describe("useUserLookup", () => {
|
|||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(result.current.fetchStatus).toBe("idle");
|
||||
expect(userListCall).not.toHaveBeenCalled();
|
||||
expect(userGetInfoV2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not query for a non-admin role", async () => {
|
||||
|
|
@ -332,7 +376,7 @@ describe("useUserLookup", () => {
|
|||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(result.current.fetchStatus).toBe("idle");
|
||||
expect(userListCall).not.toHaveBeenCalled();
|
||||
expect(userGetInfoV2).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { userListCall, UserInfo, UserListResponse } from "@/components/networking";
|
||||
import { userGetInfoV2, userListCall } from "@/components/networking";
|
||||
import type { UserInfoV2Response, UserListResponse } from "@/components/networking";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
import { canListUsers } from "@/utils/roles";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
|
||||
const infiniteUsersKeys = createQueryKeys("infiniteUsers");
|
||||
const userLookupKeys = createQueryKeys("userLookup");
|
||||
|
|
@ -58,11 +60,16 @@ export const useUserEmailLookup = (userIds: readonly string[]) => {
|
|||
|
||||
export const useUserLookup = (userId: string | null) => {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
return useQuery<UserInfo | null>({
|
||||
return useQuery<UserInfoV2Response | null>({
|
||||
queryKey: userLookupKeys.detail(userId ?? ""),
|
||||
queryFn: async () => {
|
||||
const response = await userListCall(accessToken!, [userId!], 1, 1);
|
||||
return response.users.find((user) => user.user_id === userId) ?? null;
|
||||
try {
|
||||
const user = await userGetInfoV2(accessToken!, userId!);
|
||||
return user.user_id === userId ? user : null;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: Boolean(accessToken) && Boolean(userId) && canListUsers(userRole),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
|||
import { renderWithProviders, screen } from "../../../../../tests/test-utils";
|
||||
import { ProjectDetail } from "./ProjectDetailsPage";
|
||||
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
import { useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
|
||||
const mockUseProjectDetails = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/projects/useProjectDetails", () => ({
|
||||
|
|
@ -24,12 +25,18 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
|
|||
useUISettings: () => mockUseUISettings(),
|
||||
}));
|
||||
|
||||
vi.mock("./ProjectModals/EditProjectModal", () => ({
|
||||
EditProjectModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ? <div data-testid="edit-modal" /> : null),
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/app/(dashboard)/hooks/users/useUsers")>();
|
||||
return { ...actual, useUserLookup: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("next/navigation")>()),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/common_components/DefaultProxyAdminTag", () => ({
|
||||
default: ({ userId }: { userId: string }) => <span>{userId}</span>,
|
||||
vi.mock("./ProjectModals/EditProjectModal", () => ({
|
||||
EditProjectModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ? <div data-testid="edit-modal" /> : null),
|
||||
}));
|
||||
|
||||
vi.mock("./ProjectKeysSection", () => ({
|
||||
|
|
@ -69,9 +76,11 @@ const yAxisTickLabels = (container: HTMLElement) =>
|
|||
|
||||
describe("ProjectDetail", () => {
|
||||
const onBack = vi.fn();
|
||||
const mockUseUserLookup = vi.mocked(useUserLookup);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseUserLookup.mockReturnValue({ data: null } as ReturnType<typeof useUserLookup>);
|
||||
mockUseTeam.mockReturnValue({ data: undefined, isLoading: false });
|
||||
mockUseAuthorized.mockReturnValue({ userId: "admin-user", userRole: "Admin", isViewOnly: false });
|
||||
mockUseUISettings.mockReturnValue({ data: { values: {} } });
|
||||
|
|
@ -113,6 +122,21 @@ describe("ProjectDetail", () => {
|
|||
expect(screen.getByText("My Project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the creator alias as a link to the user page", () => {
|
||||
mockUseUserLookup.mockImplementation(
|
||||
(userId) =>
|
||||
({
|
||||
data: userId === "user-1" ? { user_alias: "Alice", user_email: "alice@example.com" } : null,
|
||||
}) as ReturnType<typeof useUserLookup>,
|
||||
);
|
||||
renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
|
||||
|
||||
expect(screen.getByRole("link", { name: "Alice" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/users?user=user-1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should display the project alias as the page title", () => {
|
||||
renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
|
||||
expect(screen.getByRole("heading", { name: "My Project" })).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { isUserTeamAdminForSingleTeam } from "@/utils/roles";
|
|||
import { BarChart } from "@/components/shared/charts";
|
||||
import { ArrowLeftIcon, DollarSignIcon, EditIcon, UsersIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { UserReference } from "@/components/shared/UserReference";
|
||||
import { StatusBadge } from "@/components/shared/table_cells/status_badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -116,7 +116,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
{project.created_by && (
|
||||
<>
|
||||
<span>by</span>
|
||||
<DefaultProxyAdminTag userId={project.created_by} />
|
||||
<UserReference userId={project.created_by} />
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
|
|
@ -126,7 +126,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
{project.updated_by && (
|
||||
<>
|
||||
<span>by</span>
|
||||
<DefaultProxyAdminTag userId={project.updated_by} />
|
||||
<UserReference userId={project.updated_by} />
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import PromptInfoView from "./prompt_info";
|
||||
import * as networking from "@/components/networking";
|
||||
import { useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getPromptInfo: vi.fn(),
|
||||
|
|
@ -17,6 +18,22 @@ vi.mock("./prompt_editor_view/PromptCodeSnippets", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/app/(dashboard)/hooks/users/useUsers")>();
|
||||
return { ...actual, useUserLookup: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("next/navigation")>()),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
const mockUseUserLookup = vi.mocked(useUserLookup);
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseUserLookup.mockReturnValue({ data: null } as ReturnType<typeof useUserLookup>);
|
||||
});
|
||||
|
||||
const promptWithoutTemplate = {
|
||||
prompt_spec: {
|
||||
prompt_id: "support-reply",
|
||||
|
|
@ -58,6 +75,25 @@ describe("PromptInfoView environment scoping", () => {
|
|||
await screen.findByRole("tab", { name: "Raw JSON" });
|
||||
expect(networking.getPromptInfo).toHaveBeenCalledWith("sk-test", "support-reply", undefined);
|
||||
});
|
||||
|
||||
it("shows the creator alias as a link to the user page", async () => {
|
||||
mockUseUserLookup.mockReturnValue({
|
||||
data: { user_alias: "Alice", user_email: "alice@example.com" },
|
||||
} as ReturnType<typeof useUserLookup>);
|
||||
vi.mocked(networking.getPromptInfo)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
...promptWithoutTemplate,
|
||||
prompt_spec: { ...promptWithoutTemplate.prompt_spec, created_by: "prompt-user-1" },
|
||||
});
|
||||
|
||||
render(<PromptInfoView promptId="support-reply" onClose={vi.fn()} accessToken="sk-test" isAdmin={true} />);
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Alice" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/users?user=prompt-user-1"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PromptInfoView code snippets", () => {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
import { ArrowLeft, CheckIcon, CopyIcon, Pencil, Trash2 } from "lucide-react";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { UserReference } from "@/components/shared/UserReference";
|
||||
import PromptCodeSnippets from "./prompt_editor_view/PromptCodeSnippets";
|
||||
import { extractModel, extractTemplateVariables, getBasePromptId, getCurrentVersion } from "./prompt_utils";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
|
@ -333,7 +334,9 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
<Card className="block p-6">
|
||||
<p>{t("prompts.info.overview.createdBy")}</p>
|
||||
<div className="mt-2">
|
||||
<h3 className="text-sm font-medium">{promptData.created_by || "-"}</h3>
|
||||
<h3 className="text-sm font-medium">
|
||||
{promptData.created_by ? <UserReference userId={promptData.created_by} /> : "-"}
|
||||
</h3>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { teamSpendByUserCall } from "@/components/networking";
|
||||
|
||||
import TeamUserSpendCard from "./TeamUserSpendCard";
|
||||
|
||||
vi.mock("@/components/networking", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/components/networking")>()),
|
||||
teamSpendByUserCall: vi.fn(),
|
||||
}));
|
||||
|
||||
const renderCard = () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<TeamUserSpendCard
|
||||
accessToken="sk-test"
|
||||
startTime={new Date("2026-09-01T00:00:00Z")}
|
||||
endTime={new Date("2026-09-30T00:00:00Z")}
|
||||
teamIds={["team-alpha"]}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("TeamUserSpendCard", () => {
|
||||
it("shows the server's error instead of an empty table when the request fails", async () => {
|
||||
const timeoutMessage = "Spend per user took longer than 30s. Pick a shorter date range or fewer teams";
|
||||
vi.mocked(teamSpendByUserCall).mockRejectedValueOnce(new Error(timeoutMessage));
|
||||
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByText(timeoutMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByText("No user spend in this range")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -73,7 +73,7 @@ const columns: ColumnDef<TeamUserSpendRow>[] = [
|
|||
|
||||
const TeamUserSpendCard: React.FC<TeamUserSpendCardProps> = ({ accessToken, startTime, endTime, teamIds }) => {
|
||||
const hasTeams = teamIds.length > 0;
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["teamSpendByUser", startTime?.toISOString(), endTime?.toISOString(), teamIds],
|
||||
queryFn: () =>
|
||||
accessToken && startTime && endTime ? teamSpendByUserCall(accessToken, startTime, endTime, teamIds) : null,
|
||||
|
|
@ -105,6 +105,8 @@ const TeamUserSpendCard: React.FC<TeamUserSpendCardProps> = ({ accessToken, star
|
|||
data={rows}
|
||||
getRowId={teamUserSpendRowId}
|
||||
isLoading={isLoading}
|
||||
isError={error !== null}
|
||||
errorMessage={error instanceof Error ? error.message : "Spend per user failed to load"}
|
||||
maxBodyHeight={320}
|
||||
noDataMessage={teamIds.length === 0 ? "Select a team to see spend per user" : "No user spend in this range"}
|
||||
size="compact"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import ModelInfoView from "./model_info_view";
|
||||
import { toast } from "@/lib/toast";
|
||||
import * as networking from "./networking";
|
||||
import { useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
vi.mock("./model_info/KubernetesPodsCard", () => ({
|
||||
default: ({ modelId }: { modelId: string }) => <div data-testid="kubernetes-pods-card">{modelId}</div>,
|
||||
}));
|
||||
|
|
@ -49,6 +50,16 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
|||
useTeams: () => mockUseTeams(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/app/(dashboard)/hooks/users/useUsers")>();
|
||||
return { ...actual, useUserLookup: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("next/navigation")>()),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
const mockUsePtuCostAttributionEnabled = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled", () => ({
|
||||
usePtuCostAttributionEnabled: () => mockUsePtuCostAttributionEnabled(),
|
||||
|
|
@ -107,6 +118,7 @@ describe("ModelInfoView", () => {
|
|||
},
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useUserLookup).mockReturnValue({ data: null } as ReturnType<typeof useUserLookup>);
|
||||
mockUsePtuCostAttributionEnabled.mockReturnValue(false);
|
||||
mockUseTeams.mockReturnValue({ data: undefined, isLoading: false, error: null });
|
||||
|
||||
|
|
@ -1488,6 +1500,18 @@ describe("ModelInfoView", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("shows the creator alias as a link to the user page", async () => {
|
||||
vi.mocked(useUserLookup).mockReturnValue({
|
||||
data: { user_alias: "Alice", user_email: "alice@example.com" },
|
||||
} as ReturnType<typeof useUserLookup>);
|
||||
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Alice" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/users?user=123"),
|
||||
);
|
||||
});
|
||||
|
||||
describe("team alias", () => {
|
||||
const teamModel = {
|
||||
...defaultModelData,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Card } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { UserReference } from "@/components/shared/UserReference";
|
||||
import { applyPtuModelInfo } from "../utils/ptuModelInfo";
|
||||
import { usePtuCostAttributionEnabled } from "@/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled";
|
||||
import { ArrowLeft, CheckIcon, CopyIcon, Info } from "lucide-react";
|
||||
|
|
@ -668,7 +669,12 @@ export default function ModelInfoView({
|
|||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
Created By {modelData.model_info.created_by || "Not Set"}
|
||||
Created By{" "}
|
||||
{modelData.model_info.created_by ? (
|
||||
<UserReference userId={modelData.model_info.created_by} />
|
||||
) : (
|
||||
"Not Set"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { vi, test, expect, beforeEach, describe, type Mock } from "vitest";
|
|||
import { renderWithProviders, testQueryClient } from "../../../tests/test-utils";
|
||||
import OrganizationInfoView from "./organization_view";
|
||||
import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import { useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
|
|
@ -37,7 +38,13 @@ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/app/(dashboard)/hooks/users/useUsers")>();
|
||||
return { ...actual, useUserLookup: vi.fn() };
|
||||
});
|
||||
|
||||
const mockUseOrganization = vi.mocked(useOrganization);
|
||||
const mockUseUserLookup = vi.mocked(useUserLookup);
|
||||
|
||||
// Mock noisy/heavy child components to keep this test focused on render
|
||||
vi.mock("../object_permissions_view", () => ({
|
||||
|
|
@ -103,6 +110,7 @@ const mockOrg = {
|
|||
|
||||
beforeEach(() => {
|
||||
mockUseOrganization.mockReset();
|
||||
mockUseUserLookup.mockReturnValue({ data: null } as ReturnType<typeof useUserLookup>);
|
||||
});
|
||||
|
||||
test("renders organization view after loading data", async () => {
|
||||
|
|
@ -123,6 +131,32 @@ test("renders organization view after loading data", async () => {
|
|||
expect(orgName).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows the creator alias as a link to the user page", async () => {
|
||||
mockUseOrganization.mockReturnValue({
|
||||
data: { ...mockOrg, created_by: "org-user-1" },
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useOrganization>);
|
||||
mockUseUserLookup.mockReturnValue({
|
||||
data: { user_alias: "Alice", user_email: "alice@example.com" },
|
||||
} as ReturnType<typeof useUserLookup>);
|
||||
|
||||
renderWithProviders(
|
||||
<OrganizationInfoView
|
||||
organizationId="org_123"
|
||||
onClose={() => {}}
|
||||
accessToken="test-token"
|
||||
is_org_admin={false}
|
||||
is_proxy_admin={false}
|
||||
userModels={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Alice" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/users?user=org-user-1"),
|
||||
);
|
||||
});
|
||||
|
||||
test("should display empty state when organization has no members", async () => {
|
||||
mockUseOrganization.mockReturnValue({ data: mockOrg, isLoading: false } as any);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useUrlTab } from "@/hooks/useUrlTab";
|
|||
import { useVisitedTabs } from "@/hooks/useVisitedTabs";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { UserReference } from "@/components/shared/UserReference";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
|
@ -197,7 +198,7 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
</p>
|
||||
<p>
|
||||
{t("organizations.view.overview.createdByLabel")}
|
||||
{orgData.created_by}
|
||||
{orgData.created_by && <UserReference userId={orgData.created_by} />}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
30
ui/litellm-dashboard/src/components/shared/UserReference.tsx
Normal file
30
ui/litellm-dashboard/src/components/shared/UserReference.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import { useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
|
||||
import { EntityLink } from "@/components/shared/EntityLink";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { DEFAULT_PROXY_ADMIN_USER_ID } from "@/utils/sentinels";
|
||||
import { userDetailHref } from "@/utils/entityLinks";
|
||||
|
||||
export function UserReference({ userId }: { userId: string }) {
|
||||
const isDefaultAdmin = userId === DEFAULT_PROXY_ADMIN_USER_ID;
|
||||
const { data: user } = useUserLookup(isDefaultAdmin ? null : userId);
|
||||
|
||||
if (isDefaultAdmin) {
|
||||
return <DefaultProxyAdminTag userId={userId} />;
|
||||
}
|
||||
|
||||
const name = user?.user_alias?.trim() || user?.user_email?.trim() || null;
|
||||
const href = userDetailHref(userId);
|
||||
|
||||
if (!name) {
|
||||
return <EntityLink href={href}>{userId}</EntityLink>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleTooltip content={userId}>
|
||||
<EntityLink href={href}>{name}</EntityLink>
|
||||
</SimpleTooltip>
|
||||
);
|
||||
}
|
||||
12
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
12
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -10066,8 +10066,9 @@ export interface paths {
|
|||
*
|
||||
* Reads `deprecation_date` metadata from `model_prices_and_context_window.json`
|
||||
* (and any per-deployment `model_info.deprecation_date` overrides) for the
|
||||
* models configured on this proxy. An entry also names its `successor_model`
|
||||
* when the deployment sets `model_info.successor_model`.
|
||||
* models configured on this proxy. An entry also names its `successor_model`:
|
||||
* `model_info.successor_model` when the deployment sets one, else the
|
||||
* replacement the cost map records from the provider's deprecation notice.
|
||||
*
|
||||
* Parameters:
|
||||
* warn_within_days: Window (in days) used to bucket "imminent" models,
|
||||
|
|
@ -21599,8 +21600,9 @@ export interface paths {
|
|||
*
|
||||
* Reads `deprecation_date` metadata from `model_prices_and_context_window.json`
|
||||
* (and any per-deployment `model_info.deprecation_date` overrides) for the
|
||||
* models configured on this proxy. An entry also names its `successor_model`
|
||||
* when the deployment sets `model_info.successor_model`.
|
||||
* models configured on this proxy. An entry also names its `successor_model`:
|
||||
* `model_info.successor_model` when the deployment sets one, else the
|
||||
* replacement the cost map records from the provider's deprecation notice.
|
||||
*
|
||||
* Parameters:
|
||||
* warn_within_days: Window (in days) used to bucket "imminent" models,
|
||||
|
|
@ -38995,7 +38997,7 @@ export interface components {
|
|||
status: "upcoming" | "imminent" | "deprecated";
|
||||
/**
|
||||
* Successor Model
|
||||
* @description The model to migrate to, as set in model_info.successor_model on the deployment.
|
||||
* @description The model to migrate to: model_info.successor_model on the deployment when set, else the successor_model the cost map names for the deprecated model.
|
||||
*/
|
||||
successor_model?: string | null;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue