Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion litellm/cost_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
LlmProviders,
LlmProvidersSet,
ModelInfo,
ServiceTier,
StandardBuiltInToolsParams,
TranscriptionUsageDurationObject,
TranscriptionUsageTokensObject,
Expand Down Expand Up @@ -614,7 +615,9 @@ def cost_per_token( # noqa: PLR0915
service_tier=service_tier,
)
elif custom_llm_provider == "anthropic":
return anthropic_cost_per_token(model=model, usage=usage_block)
return anthropic_cost_per_token(
model=model, usage=usage_block, service_tier=service_tier
)
elif custom_llm_provider == "bedrock":
return bedrock_cost_per_token(
model=model, usage=usage_block, service_tier=service_tier
Expand Down Expand Up @@ -1224,6 +1227,16 @@ def completion_cost( # noqa: PLR0915
if service_tier is None and optional_params is not None:
service_tier = optional_params.get("service_tier")

# A request-level service_tier only prices the request when it is a
# concrete billable tier string. "auto" is a routing preference and any
# non-string value is not a billable tier, so defer to the tier the
# provider reports on the response/usage instead of crashing or mispricing
if (
not isinstance(service_tier, str)
or service_tier.lower() == ServiceTier.AUTO.value
):
service_tier = None

# Extract service_tier from completion_response if not provided
if service_tier is None and completion_response is not None:
if isinstance(completion_response, BaseModel):
Expand Down
16 changes: 12 additions & 4 deletions litellm/integrations/otel/emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
ServiceSpanData,
)
from litellm.integrations.otel.plumbing.providers import to_otel_span_kind
from litellm.integrations.otel.model.semconv import Error
from litellm.integrations.otel.model.semconv import Error, ExceptionEvent
from litellm.integrations.otel.model.spans import (
SPAN_REGISTRY,
SpanRole,
Expand Down Expand Up @@ -179,9 +179,17 @@ def finish_span(
else None
)
if error and (error.error_type or error.message):
span.set_attribute(Error.TYPE, error.error_type or "error")
span.set_status(
Status(StatusCode.ERROR, error.message or error.error_type or "error")
error_type = error.error_type or "error"
message = error.message or error.error_type or "error"
span.set_attribute(Error.TYPE, error_type)
span.set_status(Status(StatusCode.ERROR, message))
# Carry the full message on the standard ``exception`` event so backends
# map it as full text under ``exception.message``. Setting it as a bare
# string attribute instead lets backends like Elasticsearch dynamic-map
# it to a ``keyword`` capped at 1024 chars, truncating the message.
span.add_event(
ExceptionEvent.NAME,
{ExceptionEvent.TYPE: error_type, ExceptionEvent.MESSAGE: message},
)
# On success leave the status UNSET (the semconv default) rather than
# forcing OK — that matches the FastAPI server span and avoids implying a
Expand Down
15 changes: 15 additions & 0 deletions litellm/integrations/otel/model/semconv.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,21 @@ class Error:
TYPE: Final = "error.type"


class ExceptionEvent:
"""OTel exception-event name and attribute keys (semconv ``exception.*``).

The full error message rides ``exception.message`` on a span event rather than
a custom string attribute. Backends recognise these semantic-convention names
and map them as full text; an unrecognised key (e.g. ``error_message``) falls
into the default dynamic template, which truncates strings to a 1024-char
``keyword``.
"""

NAME: Final = "exception"
TYPE: Final = "exception.type"
MESSAGE: Final = "exception.message"


class Server:
ADDRESS: Final = "server.address"
PORT: Final = "server.port"
Expand Down
5 changes: 5 additions & 0 deletions litellm/llms/anthropic/chat/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2205,6 +2205,10 @@ def calculate_usage(
inference_geo: Optional[str] = None
if "inference_geo" in _usage and _usage["inference_geo"] is not None:
inference_geo = _usage["inference_geo"]
service_tier = cast(
str | None,
_usage.get("service_tier"), # any-ok: untyped usage dict
)

if (
"cache_creation_input_tokens" in _usage
Expand Down Expand Up @@ -2298,6 +2302,7 @@ def calculate_usage(
),
inference_geo=inference_geo,
speed=speed,
service_tier=service_tier,
)
return usage

Expand Down
23 changes: 18 additions & 5 deletions litellm/llms/anthropic/cost_calculation.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
import litellm


def _compute_cache_only_cost(model_info: "ModelInfo", usage: "Usage") -> float:
def _compute_cache_only_cost(
model_info: "ModelInfo", usage: "Usage", service_tier: str | None = None
) -> float:
"""
Return only the cache-related portion of the prompt cost (cache read + cache write).

Expand All @@ -36,7 +38,9 @@ def _compute_cache_only_cost(model_info: "ModelInfo", usage: "Usage") -> float:
cache_creation_cost,
cache_creation_cost_above_1hr,
cache_read_cost,
) = _get_token_base_cost(model_info=model_info, usage=usage)
) = _get_token_base_cost(
model_info=model_info, usage=usage, service_tier=service_tier
)

cache_cost = float(prompt_tokens_details["cache_hit_tokens"]) * cache_read_cost

Expand All @@ -56,19 +60,26 @@ def _compute_cache_only_cost(model_info: "ModelInfo", usage: "Usage") -> float:
return cache_cost


def cost_per_token(model: str, usage: "Usage") -> Tuple[float, float]:
def cost_per_token(
model: str, usage: "Usage", service_tier: str | None = None
) -> Tuple[float, float]:
"""
Calculates the cost per token for a given model, prompt tokens, and completion tokens.

Input:
- model: str, the model name without provider prefix
- usage: LiteLLM Usage block, containing anthropic caching information
- service_tier: the service tier the request was served at (e.g. "priority"),
read from the Anthropic response usage and used to select tier-specific pricing

Returns:
Tuple[float, float] - prompt_cost_in_usd, completion_cost_in_usd
"""
prompt_cost, completion_cost = generic_cost_per_token(
model=model, usage=usage, custom_llm_provider="anthropic"
model=model,
usage=usage,
custom_llm_provider="anthropic",
service_tier=service_tier,
)

# Apply provider_specific_entry multipliers for geo/speed routing
Expand All @@ -89,7 +100,9 @@ def cost_per_token(model: str, usage: "Usage") -> Tuple[float, float]:
multiplier *= provider_specific_entry.get("fast", 1.0)

if multiplier != 1.0:
cache_cost = _compute_cache_only_cost(model_info=model_info, usage=usage)
cache_cost = _compute_cache_only_cost(
model_info=model_info, usage=usage, service_tier=service_tier
)
prompt_cost = (prompt_cost - cache_cost) * multiplier + cache_cost
completion_cost *= multiplier
except Exception:
Expand Down
2 changes: 2 additions & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,8 @@ class LiteLLMRoutes(enum.Enum):
# vector stores
"/vector_stores",
"/v1/vector_stores",
"/vector_stores/{vector_store_id}",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: View-only users can mutate vector stores

Adding the bare vector-store route to openai_routes makes RouteChecks.is_llm_api_route() return true for GET, POST, and DELETE on /v1/vector_stores/{vector_store_id}. That path is allowed before the view-only write checks run, so an INTERNAL_USER_VIEW_ONLY key can now update or delete vector stores it can access; keep this out of the blanket LLM route group or make the route check method-aware so only reads are allowed for view-only roles.

"/v1/vector_stores/{vector_store_id}",
"/vector_stores/{vector_store_id}/search",
"/v1/vector_stores/{vector_store_id}/search",
"/vector_stores/{vector_store_id}/files",
Expand Down
167 changes: 167 additions & 0 deletions litellm/proxy/common_utils/model_listing_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""Team-scoped (BYOK) model-name translation for the model listing endpoints.

`/v1/models`, `/models`, and `GET /v1/models/{id}` should surface the public
`team_public_model_name` rather than the internal routing key
`model_name_{team_id}_{uuid}`, consistent with `/v1/model/info`. The internal
key still routes regardless; this is a presentation-layer swap only and does not
touch access-group or auth semantics (see issue #28382). Operators can pin the
legacy internal names with `general_settings.use_team_public_model_name: false`.
"""

from __future__ import annotations

from collections.abc import Mapping
from typing import TYPE_CHECKING, cast

if TYPE_CHECKING:
from litellm.router import Router


class TeamModelNameTranslator:
"""Translates internal team routing keys to their public names for the model
listing/retrieve responses. Stateless; the live router and general_settings
are injected per call so the unit tests can drive it without globals.
"""

@staticmethod
def _internal_public_pair(model: object) -> tuple[str, str] | None:
"""`(internal_routing_key, public_name)` for a team-scoped row, else None."""
if not isinstance(model, dict):
return None
model_dict = cast(dict[str, object], model) # any-ok: checked
model_info_raw: object = model_dict.get("model_info")
if not isinstance(model_info_raw, Mapping):
return None
model_info = cast(Mapping[str, object], model_info_raw) # any-ok: checked
team_id = model_info.get("team_id")
team_public = model_info.get("team_public_model_name")
name = model_dict.get("model_name")
if (
isinstance(team_id, str)
and isinstance(team_public, str)
and isinstance(name, str)
and team_id
and team_public
and name.startswith(f"model_name_{team_id}_")
):
return name, team_public
return None

@staticmethod
def _is_enabled(general_settings: Mapping[str, object]) -> bool:
return general_settings.get("use_team_public_model_name", True) is not False

@staticmethod
def build_internal_to_public_map(
llm_router: "Router | None",
general_settings: Mapping[str, object],
) -> dict[str, str]:
"""Internal team routing key -> public `team_public_model_name`.

Empty when disabled via the legacy flag, the router is absent, or the
router model list is malformed.
"""
if llm_router is None or not TeamModelNameTranslator._is_enabled(
general_settings
):
return {}
router_model_list = llm_router.get_model_list()
if not isinstance(router_model_list, list):
return {}
return dict(
pair
for pair in (
TeamModelNameTranslator._internal_public_pair(model)
for model in router_model_list
)
if pair is not None
)

@staticmethod
def _response_to_lookup_map(
model_names: list[str],
internal_to_public: dict[str, str],
) -> dict[str, str]:
"""Map each public response id to the first internal lookup id seen in
`model_names`, preserving first-occurrence order. First-wins keeps list
and retrieve in agreement on which accessible deployment a shared public
id resolves to: a global iterated before a colliding team alias stays
the listed entry, and sibling team rows collapse to their first
occurrence.
"""
result: dict[str, str] = {}
for name in model_names:
result.setdefault(internal_to_public.get(name, name), name)
return result

@staticmethod
def listing_entries(
model_names: list[str],
llm_router: "Router | None",
general_settings: Mapping[str, object],
) -> list[tuple[str, str]]:
"""`(response_id, metadata_lookup_id)` for each listed model, de-duplicated
by response_id while preserving order.

For team-scoped rows `response_id` is the public name shown to the client,
while `metadata_lookup_id` stays the internal routing key so downstream
metadata/fallback lookups (keyed by the routing name) still resolve. The
lookup id is always one of `model_names` (the caller's accessible set), so
a public name shared across teams never resolves to another team's
internal key. Both ids are identical for unmapped names (globals,
access-group keys).
"""
internal_to_public = TeamModelNameTranslator.build_internal_to_public_map(
llm_router, general_settings
)
if not internal_to_public:
return [(name, name) for name in model_names]
return list(
TeamModelNameTranslator._response_to_lookup_map(
model_names, internal_to_public
).items()
)

@staticmethod
def translate_listing(
model_names: list[str],
llm_router: "Router | None",
general_settings: Mapping[str, object],
) -> list[str]:
"""Public-name view of `model_names` (the `response_id` of each listing
entry). Sibling deployments sharing a public name collapse to one entry
while preserving order; unmapped names pass through.
"""
return [
entry[0]
for entry in TeamModelNameTranslator.listing_entries(
model_names, llm_router, general_settings
)
]

@staticmethod
def resolve_public_name(
model_id: str,
available_models: list[str],
llm_router: "Router | None",
general_settings: Mapping[str, object],
) -> str:
"""Resolve a public team name back to the internal routing key the router
indexes by, so `GET /v1/models/{id}` accepts the name the listing returns.

Resolution is restricted to `available_models` (the caller's accessible
set) so colliding public names across teams never resolve across an access
boundary. Uses the same first-occurrence dedup as `listing_entries` so a
public id advertised by `/v1/models` resolves to the same internal
deployment that the listing's metadata was built from. Returns `model_id`
unchanged when it is not an accessible public team name (already-internal
names and globals pass through).
"""
internal_to_public = TeamModelNameTranslator.build_internal_to_public_map(
llm_router, general_settings
)
if not internal_to_public:
return model_id
return TeamModelNameTranslator._response_to_lookup_map(
available_models, internal_to_public
).get(model_id, model_id)
Loading
Loading