mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_user_spend_slack_alerts
This commit is contained in:
commit
0a04173f2c
167 changed files with 19236 additions and 1700 deletions
18
.github/workflows/check-ui-api-types.yml
vendored
18
.github/workflows/check-ui-api-types.yml
vendored
|
|
@ -83,6 +83,24 @@ jobs:
|
|||
if: steps.changes.outputs.relevant == 'true'
|
||||
run: uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
|
||||
|
||||
- name: Regenerate the lazy OpenAPI snapshot
|
||||
if: steps.changes.outputs.relevant == 'true'
|
||||
run: uv run --no-sync python -m litellm.proxy._lazy_openapi_snapshot
|
||||
|
||||
- name: Fail if the lazy OpenAPI snapshot is stale
|
||||
if: steps.changes.outputs.relevant == 'true'
|
||||
run: |
|
||||
if ! git diff --exit-code -- litellm/proxy/_lazy_openapi_snapshot.json; then
|
||||
echo "::error file=litellm/proxy/_lazy_openapi_snapshot.json::The lazy OpenAPI snapshot is out of sync with the lazily loaded routes."
|
||||
echo ""
|
||||
echo "A lazily loaded route or model changed without regenerating the snapshot that /openapi.json serves for unloaded features."
|
||||
echo "To fix, run from the repo root:"
|
||||
echo " uv run python -m litellm.proxy._lazy_openapi_snapshot"
|
||||
echo "then run npm run gen:api from ui/litellm-dashboard and commit both files."
|
||||
exit 1
|
||||
fi
|
||||
echo "_lazy_openapi_snapshot.json is in sync with the lazily loaded routes."
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.changes.outputs.relevant == 'true'
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@
|
|||
"limit": 56
|
||||
},
|
||||
"reportPrivateUsage": {
|
||||
"limit": 1810
|
||||
"limit": 1808
|
||||
},
|
||||
"reportRedeclaration": {
|
||||
"limit": 8
|
||||
|
|
@ -135,7 +135,7 @@
|
|||
"limit": 21
|
||||
},
|
||||
"reportUnusedFunction": {
|
||||
"limit": 139
|
||||
"limit": 138
|
||||
},
|
||||
"reportUnusedImport": {
|
||||
"limit": 544
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@
|
|||
-- partitioned, so existing installs are unaffected until you run this.
|
||||
--
|
||||
-- IMPORTANT
|
||||
-- * After partitioning, `prisma db push` (including the proxy's
|
||||
-- --use_prisma_db_push startup mode) is NOT supported: it tries to rewrite
|
||||
-- the primary key back to ("request_id"), which Postgres rejects on a
|
||||
-- partitioned table. The proxy detects this and exits with guidance.
|
||||
-- Use the default startup path (`prisma migrate deploy`) instead.
|
||||
-- * Test on a staging copy first and take a backup.
|
||||
-- * Postgres cannot convert a populated table to partitioned in place, so this
|
||||
-- renames the old table aside and creates a fresh partitioned table.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""
|
||||
Polls LiteLLM_ManagedObjectTable to check if the response is complete.
|
||||
Cost tracking is handled automatically by the get-responses call.
|
||||
Cost tracking is handled by the get-responses call, which prices normally only because the
|
||||
poll stamps itself with BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN; user-facing reads of the
|
||||
same route are non-inference and free.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
|
@ -9,12 +11,14 @@ from typing import TYPE_CHECKING, Dict, Optional, cast
|
|||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import (
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY,
|
||||
MANAGED_OBJECT_STALENESS_CUTOFF_DAYS,
|
||||
MAX_OBJECTS_PER_POLL_CYCLE,
|
||||
STALE_OBJECT_CLEANUP_BATCH_SIZE,
|
||||
)
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
from litellm.types.utils import BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.proxy.utils import PrismaClient, ProxyLogging
|
||||
|
|
@ -113,7 +117,8 @@ class CheckResponsesCost:
|
|||
Check if background responses are complete and track their cost.
|
||||
- Get all status="queued" or "in_progress" and file_purpose="response" jobs
|
||||
- Query the provider to check if response is complete
|
||||
- Cost is automatically tracked by the get-responses call
|
||||
- Cost is tracked by the get-responses call, billed because the poll is stamped
|
||||
with BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
||||
- Mark responses in a terminal state as complete in the database
|
||||
"""
|
||||
try:
|
||||
|
|
@ -153,6 +158,7 @@ class CheckResponsesCost:
|
|||
# Prepare metadata with model information for cost tracking
|
||||
litellm_metadata = {
|
||||
"user_api_key_user_id": job.created_by or "default-user-id",
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY: BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN,
|
||||
}
|
||||
|
||||
# Add model information if available
|
||||
|
|
|
|||
|
|
@ -40,6 +40,65 @@ def _get_prisma_env() -> dict:
|
|||
|
||||
_MIGRATION_TS_RE = re.compile(r"^(\d{14})_")
|
||||
|
||||
_SPEND_LOGS_ALTER_RE = re.compile(r'^ALTER\s+TABLE\s+"LiteLLM_SpendLogs"\s', re.IGNORECASE)
|
||||
_SPEND_LOGS_ARTIFACT_DROP_RE = re.compile(
|
||||
r'^DROP\s+TABLE\s+"LiteLLM_SpendLogs_[^"]*"', re.IGNORECASE
|
||||
)
|
||||
_SPEND_LOGS_PK_CLAUSE_RE = re.compile(
|
||||
r'^(?:DROP\s+CONSTRAINT\s+"[^"]*_pkey"'
|
||||
r'|ADD\s+(?:CONSTRAINT\s+"[^"]*"\s+)?PRIMARY\s+KEY\s*\([^)]*\))$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
PARTITIONED_SPEND_LOGS_PUSH_ERROR = (
|
||||
"LiteLLM_SpendLogs is a partitioned table (see db_scripts/partition_spend_logs.sql), "
|
||||
"so its primary key must include the partition key (\"startTime\"). `prisma db push` "
|
||||
"reconciles the database against schema.prisma, which declares the unpartitioned "
|
||||
"primary key (\"request_id\"), and Postgres rejects that rewrite with: unique "
|
||||
"constraint on partitioned table must include all partitioning columns. Start the "
|
||||
"proxy without --use_prisma_db_push so it uses `prisma migrate deploy`, which only "
|
||||
"applies shipped migrations and leaves the partitioned primary key alone."
|
||||
)
|
||||
|
||||
|
||||
def _without_sql_comments(statement: str) -> str:
|
||||
return "\n".join(
|
||||
line
|
||||
for line in statement.splitlines()
|
||||
if line.strip() and not line.strip().startswith("--")
|
||||
).strip()
|
||||
|
||||
|
||||
def _without_spend_logs_pk_clauses(statement: str) -> Optional[str]:
|
||||
prefix_match = _SPEND_LOGS_ALTER_RE.match(statement)
|
||||
if not prefix_match:
|
||||
return statement
|
||||
kept = tuple(
|
||||
clause.strip()
|
||||
for clause in statement[prefix_match.end():].split(",\n")
|
||||
if not _SPEND_LOGS_PK_CLAUSE_RE.match(clause.strip())
|
||||
)
|
||||
if not kept:
|
||||
return None
|
||||
return statement[: prefix_match.end()] + ",\n".join(kept)
|
||||
|
||||
|
||||
def filter_partitioned_spend_logs_diff(diff_sql: str) -> str:
|
||||
"""Drop statements from a `prisma migrate diff` script that fight the
|
||||
SpendLogs partitioning runbook (db_scripts/partition_spend_logs.sql): the
|
||||
primary-key rewrite on "LiteLLM_SpendLogs", which Postgres rejects on a
|
||||
partitioned table, and drops of runbook artifacts such as
|
||||
"LiteLLM_SpendLogs_legacy"."""
|
||||
kept = tuple(
|
||||
filtered
|
||||
for statement in diff_sql.split(";")
|
||||
for bare in (_without_sql_comments(statement),)
|
||||
if bare and not _SPEND_LOGS_ARTIFACT_DROP_RE.match(bare)
|
||||
for filtered in (_without_spend_logs_pk_clauses(bare),)
|
||||
if filtered is not None
|
||||
)
|
||||
return "".join(f"{statement};\n\n" for statement in kept)
|
||||
|
||||
|
||||
def _migration_timestamp(name: str) -> int:
|
||||
"""Extract the leading `YYYYMMDDHHMMSS` timestamp from a migration name.
|
||||
|
|
@ -355,7 +414,24 @@ class ProxyExtrasDBManager:
|
|||
return
|
||||
logger.info(f"Migration diff created at {diff_sql_path}")
|
||||
|
||||
if ProxyExtrasDBManager.spend_logs_is_partitioned():
|
||||
filtered_sql = filter_partitioned_spend_logs_diff(
|
||||
diff_sql_path.read_text()
|
||||
)
|
||||
diff_sql_path.write_text(filtered_sql)
|
||||
logger.info(
|
||||
"LiteLLM_SpendLogs is partitioned; removed its primary-key "
|
||||
"rewrite and partitioning artifacts from the drift script"
|
||||
)
|
||||
if not filtered_sql.strip():
|
||||
logger.info("Drift script is empty after filtering; nothing to apply")
|
||||
if not mark_all_applied:
|
||||
return
|
||||
ProxyExtrasDBManager._mark_migrations_applied(migrations_dir)
|
||||
return
|
||||
|
||||
# 2. Run prisma db execute to apply the migration
|
||||
applied_ok = False
|
||||
try:
|
||||
logger.info("Running prisma db execute to apply the migration diff...")
|
||||
result = subprocess.run(
|
||||
|
|
@ -376,6 +452,7 @@ class ProxyExtrasDBManager:
|
|||
)
|
||||
logger.info(f"prisma db execute stdout: {result.stdout}")
|
||||
logger.info("✅ Migration diff applied successfully")
|
||||
applied_ok = True
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning(f"Failed to apply migration diff: {e.stderr}")
|
||||
except subprocess.TimeoutExpired:
|
||||
|
|
@ -384,6 +461,16 @@ class ProxyExtrasDBManager:
|
|||
# 3. Mark all migrations as applied
|
||||
if not mark_all_applied:
|
||||
return
|
||||
if not applied_ok:
|
||||
logger.warning(
|
||||
"Drift script failed to apply; NOT marking migrations as "
|
||||
"applied so a later migration run can retry them"
|
||||
)
|
||||
return
|
||||
ProxyExtrasDBManager._mark_migrations_applied(migrations_dir)
|
||||
|
||||
@staticmethod
|
||||
def _mark_migrations_applied(migrations_dir: str):
|
||||
migration_names = ProxyExtrasDBManager._get_migration_names(migrations_dir)
|
||||
logger.info(f"Resolving {len(migration_names)} migrations")
|
||||
for migration_name in migration_names:
|
||||
|
|
@ -410,6 +497,55 @@ class ProxyExtrasDBManager:
|
|||
f"Failed to resolve migration {migration_name}: {e.stderr}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def spend_logs_is_partitioned() -> bool:
|
||||
"""True when the connected database's LiteLLM_SpendLogs is a
|
||||
partitioned table in Prisma's target schema (the `schema` URL param,
|
||||
falling back to Prisma's default target, public), i.e. the operator
|
||||
ran db_scripts/partition_spend_logs.sql. Returns False when psycopg is
|
||||
unavailable or the database cannot be reached, preserving the
|
||||
pre-existing behavior in those cases."""
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
return False
|
||||
|
||||
try:
|
||||
import psycopg
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
cleaned_url = ProxyExtrasDBManager._strip_prisma_query_params(database_url)
|
||||
try:
|
||||
with psycopg.connect(
|
||||
cleaned_url, connect_timeout=10, autocommit=True
|
||||
) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 "
|
||||
"FROM pg_partitioned_table pt "
|
||||
"JOIN pg_class c ON c.oid = pt.partrelid "
|
||||
"JOIN pg_namespace n ON n.oid = c.relnamespace "
|
||||
"WHERE c.relname = 'LiteLLM_SpendLogs' "
|
||||
" AND n.nspname = %s",
|
||||
(
|
||||
ProxyExtrasDBManager._prisma_schema_param(database_url)
|
||||
or "public",
|
||||
),
|
||||
).fetchone()
|
||||
except (psycopg.OperationalError, psycopg.DatabaseError):
|
||||
return False
|
||||
return row is not None
|
||||
|
||||
@staticmethod
|
||||
def _prisma_schema_param(url: str) -> Optional[str]:
|
||||
"""The `schema` query param Prisma uses to pick its target schema,
|
||||
or None when the URL does not set one."""
|
||||
from urllib.parse import urlparse, parse_qsl
|
||||
|
||||
return next(
|
||||
(v for k, v in parse_qsl(urlparse(url).query) if k == "schema"),
|
||||
None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _strip_prisma_query_params(url: str) -> str:
|
||||
"""Remove Prisma-specific query params (connection_limit, pool_timeout,
|
||||
|
|
@ -528,7 +664,8 @@ class ProxyExtrasDBManager:
|
|||
migrations_dir = ProxyExtrasDBManager._get_prisma_dir()
|
||||
|
||||
if not use_migrate:
|
||||
# Preserve `prisma db push` path unchanged.
|
||||
if ProxyExtrasDBManager.spend_logs_is_partitioned():
|
||||
raise RuntimeError(PARTITIONED_SPEND_LOGS_PUSH_ERROR)
|
||||
original_dir = os.getcwd()
|
||||
os.chdir(migrations_dir)
|
||||
try:
|
||||
|
|
@ -972,6 +1109,8 @@ class ProxyExtrasDBManager:
|
|||
)
|
||||
raise
|
||||
else:
|
||||
if ProxyExtrasDBManager.spend_logs_is_partitioned():
|
||||
raise RuntimeError(PARTITIONED_SPEND_LOGS_PUSH_ERROR)
|
||||
# Use prisma db push with increased timeout
|
||||
subprocess.run(
|
||||
[_get_prisma_command(), "db", "push", "--accept-data-loss"],
|
||||
|
|
|
|||
|
|
@ -445,6 +445,7 @@ max_ui_session_budget: Optional[float] = (
|
|||
1.0 # USD budget for each dashboard login session (playground, test connection)
|
||||
)
|
||||
internal_user_budget_duration: Optional[str] = None
|
||||
budget_rollover: bool = False # carry spend beyond max_budget into the next window instead of zeroing it
|
||||
tag_budget_config: Optional[Dict[str, "BudgetConfig"]] = None
|
||||
max_end_user_budget: Optional[float] = None
|
||||
max_end_user_budget_id: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -1473,6 +1473,12 @@ LITELLM_PROXY_MASTER_KEY_ALIAS: Final = "litellm_proxy_master_key"
|
|||
# ``ProxyLogging._handle_logging_proxy_only_error``.
|
||||
LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL: Final = "litellm_no_upstream_llm_call"
|
||||
|
||||
# Key/team metadata fields naming the OTel Resource ``service.name``, highest
|
||||
# precedence first. Shared between the OTel v2 tenant router (which reads them
|
||||
# out of ``user_api_key_auth_metadata``) and proxy request setup (which re-applies
|
||||
# the key's values after the team metadata merge so a key outranks its team).
|
||||
OTEL_SERVICE_NAME_METADATA_KEYS: Final = ("otel_service_name_override", "otel_service_name")
|
||||
|
||||
# Key Rotation Constants
|
||||
LITELLM_KEY_ROTATION_ENABLED: Final = os.getenv("LITELLM_KEY_ROTATION_ENABLED", "false")
|
||||
LITELLM_KEY_ROTATION_CHECK_INTERVAL_SECONDS: Final = int(
|
||||
|
|
@ -1647,6 +1653,7 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES: Final = [
|
|||
"enable_anthropic_prompt_caching",
|
||||
"anthropic_prompt_caching_ttl",
|
||||
"max_ui_session_budget",
|
||||
"budget_rollover",
|
||||
]
|
||||
SPECIAL_LITELLM_AUTH_TOKEN: Final = ["ui-token"]
|
||||
DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL = int(os.getenv("DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL", 60))
|
||||
|
|
@ -1813,6 +1820,43 @@ BROWSER_SECURITY_HEADERS: Final[frozenset[str]] = frozenset(
|
|||
|
||||
UNSAFE_PROXY_RESPONSE_HEADERS: Final[frozenset[str]] = HTTP_FRAMING_HEADERS | BROWSER_SECURITY_HEADERS
|
||||
|
||||
# A retrieved response replays the usage of the call that created it, so pricing these
|
||||
# read/management routes like inference bills the same tokens twice.
|
||||
NON_INFERENCE_CALL_TYPES: Final[frozenset[str]] = frozenset(
|
||||
{
|
||||
"get_responses",
|
||||
"aget_responses",
|
||||
"delete_responses",
|
||||
"adelete_responses",
|
||||
"cancel_responses",
|
||||
"acancel_responses",
|
||||
"list_input_items",
|
||||
"alist_input_items",
|
||||
"vector_store_create",
|
||||
"avector_store_create",
|
||||
"vector_store_retrieve",
|
||||
"avector_store_retrieve",
|
||||
"vector_store_list",
|
||||
"avector_store_list",
|
||||
"vector_store_update",
|
||||
"avector_store_update",
|
||||
"vector_store_delete",
|
||||
"avector_store_delete",
|
||||
"vector_store_file_create",
|
||||
"avector_store_file_create",
|
||||
"vector_store_file_list",
|
||||
"avector_store_file_list",
|
||||
"vector_store_file_retrieve",
|
||||
"avector_store_file_retrieve",
|
||||
"vector_store_file_content",
|
||||
"avector_store_file_content",
|
||||
"vector_store_file_update",
|
||||
"avector_store_file_update",
|
||||
"vector_store_file_delete",
|
||||
"avector_store_file_delete",
|
||||
}
|
||||
)
|
||||
|
||||
# PTU reservation rollup writes rows to LiteLLM_DailyTeamSpend with this
|
||||
# sentinel api_key so PTU flat cost stays distinguishable from real per-request
|
||||
# spend under the table's composite unique constraint.
|
||||
|
|
|
|||
|
|
@ -557,9 +557,10 @@ def cost_per_token(
|
|||
)
|
||||
elif call_type == "atranscription" or call_type == "transcription":
|
||||
if _transcription_usage_has_token_details(usage_block):
|
||||
return openai_cost_per_token(
|
||||
return generic_cost_per_token(
|
||||
model=model_without_prefix,
|
||||
usage=usage_block,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
service_tier=service_tier,
|
||||
data_residency=data_residency,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class CustomBatchLogger(CustomLogger):
|
|||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
async def periodic_flush(self):
|
||||
async def periodic_flush(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(self.flush_interval)
|
||||
verbose_logger.debug("CustomLogger periodic flush after %s seconds", self.flush_interval)
|
||||
|
|
|
|||
|
|
@ -149,7 +149,6 @@ class PromptManager:
|
|||
)
|
||||
self.prompts[template_id] = template
|
||||
except Exception:
|
||||
# Optional: print(f"Error loading prompt from JSON: {template_id}")
|
||||
pass
|
||||
|
||||
def _load_prompt_file(self, file_path: str | Path, prompt_id: str) -> PromptTemplate:
|
||||
|
|
|
|||
395
litellm/integrations/newrelic/newrelic_metrics.py
Normal file
395
litellm/integrations/newrelic/newrelic_metrics.py
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
"""
|
||||
New Relic Metric API Integration - sends per-team cost/usage metrics to /metric/v1
|
||||
|
||||
NR Reference API: https://docs.newrelic.com/docs/data-apis/ingest-apis/metric-api/introduction-metric-api/
|
||||
|
||||
`async_log_success_event` / `async_log_failure_event` queue one record per request;
|
||||
at flush the queue is aggregated by (team, model group, model, provider, status)
|
||||
into count/summary metrics. `interval.ms` is the real window between flushes,
|
||||
computed at flush time.
|
||||
|
||||
Team-scoped by construction: the ingest key is injected explicitly and there is
|
||||
deliberately no environment-variable fallback, so a team's metrics are never sent
|
||||
with the proxy operator's credentials (mirrors ``allow_env_credentials=False`` on
|
||||
the Datadog team logger).
|
||||
|
||||
Error policy on flush: 4xx drops the batch (a retry would fail identically; 403
|
||||
is a permanent credential failure), 5xx/network re-queues capped at
|
||||
``max_queue_size`` records with the oldest dropped.
|
||||
|
||||
For batching specific details see CustomBatchLogger class
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import gzip
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Mapping
|
||||
from math import ceil
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from httpx import HTTPStatusError, Response
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.custom_batch_logger import CustomBatchLogger
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
get_async_httpx_client,
|
||||
httpxSpecialProvider,
|
||||
)
|
||||
from litellm.types.integrations.newrelic import (
|
||||
NEWRELIC_DEFAULT_REGION,
|
||||
NEWRELIC_METRIC_ATTRIBUTE_MAX_LEN,
|
||||
NEWRELIC_METRIC_COMPLETION_TOKENS,
|
||||
NEWRELIC_METRIC_COST_USD,
|
||||
NEWRELIC_METRIC_ENDPOINT_BY_REGION,
|
||||
NEWRELIC_METRIC_PROMPT_TOKENS,
|
||||
NEWRELIC_METRIC_REQUEST_DURATION_MS,
|
||||
NEWRELIC_METRIC_REQUESTS,
|
||||
NEWRELIC_METRIC_TOTAL_TOKENS,
|
||||
NEWRELIC_METRICS_MAX_BATCH_SIZE,
|
||||
NEWRELIC_METRICS_MAX_DRAIN_PASSES,
|
||||
NEWRELIC_METRICS_MAX_RETRY_QUEUE_SIZE,
|
||||
NewRelicCountMetric,
|
||||
NewRelicMetric,
|
||||
NewRelicMetricCommon,
|
||||
NewRelicMetricEnvelope,
|
||||
NewRelicMetricRecord,
|
||||
NewRelicSummaryMetric,
|
||||
NewRelicSummaryValue,
|
||||
)
|
||||
from litellm.types.utils import StandardLoggingPayload
|
||||
|
||||
# 408 (request timeout) and 429 (rate limit) are transient client errors the
|
||||
# Metric API expects a retry on, unlike 400/403 which a retry would only repeat.
|
||||
_RETRYABLE_CLIENT_STATUSES: Final = frozenset({408, 429})
|
||||
|
||||
|
||||
def resolve_newrelic_metric_endpoint(newrelic_region: str | None) -> str:
|
||||
if not newrelic_region:
|
||||
return NEWRELIC_METRIC_ENDPOINT_BY_REGION[NEWRELIC_DEFAULT_REGION]
|
||||
endpoint: Final = NEWRELIC_METRIC_ENDPOINT_BY_REGION.get(newrelic_region.lower())
|
||||
if endpoint is None:
|
||||
verbose_logger.warning(
|
||||
"New Relic: unknown newrelic_region %r; supported regions: %s. Using the default (US) endpoint.",
|
||||
newrelic_region,
|
||||
", ".join(sorted(NEWRELIC_METRIC_ENDPOINT_BY_REGION)),
|
||||
)
|
||||
return NEWRELIC_METRIC_ENDPOINT_BY_REGION[NEWRELIC_DEFAULT_REGION]
|
||||
return endpoint
|
||||
|
||||
|
||||
def _metric_record_from_payload(standard_logging_object: StandardLoggingPayload) -> NewRelicMetricRecord:
|
||||
metadata: Final = standard_logging_object.get("metadata")
|
||||
team_id: Final = ((metadata.get("user_api_key_team_id") or metadata.get("team_id")) if metadata else None) or ""
|
||||
team_alias: Final = (
|
||||
(metadata.get("user_api_key_team_alias") or metadata.get("team_alias")) if metadata else None
|
||||
) or ""
|
||||
return NewRelicMetricRecord(
|
||||
team_id=team_id,
|
||||
team_alias=team_alias,
|
||||
model_group=standard_logging_object.get("model_group") or "",
|
||||
model=standard_logging_object.get("model") or "",
|
||||
custom_llm_provider=standard_logging_object.get("custom_llm_provider") or "",
|
||||
status=str(standard_logging_object.get("status") or "success"),
|
||||
response_cost=float(standard_logging_object.get("response_cost") or 0.0),
|
||||
prompt_tokens=int(standard_logging_object.get("prompt_tokens") or 0),
|
||||
completion_tokens=int(standard_logging_object.get("completion_tokens") or 0),
|
||||
total_tokens=int(standard_logging_object.get("total_tokens") or 0),
|
||||
duration_ms=float(standard_logging_object.get("response_time") or 0.0) * 1000.0,
|
||||
)
|
||||
|
||||
|
||||
def _bucket_metrics(bucket_records: tuple[NewRelicMetricRecord, ...]) -> tuple[NewRelicMetric, ...]:
|
||||
first: Final = bucket_records[0]
|
||||
attributes: Final[Mapping[str, str]] = { # mutable-ok: JSON leaf; safe_dumps stringifies MappingProxyType
|
||||
key: value[:NEWRELIC_METRIC_ATTRIBUTE_MAX_LEN]
|
||||
for key, value in (
|
||||
("team_id", first.team_id),
|
||||
("team_alias", first.team_alias),
|
||||
("model_group", first.model_group),
|
||||
("model", first.model),
|
||||
("custom_llm_provider", first.custom_llm_provider),
|
||||
("status", first.status),
|
||||
)
|
||||
if value
|
||||
}
|
||||
durations: Final = tuple(record.duration_ms for record in bucket_records)
|
||||
counts: Final[tuple[tuple[str, float], ...]] = (
|
||||
(NEWRELIC_METRIC_REQUESTS, float(len(bucket_records))),
|
||||
(NEWRELIC_METRIC_COST_USD, sum(record.response_cost for record in bucket_records)),
|
||||
(NEWRELIC_METRIC_PROMPT_TOKENS, float(sum(record.prompt_tokens for record in bucket_records))),
|
||||
(NEWRELIC_METRIC_COMPLETION_TOKENS, float(sum(record.completion_tokens for record in bucket_records))),
|
||||
(NEWRELIC_METRIC_TOTAL_TOKENS, float(sum(record.total_tokens for record in bucket_records))),
|
||||
)
|
||||
count_metrics: Final[tuple[NewRelicMetric, ...]] = tuple(
|
||||
NewRelicCountMetric(name=name, type="count", value=value, attributes=attributes) for name, value in counts
|
||||
)
|
||||
summary_metric: Final = NewRelicSummaryMetric(
|
||||
name=NEWRELIC_METRIC_REQUEST_DURATION_MS,
|
||||
type="summary",
|
||||
value=NewRelicSummaryValue(
|
||||
count=len(durations),
|
||||
sum=sum(durations),
|
||||
min=min(durations),
|
||||
max=max(durations),
|
||||
),
|
||||
attributes=attributes,
|
||||
)
|
||||
return (*count_metrics, summary_metric)
|
||||
|
||||
|
||||
def build_metric_payload(
|
||||
records: tuple[NewRelicMetricRecord, ...],
|
||||
*,
|
||||
window_start: float,
|
||||
now: float,
|
||||
) -> tuple[NewRelicMetricEnvelope, ...]:
|
||||
"""Aggregates records into one Metric API envelope for the flush window."""
|
||||
interval_ms: Final = max(1, int((now - window_start) * 1000))
|
||||
bucket_keys: Final = tuple(dict.fromkeys(record.bucket_key for record in records))
|
||||
metrics: Final = tuple(
|
||||
metric
|
||||
for key in bucket_keys
|
||||
for metric in _bucket_metrics(tuple(record for record in records if record.bucket_key == key))
|
||||
)
|
||||
common: Final[NewRelicMetricCommon] = {
|
||||
"timestamp": int(window_start * 1000),
|
||||
"interval.ms": interval_ms,
|
||||
}
|
||||
return (NewRelicMetricEnvelope(common=common, metrics=metrics),)
|
||||
|
||||
|
||||
class NewRelicMetricsLogger(CustomBatchLogger):
|
||||
def __init__(
|
||||
self,
|
||||
newrelic_api_key: str,
|
||||
newrelic_region: str | None = None,
|
||||
) -> None:
|
||||
if not newrelic_api_key:
|
||||
raise ValueError(
|
||||
"newrelic_api_key is required for NewRelicMetricsLogger; "
|
||||
"team-scoped metrics never fall back to environment credentials"
|
||||
)
|
||||
self.newrelic_api_key: Final = newrelic_api_key
|
||||
self.metric_api_url: Final = resolve_newrelic_metric_endpoint(newrelic_region)
|
||||
self.async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.LoggingCallback)
|
||||
self._stopped: bool = False
|
||||
self._drain_lock = asyncio.Lock()
|
||||
asyncio.create_task(self.periodic_flush())
|
||||
self.flush_lock = asyncio.Lock()
|
||||
super().__init__(
|
||||
flush_lock=self.flush_lock,
|
||||
batch_size=NEWRELIC_METRICS_MAX_BATCH_SIZE,
|
||||
max_queue_size=NEWRELIC_METRICS_MAX_RETRY_QUEUE_SIZE,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Ends the periodic flush loop; called on DynamicLoggingCache eviction.
|
||||
|
||||
Schedules one final drain of anything still queued, so eviction never
|
||||
silently discards records. Guarded so it can never raise into the
|
||||
cache's eviction path.
|
||||
"""
|
||||
self._stopped = True
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(self._final_drain())
|
||||
except Exception: # noqa: BLE001 # no running loop / shutdown; the periodic loop's final drain still runs
|
||||
verbose_logger.debug("New Relic Metrics: could not schedule final drain on stop()", exc_info=True)
|
||||
|
||||
async def _drain_with_retry(self) -> None:
|
||||
"""Deliver everything queued on a stopped logger, or drop it with a log.
|
||||
|
||||
A stopped logger has no periodic loop left, so every post-stop path
|
||||
funnels through here. ``_drain_lock`` serializes drains: a callback that
|
||||
appends and starts its own drain queues behind the running one instead
|
||||
of racing it. Each pass attempts the whole current queue in
|
||||
``batch_size`` chunks, unlike the periodic path it does not stop at the
|
||||
first failing chunk, so a persistently failing head never starves the
|
||||
tail. Only after ``_MAX_DRAIN_PASSES`` against a permanently failing
|
||||
destination is the remainder dropped, and then only the records that were
|
||||
queued when this drain began, so every dropped record got the full retry
|
||||
budget: a record a callback appended mid-drain is not in that snapshot,
|
||||
so it is left for its own serialized drain rather than dropped after
|
||||
fewer attempts, and is never stranded.
|
||||
"""
|
||||
async with self._drain_lock:
|
||||
attempted: Final = tuple(self.log_queue)
|
||||
for _pass in range(NEWRELIC_METRICS_MAX_DRAIN_PASSES):
|
||||
await self._drain_flush_once()
|
||||
if not self.log_queue:
|
||||
return
|
||||
if _pass < NEWRELIC_METRICS_MAX_DRAIN_PASSES - 1:
|
||||
await asyncio.sleep(2**_pass)
|
||||
async with self.flush_lock:
|
||||
tried_ids: Final = frozenset(id(record) for record in attempted)
|
||||
survivors: Final = tuple(record for record in self.log_queue if id(record) not in tried_ids)
|
||||
dropped: Final = len(self.log_queue) - len(survivors)
|
||||
if dropped:
|
||||
verbose_logger.warning(
|
||||
"New Relic Metrics: dropping %s records after %s drain passes",
|
||||
dropped,
|
||||
NEWRELIC_METRICS_MAX_DRAIN_PASSES,
|
||||
)
|
||||
self.log_queue[:] = list(survivors) # mutable-ok: leave late arrivals for the next serialized drain
|
||||
|
||||
async def _drain_flush_once(self) -> None:
|
||||
"""Attempt every queued record once, in ``batch_size`` chunks, without
|
||||
stopping at the first failing chunk so a persistently failing head does
|
||||
not starve the tail (the periodic ``flush_queue`` deliberately stops
|
||||
instead). Takes the queue under ``flush_lock`` and re-queues only the
|
||||
chunks a 5xx/network error left undelivered, so records a concurrent
|
||||
request appends during the sends survive for the next pass."""
|
||||
async with self.flush_lock:
|
||||
pending: Final = tuple(self.log_queue)
|
||||
window_start: Final = self.last_flush_time
|
||||
self.last_flush_time = time.time()
|
||||
del self.log_queue[:]
|
||||
if not pending:
|
||||
return
|
||||
chunks: Final = tuple(
|
||||
pending[start : start + self.batch_size] for start in range(0, len(pending), self.batch_size)
|
||||
)
|
||||
delivered: Final = tuple([await self._classify_and_send(chunk, window_start) for chunk in chunks])
|
||||
failed: Final = tuple(record for chunk, ok in zip(chunks, delivered) for record in (() if ok else chunk))
|
||||
if failed:
|
||||
self._requeue(failed)
|
||||
|
||||
async def _final_drain(self) -> None:
|
||||
await self._drain_with_retry()
|
||||
|
||||
async def periodic_flush(self) -> None:
|
||||
while not self._stopped:
|
||||
await asyncio.sleep(self.flush_interval)
|
||||
if self._stopped:
|
||||
break
|
||||
await self.flush_queue()
|
||||
await self._final_drain()
|
||||
|
||||
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time) -> None:
|
||||
try:
|
||||
await self._log_async_event(standard_logging_object=kwargs.get("standard_logging_object", None))
|
||||
except Exception as e: # noqa: BLE001 # logging must never break the request path
|
||||
verbose_logger.exception("New Relic Metrics Layer Error - %s\n%s", e, traceback.format_exc())
|
||||
|
||||
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time) -> None:
|
||||
try:
|
||||
await self._log_async_event(standard_logging_object=kwargs.get("standard_logging_object", None))
|
||||
except Exception as e: # noqa: BLE001 # logging must never break the request path
|
||||
verbose_logger.exception("New Relic Metrics Layer Error - %s\n%s", e, traceback.format_exc())
|
||||
|
||||
async def _log_async_event(self, standard_logging_object: StandardLoggingPayload | None) -> None:
|
||||
if standard_logging_object is None:
|
||||
raise ValueError("standard_logging_object not found in kwargs")
|
||||
self.log_queue.append(_metric_record_from_payload(standard_logging_object))
|
||||
if self._stopped:
|
||||
# A stopped logger has no periodic loop left; an in-flight callback
|
||||
# that appends after the eviction drain delivers its own record.
|
||||
await self._drain_with_retry()
|
||||
return
|
||||
if len(self.log_queue) >= self.batch_size:
|
||||
await self.flush_queue()
|
||||
|
||||
async def flush_queue(self) -> None:
|
||||
async with self.flush_lock:
|
||||
window_start: Final = self.last_flush_time
|
||||
self.last_flush_time = time.time()
|
||||
queued: Final = len(self.log_queue)
|
||||
if not queued:
|
||||
return
|
||||
verbose_logger.debug("New Relic Metrics: Flushing %s queued records", queued)
|
||||
# Bounded by what is queued now: records appended mid-flush belong to
|
||||
# the next window, and looping until empty would never end under load.
|
||||
for _chunk in range(ceil(queued / self.batch_size)):
|
||||
if not await self.async_send_batch(window_start=window_start):
|
||||
return
|
||||
|
||||
async def async_send_batch(self, window_start: float | None = None) -> bool:
|
||||
"""Sends the oldest ``batch_size`` records only, so a queue grown past that
|
||||
by re-queues cannot breach the Metric API data point cap in one request.
|
||||
Returns False once a chunk fails and is re-queued, so the caller stops."""
|
||||
if not self.log_queue:
|
||||
return False
|
||||
|
||||
batch_to_send: Final[tuple[NewRelicMetricRecord, ...]] = tuple(self.log_queue[: self.batch_size])
|
||||
del self.log_queue[: len(batch_to_send)]
|
||||
|
||||
delivered: Final = await self._classify_and_send(
|
||||
batch_to_send, window_start if window_start is not None else self.last_flush_time
|
||||
)
|
||||
if not delivered:
|
||||
self._requeue(batch_to_send)
|
||||
return delivered
|
||||
|
||||
async def _classify_and_send(self, batch: tuple[NewRelicMetricRecord, ...], window_start: float) -> bool:
|
||||
"""Send one chunk and classify the outcome, never touching the queue.
|
||||
Returns True when the batch is done with (delivered on any 2xx, or a 4xx
|
||||
a retry would only repeat, 403 being a permanent bad-key rejection), and
|
||||
False when a 5xx or network error means the caller should re-queue it.
|
||||
|
||||
``AsyncHTTPHandler.post`` raises ``HTTPStatusError`` on any non-2xx, so a
|
||||
4xx never returns a response here; the status is read off the raised
|
||||
error to keep the client-error path (drop) distinct from 5xx (retry)."""
|
||||
payload: Final = build_metric_payload(records=batch, window_start=window_start, now=time.time())
|
||||
try:
|
||||
status = (
|
||||
await self.async_send_compressed_data(payload)
|
||||
).status_code # rebind-ok: reassigned from the raised HTTPStatusError below
|
||||
except HTTPStatusError as e:
|
||||
status = e.response.status_code
|
||||
except Exception as e: # noqa: BLE001 # transport/network failure re-queues the batch
|
||||
verbose_logger.warning(
|
||||
"New Relic Metrics: network error sending %s records, will retry - %s",
|
||||
len(batch),
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
if 200 <= status < 300:
|
||||
return True
|
||||
|
||||
if 400 <= status < 500 and status not in _RETRYABLE_CLIENT_STATUSES:
|
||||
verbose_logger.warning(
|
||||
"New Relic Metrics: %s from Metric API%s, dropping %s records.",
|
||||
status,
|
||||
" (permanent credential failure: invalid or revoked team ingest key)" if status == 403 else "",
|
||||
len(batch),
|
||||
)
|
||||
return True
|
||||
|
||||
verbose_logger.warning(
|
||||
"New Relic Metrics: %s from Metric API, will retry %s records",
|
||||
status,
|
||||
len(batch),
|
||||
)
|
||||
return False
|
||||
|
||||
def _requeue(self, batch: tuple[NewRelicMetricRecord, ...]) -> None:
|
||||
"""Prepends ``batch`` in place (never by assignment: records appended by
|
||||
concurrent requests during the flush await must survive), keeping
|
||||
chronological order so the cap drops the oldest records first."""
|
||||
self.log_queue[:0] = batch
|
||||
overflow: Final = len(self.log_queue) - self.max_queue_size
|
||||
if overflow > 0:
|
||||
del self.log_queue[:overflow]
|
||||
verbose_logger.warning(
|
||||
"New Relic Metrics: retry queue exceeded max_queue_size=%s; dropped %s oldest records.",
|
||||
self.max_queue_size,
|
||||
overflow,
|
||||
)
|
||||
|
||||
async def async_send_compressed_data(self, payload: tuple[NewRelicMetricEnvelope, ...]) -> Response:
|
||||
compressed_data: Final = gzip.compress(safe_dumps(payload).encode("utf-8"))
|
||||
headers: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"Content-Encoding": "gzip",
|
||||
"Api-Key": self.newrelic_api_key,
|
||||
}
|
||||
)
|
||||
return await self.async_client.post(
|
||||
url=self.metric_api_url,
|
||||
data=compressed_data,
|
||||
headers=headers,
|
||||
)
|
||||
90
litellm/integrations/newrelic/newrelic_team_handler.py
Normal file
90
litellm/integrations/newrelic/newrelic_team_handler.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""
|
||||
New Relic Team Handler
|
||||
|
||||
Used to get the NewRelicMetricsLogger for a given request.
|
||||
Handles Key/Team Based New Relic metrics, following the same pattern as DataDogHandler.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.litellm_logging import StandardCallbackDynamicParams
|
||||
|
||||
from .newrelic_metrics import NewRelicMetricsLogger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import DynamicLoggingCache
|
||||
|
||||
|
||||
class NewRelicLoggingConfig(TypedDict):
|
||||
newrelic_api_key: ReadOnly[str | None]
|
||||
newrelic_region: ReadOnly[str | None]
|
||||
|
||||
|
||||
class NewRelicHandler:
|
||||
@staticmethod
|
||||
def get_newrelic_logger_for_request(
|
||||
standard_callback_dynamic_params: StandardCallbackDynamicParams,
|
||||
in_memory_dynamic_logger_cache: "DynamicLoggingCache",
|
||||
) -> NewRelicMetricsLogger:
|
||||
"""
|
||||
Get a team-scoped NewRelicMetricsLogger for a given request.
|
||||
|
||||
Resolves and caches per-team NewRelicMetricsLogger instances using
|
||||
DynamicLoggingCache, keyed by the team's New Relic credentials. Each unique
|
||||
set of credentials gets its own logger instance with its own batch/flush loop.
|
||||
|
||||
Note: This handler is only called when a team-scoped newrelic_api_key is
|
||||
present. The trace logger for the ``newrelic`` callback (OTel v2 / legacy
|
||||
agent) is managed separately by _init_custom_logger_compatible_class via
|
||||
_in_memory_loggers.
|
||||
"""
|
||||
_credentials: Final = NewRelicHandler.get_dynamic_newrelic_logging_config(
|
||||
standard_callback_dynamic_params=standard_callback_dynamic_params,
|
||||
)
|
||||
|
||||
temp_newrelic_logger = in_memory_dynamic_logger_cache.get_cache(
|
||||
credentials=_credentials, service_name="newrelic"
|
||||
)
|
||||
|
||||
if temp_newrelic_logger is None:
|
||||
temp_newrelic_logger = NewRelicHandler._create_newrelic_logger_from_credentials(
|
||||
credentials=_credentials,
|
||||
in_memory_dynamic_logger_cache=in_memory_dynamic_logger_cache,
|
||||
)
|
||||
|
||||
return temp_newrelic_logger
|
||||
|
||||
@staticmethod
|
||||
def _create_newrelic_logger_from_credentials(
|
||||
credentials: NewRelicLoggingConfig,
|
||||
in_memory_dynamic_logger_cache: "DynamicLoggingCache",
|
||||
) -> NewRelicMetricsLogger:
|
||||
newrelic_logger: Final = NewRelicMetricsLogger(
|
||||
newrelic_api_key=credentials.get("newrelic_api_key") or "",
|
||||
newrelic_region=credentials.get("newrelic_region"),
|
||||
)
|
||||
in_memory_dynamic_logger_cache.set_cache(
|
||||
credentials=credentials,
|
||||
service_name="newrelic",
|
||||
logging_obj=newrelic_logger,
|
||||
)
|
||||
verbose_logger.debug("New Relic: Created and cached new NewRelicMetricsLogger for team-scoped credentials")
|
||||
return newrelic_logger
|
||||
|
||||
@staticmethod
|
||||
def get_dynamic_newrelic_logging_config(
|
||||
standard_callback_dynamic_params: StandardCallbackDynamicParams,
|
||||
) -> NewRelicLoggingConfig:
|
||||
return NewRelicLoggingConfig(
|
||||
newrelic_api_key=standard_callback_dynamic_params.get("newrelic_api_key"),
|
||||
newrelic_region=standard_callback_dynamic_params.get("newrelic_region"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _dynamic_newrelic_credentials_are_passed(
|
||||
standard_callback_dynamic_params: StandardCallbackDynamicParams,
|
||||
) -> bool:
|
||||
return standard_callback_dynamic_params.get("newrelic_api_key") is not None
|
||||
|
|
@ -22,6 +22,7 @@ from litellm.integrations.opentelemetry_utils.gen_ai_semconv import (
|
|||
)
|
||||
from litellm.integrations.otel.model.db_endpoint import db_span_attributes
|
||||
from litellm.integrations.otel.model.semconv import Metric
|
||||
from litellm.litellm_core_utils.internal_call_metadata import is_unbilled_non_inference_call_from_params
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
from litellm.litellm_core_utils.secret_redaction import redact_string
|
||||
from litellm.litellm_core_utils.service_tier_utils import (
|
||||
|
|
@ -1643,7 +1644,12 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
|
|||
|
||||
if self._operation_duration_histogram:
|
||||
self._operation_duration_histogram.record(duration_s, attributes=common_attrs)
|
||||
if response_obj and (usage := response_obj.get("usage")) and self._token_usage_histogram:
|
||||
if (
|
||||
self._token_usage_histogram
|
||||
and response_obj
|
||||
and not is_unbilled_non_inference_call_from_params(kwargs.get("call_type"), params, response_obj)
|
||||
and (usage := response_obj.get("usage"))
|
||||
):
|
||||
in_attrs: Final = {**common_attrs, TOKEN_TYPE_ATTRIBUTE: "input"}
|
||||
out_attrs: Final = {**common_attrs, TOKEN_TYPE_ATTRIBUTE: "output"}
|
||||
self._token_usage_histogram.record(usage.get("prompt_tokens", 0), attributes=in_attrs)
|
||||
|
|
@ -1719,6 +1725,11 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
|
|||
if not self._time_per_output_token_histogram:
|
||||
return
|
||||
|
||||
if is_unbilled_non_inference_call_from_params(
|
||||
kwargs.get("call_type"), kwargs.get("litellm_params"), response_obj
|
||||
):
|
||||
return
|
||||
|
||||
# Get completion tokens from response_obj
|
||||
completion_tokens = None
|
||||
if response_obj and (usage := response_obj.get("usage")):
|
||||
|
|
@ -2488,7 +2499,14 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
|
|||
|
||||
self._set_service_tier_attributes(span=span, standard_logging_payload=standard_logging_payload)
|
||||
|
||||
usage: Final = response_obj and response_obj.get("usage")
|
||||
usage: Final = (
|
||||
response_obj.get("usage")
|
||||
if response_obj
|
||||
and not is_unbilled_non_inference_call_from_params(
|
||||
kwargs.get("call_type"), litellm_params, response_obj
|
||||
)
|
||||
else None
|
||||
)
|
||||
if usage:
|
||||
self.safe_set_attribute(
|
||||
span=span,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class GenAIOperation(str, Enum):
|
|||
EXECUTE_TOOL = "execute_tool" # MCP tool-call spans
|
||||
LITELLM_VECTOR_STORE_MANAGEMENT = "litellm.vector_store_management"
|
||||
LITELLM_VECTOR_STORE_FILE_MANAGEMENT = "litellm.vector_store_file_management"
|
||||
LITELLM_RESPONSES_MANAGEMENT = "litellm.responses_management"
|
||||
LITELLM_MODERATION = "litellm.moderation"
|
||||
|
||||
|
||||
|
|
@ -383,6 +384,14 @@ _OPERATION_BY_CALL_TYPE: Final[dict[str, GenAIOperation]] = {
|
|||
"aembedding": GenAIOperation.EMBEDDINGS,
|
||||
"responses": GenAIOperation.CHAT,
|
||||
"aresponses": GenAIOperation.CHAT,
|
||||
"get_responses": GenAIOperation.LITELLM_RESPONSES_MANAGEMENT,
|
||||
"aget_responses": GenAIOperation.LITELLM_RESPONSES_MANAGEMENT,
|
||||
"delete_responses": GenAIOperation.LITELLM_RESPONSES_MANAGEMENT,
|
||||
"adelete_responses": GenAIOperation.LITELLM_RESPONSES_MANAGEMENT,
|
||||
"cancel_responses": GenAIOperation.LITELLM_RESPONSES_MANAGEMENT,
|
||||
"acancel_responses": GenAIOperation.LITELLM_RESPONSES_MANAGEMENT,
|
||||
"list_input_items": GenAIOperation.LITELLM_RESPONSES_MANAGEMENT,
|
||||
"alist_input_items": GenAIOperation.LITELLM_RESPONSES_MANAGEMENT,
|
||||
"image_generation": GenAIOperation.GENERATE_CONTENT,
|
||||
"aimage_generation": GenAIOperation.GENERATE_CONTENT,
|
||||
"moderation": GenAIOperation.LITELLM_MODERATION,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from litellm.integrations.otel.model.semconv import (
|
|||
resolve_provider,
|
||||
)
|
||||
from litellm.integrations.otel.model.utils import to_seconds
|
||||
from litellm.litellm_core_utils.internal_call_metadata import is_unbilled_non_inference_call_from_params
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
|
||||
|
||||
|
|
@ -198,16 +199,21 @@ class GenAIMetricRecorder:
|
|||
) -> None:
|
||||
common_attrs: Final = self._filter_attributes(self._bounded_attributes(kwargs))
|
||||
duration_s: Final = (end_time - start_time).total_seconds()
|
||||
usage_is_replayed: Final = is_unbilled_non_inference_call_from_params(
|
||||
kwargs.get("call_type"), kwargs.get("litellm_params"), response_obj
|
||||
)
|
||||
|
||||
self._metrics.operation_duration.record(duration_s, attributes=common_attrs)
|
||||
self._record_token_usage(response_obj, common_attrs)
|
||||
if not usage_is_replayed:
|
||||
self._record_token_usage(response_obj, common_attrs)
|
||||
|
||||
cost: Final = kwargs.get("response_cost")
|
||||
if cost:
|
||||
self._metrics.token_cost.record(cost, attributes=common_attrs)
|
||||
|
||||
self._record_time_to_first_token(kwargs, common_attrs)
|
||||
self._record_time_per_output_token(kwargs, response_obj, end_time, duration_s, common_attrs)
|
||||
if not usage_is_replayed:
|
||||
self._record_time_per_output_token(kwargs, response_obj, end_time, duration_s, common_attrs)
|
||||
self._record_response_duration(kwargs, end_time, common_attrs)
|
||||
|
||||
def record_failure(
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@
|
|||
|
||||
When a request carries team/key vendor credentials in
|
||||
``standard_callback_dynamic_params``, or the key/team config resolved at auth
|
||||
names a destination project, its spans must export through a
|
||||
``TracerProvider`` whose OTLP headers carry those credentials / that project.
|
||||
``TenantTracerCache`` builds and caches one provider per distinct
|
||||
(credentials, project) pair, and otherwise hands back the logger's default
|
||||
tracer. This lets a single logger fan requests out to many tenants without
|
||||
needing a logger per tenant.
|
||||
names a destination project or a service name, its spans must export through a
|
||||
``TracerProvider`` whose OTLP headers carry those credentials / that project,
|
||||
or whose Resource carries that ``service.name``. ``TenantTracerCache`` builds
|
||||
and caches one provider per distinct (credentials, project, service name)
|
||||
tuple, and otherwise hands back the logger's default tracer. This lets a
|
||||
single logger fan requests out to many tenants without needing a logger per
|
||||
tenant.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
|
@ -22,6 +23,7 @@ from opentelemetry.sdk.trace import TracerProvider
|
|||
from opentelemetry.trace import Tracer
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import OTEL_SERVICE_NAME_METADATA_KEYS
|
||||
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
|
||||
from litellm.integrations.otel.plumbing.providers import (
|
||||
build_tracer_provider,
|
||||
|
|
@ -65,8 +67,30 @@ _MAX_RETIRED_PROVIDERS: Final = 64
|
|||
|
||||
_HeaderItems: TypeAlias = tuple[tuple[str, str], ...]
|
||||
|
||||
_RouteKey: TypeAlias = tuple[_HeaderItems, _HeaderItems, str | None, str | None]
|
||||
|
||||
_NO_HEADERS: Final[Mapping[str, str]] = MappingProxyType({})
|
||||
|
||||
#: Key/team config fields naming the Resource ``service.name``, highest
|
||||
#: precedence first. Read only from ``user_api_key_auth_metadata`` (the config
|
||||
#: the proxy resolved at auth), never from client-supplied request metadata:
|
||||
#: the service name picks the dataset/service traces land in (Honeycomb routes
|
||||
#: datasets by it), so a caller must not be able to choose one.
|
||||
_SERVICE_NAME_KEYS: Final = OTEL_SERVICE_NAME_METADATA_KEYS
|
||||
|
||||
|
||||
def tenant_service_name(auth_metadata: Mapping[str, str] | None) -> str | None:
|
||||
"""The per-request ``service.name`` override for this key/team, if any.
|
||||
|
||||
``None`` keeps the env-configured default (``OTEL_SERVICE_NAME``).
|
||||
"""
|
||||
if not auth_metadata:
|
||||
return None
|
||||
return next(
|
||||
(stripped for key in _SERVICE_NAME_KEYS if (stripped := (auth_metadata.get(key) or "").strip())),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _shutdown_provider(provider: TracerProvider) -> None:
|
||||
"""Flush + stop an evicted provider's processors (reclaims their threads).
|
||||
|
|
@ -116,7 +140,7 @@ class TenantRoute:
|
|||
|
||||
|
||||
class TenantTracerCache:
|
||||
"""Credential/project-scoped ``TracerProvider`` cache keyed by the routing headers."""
|
||||
"""Tenant-scoped ``TracerProvider`` cache keyed by routing headers and service name."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -131,7 +155,7 @@ class TenantTracerCache:
|
|||
# thread-pool workers concurrently with the event loop, so cache
|
||||
# updates, span counts, and retirement must be atomic.
|
||||
self._lock: Final = threading.Lock()
|
||||
self._providers: OrderedDict[tuple[_HeaderItems, _HeaderItems, str | None], TracerProvider] = (
|
||||
self._providers: OrderedDict[_RouteKey, TracerProvider] = (
|
||||
OrderedDict() # mutable-ok: bounded LRU; eviction needs in-place ordered mutation
|
||||
)
|
||||
self._open_span_counts: dict[TracerProvider, int] = {} # mutable-ok: live refcount state
|
||||
|
|
@ -172,10 +196,11 @@ class TenantTracerCache:
|
|||
) -> TenantRoute:
|
||||
"""Return the tracer (and trace-detachment flag) for this request.
|
||||
|
||||
Use ``default`` unless the request's dynamic credentials or its key/team
|
||||
project require a scoped tracer, in which case build (or reuse) one. The
|
||||
cache is a bounded LRU: the least-recently-used provider is flushed and
|
||||
shut down on overflow so its exporter threads don't accumulate.
|
||||
Use ``default`` unless the request's dynamic credentials, its key/team
|
||||
project, or its key/team service name require a scoped tracer, in
|
||||
which case build (or reuse) one. The cache is a bounded LRU: the
|
||||
least-recently-used provider is flushed and shut down on overflow so
|
||||
its exporter threads don't accumulate.
|
||||
|
||||
A routed provider is returned already held — its open-span count is
|
||||
incremented in the same critical section as the cache update — so a
|
||||
|
|
@ -184,7 +209,8 @@ class TenantTracerCache:
|
|||
"""
|
||||
credential_headers: Final = dynamic_otlp_headers(self._callback_name, dynamic_params) or _NO_HEADERS
|
||||
project_headers: Final = self._project_headers(auth_metadata)
|
||||
if not credential_headers and not project_headers:
|
||||
service_name: Final = tenant_service_name(auth_metadata)
|
||||
if not credential_headers and not project_headers and service_name is None:
|
||||
return TenantRoute(tracer=default, detached=False)
|
||||
# A fixed per-integration region endpoint (New Relic us/eu), never a
|
||||
# caller-supplied host; ``None`` keeps the preset's own endpoint.
|
||||
|
|
@ -193,9 +219,12 @@ class TenantTracerCache:
|
|||
tuple(sorted(credential_headers.items())),
|
||||
tuple(sorted(project_headers.items())),
|
||||
endpoint,
|
||||
service_name,
|
||||
)
|
||||
with self._lock:
|
||||
provider: Final = self._cached_provider_locked(cache_key, credential_headers, project_headers, endpoint)
|
||||
provider: Final = self._cached_provider_locked(
|
||||
cache_key, credential_headers, project_headers, endpoint, service_name
|
||||
)
|
||||
self._open_span_counts[provider] = self._open_span_counts.get(provider, 0) + 1
|
||||
evicted: Final = self._evicted_on_overflow_locked()
|
||||
if evicted is not None:
|
||||
|
|
@ -208,16 +237,19 @@ class TenantTracerCache:
|
|||
|
||||
def _cached_provider_locked(
|
||||
self,
|
||||
cache_key: tuple[_HeaderItems, _HeaderItems, str | None],
|
||||
cache_key: _RouteKey,
|
||||
credential_headers: Mapping[str, str],
|
||||
project_headers: Mapping[str, str],
|
||||
endpoint: str | None,
|
||||
service_name: str | None,
|
||||
) -> TracerProvider:
|
||||
cached: Final = self._providers.get(cache_key)
|
||||
if cached is not None:
|
||||
self._providers.move_to_end(cache_key)
|
||||
return cached
|
||||
built: Final = build_tracer_provider(self._routed_config(credential_headers, project_headers, endpoint))
|
||||
built: Final = build_tracer_provider(
|
||||
self._routed_config(credential_headers, project_headers, endpoint, service_name)
|
||||
)
|
||||
self._providers[cache_key] = built
|
||||
return built
|
||||
|
||||
|
|
@ -267,6 +299,7 @@ class TenantTracerCache:
|
|||
credential_headers: Mapping[str, str],
|
||||
project_headers: Mapping[str, str],
|
||||
endpoint: str | None = None,
|
||||
service_name: str | None = None,
|
||||
) -> OpenTelemetryV2Config:
|
||||
"""Clone the config, rewriting headers on the callback's own exporter.
|
||||
|
||||
|
|
@ -285,7 +318,10 @@ class TenantTracerCache:
|
|||
self._routed_exporter(spec, credential_headers, project_headers, endpoint)
|
||||
for spec in self._config.exporters
|
||||
]
|
||||
return self._config.model_copy(update={"exporters": exporters})
|
||||
update: Final = (
|
||||
{"exporters": exporters} if service_name is None else {"exporters": exporters, "service_name": service_name}
|
||||
)
|
||||
return self._config.model_copy(update=update)
|
||||
|
||||
def _routed_exporter(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -2341,6 +2341,7 @@ def exception_type(
|
|||
litellm_response_headers: Final = _get_response_headers(original_exception=original_exception)
|
||||
try:
|
||||
error_str = redact_string(str(original_exception)) if _ENABLE_SECRET_REDACTION else str(original_exception)
|
||||
extra_information = ""
|
||||
if model or custom_llm_provider:
|
||||
if hasattr(original_exception, "message"):
|
||||
error_str = (
|
||||
|
|
@ -2357,7 +2358,6 @@ def exception_type(
|
|||
# Common Extra information needed for all providers
|
||||
# We pass num retries, api_base, vertex_deployment etc to the exception here
|
||||
################################################################################
|
||||
extra_information = ""
|
||||
try:
|
||||
_api_base: Final = litellm.get_api_base(model=model, optional_params=extra_kwargs)
|
||||
messages: Final = litellm.get_first_chars_messages(kwargs=completion_kwargs)
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ from __future__ import annotations
|
|||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY
|
||||
from litellm.types.utils import InternalCallOrigin
|
||||
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY, NON_INFERENCE_CALL_TYPES
|
||||
from litellm.types.utils import BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN, InternalCallOrigin
|
||||
|
||||
BUDGET_RESERVATION_METADATA_KEYS: Final = frozenset({"user_api_key_budget_reservation"})
|
||||
|
||||
|
|
@ -45,6 +45,60 @@ budget-checked like the request that spawned it. Everything else on the parent's
|
|||
be a lie on a sub-call that runs after it returned."""
|
||||
|
||||
|
||||
def is_background_response(response: object) -> bool:
|
||||
"""Whether a retrieved object is a response created with ``background=true``.
|
||||
|
||||
Such a create returns ``status="queued"`` and no usage at all, so nothing has billed the
|
||||
job by the time anyone reads it back. Accepts the response as a mapping or a model,
|
||||
because the callers hold it in both shapes.
|
||||
"""
|
||||
if isinstance(response, Mapping):
|
||||
return response.get("background") is True
|
||||
return getattr(response, "background", None) is True
|
||||
|
||||
|
||||
def is_unbilled_non_inference_call(
|
||||
call_type: str | None,
|
||||
metadata: Mapping[str, object] | None,
|
||||
response: object,
|
||||
) -> bool:
|
||||
"""A read/management route priced at zero, because the usage it reports belongs to the
|
||||
call that created the object it just read.
|
||||
|
||||
Retrieving a background response is the exception, and the enterprise cost poller's read
|
||||
is the same exception seen from the other side: that job's create billed nothing, so its
|
||||
retrieval is the only place the spend is ever visible. Pricing those at zero would lose
|
||||
the spend rather than deduplicate it.
|
||||
"""
|
||||
if call_type not in NON_INFERENCE_CALL_TYPES:
|
||||
return False
|
||||
if is_background_response(response):
|
||||
return False
|
||||
if metadata is None:
|
||||
return True
|
||||
return metadata.get(INTERNAL_CALL_ORIGIN_METADATA_KEY) != BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
||||
|
||||
|
||||
def is_unbilled_non_inference_call_from_params(
|
||||
call_type: str | None,
|
||||
litellm_params: Mapping[str, object] | None,
|
||||
response: object,
|
||||
) -> bool:
|
||||
""":func:`is_unbilled_non_inference_call` for callers holding raw ``litellm_params``.
|
||||
|
||||
The call-type membership test runs first so that inference traffic, which is every
|
||||
request in a normal workload, never pays for the metadata merge behind it.
|
||||
"""
|
||||
if call_type not in NON_INFERENCE_CALL_TYPES:
|
||||
return False
|
||||
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
|
||||
|
||||
metadata: Final = (
|
||||
StandardLoggingPayloadSetup.merge_litellm_metadata(litellm_params) if litellm_params is not None else None
|
||||
)
|
||||
return is_unbilled_non_inference_call(call_type, metadata, response)
|
||||
|
||||
|
||||
def sanitize_user_api_key_auth(auth: object) -> object:
|
||||
"""Copy of the auth object with its budget reservation removed; the cost callback
|
||||
falls back to reading the reservation from inside the auth object."""
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ from litellm.integrations.mlflow import MlflowLogger
|
|||
from litellm.integrations.sqs import SQSLogger
|
||||
from litellm.litellm_core_utils.core_helpers import is_expected_client_error, reconstruct_model_name
|
||||
from litellm.litellm_core_utils.get_litellm_params import get_litellm_params
|
||||
from litellm.litellm_core_utils.internal_call_metadata import is_unbilled_non_inference_call
|
||||
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import (
|
||||
cost_breakdown_with_guardrail,
|
||||
guardrail_information_cost,
|
||||
|
|
@ -612,37 +613,60 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
processed_list: Final[list[str | Callable | CustomLogger]] = []
|
||||
for callback in callback_list:
|
||||
if isinstance(callback, str) and callback in litellm._known_custom_logger_compatible_callbacks:
|
||||
# For callbacks that support team-scoped credentials (e.g. datadog),
|
||||
# pass only the relevant dynamic params as custom_logger_init_args.
|
||||
_custom_logger_init_args: dict | None = None
|
||||
if callback == "datadog":
|
||||
# dd_* params are blocked from standard_callback_dynamic_params
|
||||
# (request-level security); only the proxy-stamped team/key
|
||||
# callback vars are admin-configured and trusted.
|
||||
_custom_logger_init_args = {k: v for k, v in self._trusted_callback_vars if k.startswith("dd_")}
|
||||
|
||||
callback_class = _init_custom_logger_compatible_class(
|
||||
callback,
|
||||
internal_usage_cache=None,
|
||||
llm_router=None,
|
||||
custom_logger_init_args=_custom_logger_init_args,
|
||||
)
|
||||
if callback_class is not None:
|
||||
processed_list.append(callback_class)
|
||||
for callback_instance in self._resolve_dynamic_callback_string(callback):
|
||||
processed_list.append(callback_instance)
|
||||
|
||||
# If processing dynamic_success_callbacks, add to dynamic_async_success_callbacks
|
||||
if dynamic_callbacks_type == "success":
|
||||
if self.dynamic_async_success_callbacks is None:
|
||||
self.dynamic_async_success_callbacks = []
|
||||
self.dynamic_async_success_callbacks.append(callback_class)
|
||||
self.dynamic_async_success_callbacks.append(callback_instance)
|
||||
elif dynamic_callbacks_type == "failure":
|
||||
if self.dynamic_async_failure_callbacks is None:
|
||||
self.dynamic_async_failure_callbacks = []
|
||||
self.dynamic_async_failure_callbacks.append(callback_class)
|
||||
self.dynamic_async_failure_callbacks.append(callback_instance)
|
||||
else:
|
||||
processed_list.append(callback)
|
||||
return processed_list
|
||||
|
||||
def _resolve_dynamic_callback_string(self, callback: str) -> "tuple[CustomLogger, ...]":
|
||||
"""
|
||||
Resolve a known callback name to the logger instance(s) it dispatches to.
|
||||
|
||||
For callbacks that support team-scoped credentials (datadog, newrelic),
|
||||
only the proxy-stamped team/key callback vars are passed as
|
||||
custom_logger_init_args: dd_*/newrelic_* params are blocked from
|
||||
standard_callback_dynamic_params (request-level security), so the
|
||||
trusted-vars channel is the only way credentials reach a per-team logger.
|
||||
"""
|
||||
_trusted_var_prefix: Final = "dd_" if callback == "datadog" else "newrelic_" if callback == "newrelic" else None
|
||||
_custom_logger_init_args: Final[dict | None] = (
|
||||
{k: v for k, v in self._trusted_callback_vars if k.startswith(_trusted_var_prefix)}
|
||||
if _trusted_var_prefix is not None
|
||||
else None
|
||||
)
|
||||
|
||||
callback_class: Final = _init_custom_logger_compatible_class(
|
||||
callback,
|
||||
internal_usage_cache=None,
|
||||
llm_router=None,
|
||||
custom_logger_init_args=_custom_logger_init_args,
|
||||
)
|
||||
if callback_class is None:
|
||||
return ()
|
||||
|
||||
# With team creds, "newrelic" resolves to the per-team METRICS logger;
|
||||
# resolve the name again without creds so the trace logger (OTel v2 /
|
||||
# legacy agent) keeps receiving this request.
|
||||
_newrelic_trace_class: Final = (
|
||||
_init_custom_logger_compatible_class(callback, internal_usage_cache=None, llm_router=None)
|
||||
if callback == "newrelic" and _custom_logger_init_args and _custom_logger_init_args.get("newrelic_api_key")
|
||||
else None
|
||||
)
|
||||
if _newrelic_trace_class is not None and _newrelic_trace_class is not callback_class:
|
||||
return (callback_class, _newrelic_trace_class)
|
||||
return (callback_class,)
|
||||
|
||||
def initialize_standard_callback_dynamic_params(self, kwargs: dict | None = None) -> StandardCallbackDynamicParams:
|
||||
"""
|
||||
Initialize the standard callback dynamic params from the kwargs
|
||||
|
|
@ -1586,6 +1610,11 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
if cache_hit is True:
|
||||
return 0.0
|
||||
|
||||
if is_unbilled_non_inference_call(
|
||||
self.call_type, StandardLoggingPayloadSetup.merge_litellm_metadata(self.litellm_params), result
|
||||
):
|
||||
return 0.0
|
||||
|
||||
transformed_result: Final = self._generate_content_result_as_model_response(result)
|
||||
if transformed_result is not None:
|
||||
result = transformed_result
|
||||
|
|
@ -4636,6 +4665,19 @@ def _init_custom_logger_compatible_class(
|
|||
_in_memory_loggers.append(gitlab_logger)
|
||||
return gitlab_logger
|
||||
elif logging_integration == "newrelic":
|
||||
if custom_logger_init_args.get("newrelic_api_key"):
|
||||
# Team-scoped credentials: per-team METRICS logger, isolated per
|
||||
# credential set via DynamicLoggingCache. The trace logger for
|
||||
# this name stays on the global path below.
|
||||
from litellm.integrations.newrelic.newrelic_team_handler import (
|
||||
NewRelicHandler,
|
||||
)
|
||||
|
||||
return NewRelicHandler.get_newrelic_logger_for_request(
|
||||
standard_callback_dynamic_params=custom_logger_init_args,
|
||||
in_memory_dynamic_logger_cache=in_memory_dynamic_logger_cache,
|
||||
)
|
||||
|
||||
_v2 = _maybe_construct_otel_v2("newrelic", _in_memory_loggers)
|
||||
if _v2 is not None:
|
||||
return _v2
|
||||
|
|
@ -5057,7 +5099,7 @@ class StandardLoggingPayloadSetup:
|
|||
return messages
|
||||
|
||||
@staticmethod
|
||||
def merge_litellm_metadata(litellm_params: dict) -> dict:
|
||||
def merge_litellm_metadata(litellm_params: Mapping[str, object]) -> dict:
|
||||
"""
|
||||
Merge both litellm_metadata and metadata from litellm_params.
|
||||
|
||||
|
|
@ -5819,7 +5861,7 @@ def get_standard_logging_object_payload(
|
|||
cache_hit: Final = kwargs.get("cache_hit", False)
|
||||
# Extract usage as a plain dict, avoiding Pydantic round-trip
|
||||
raw_usage_dict: Final = StandardLoggingPayloadSetup.get_usage_as_dict(
|
||||
response_obj=response_obj,
|
||||
response_obj=None if is_unbilled_non_inference_call(call_type, metadata, response_obj) else response_obj,
|
||||
combined_usage_object=cast(Usage | None, kwargs.get("combined_usage_object")),
|
||||
)
|
||||
usage_dict: Final = (
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ from typing import Any, Final, Literal
|
|||
|
||||
import litellm
|
||||
from litellm.constants import OPENAI_FILE_SEARCH_COST_PER_1K_CALLS
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import _get_web_search_requests
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import (
|
||||
get_web_search_requests_from_usage,
|
||||
)
|
||||
from litellm.types.llms.openai import (
|
||||
FileSearchTool,
|
||||
ResponsesAPIResponse,
|
||||
|
|
@ -368,7 +370,7 @@ class StandardBuiltInToolCostTracking:
|
|||
get_anthropic_web_search_requests_from_response,
|
||||
)
|
||||
|
||||
if usage is not None and (_get_web_search_requests(getattr(usage, "server_tool_use", None)) is not None):
|
||||
if usage is not None and (get_web_search_requests_from_usage(usage) is not None):
|
||||
return usage
|
||||
web_search_requests: Final = get_anthropic_web_search_requests_from_response(response_object)
|
||||
if web_search_requests is None:
|
||||
|
|
@ -416,7 +418,7 @@ class StandardBuiltInToolCostTracking:
|
|||
# Anthropic Claude (direct API and Vertex AI) uses server_tool_use.web_search_requests.
|
||||
# Without this check, Claude ModelResponse always falls through to return False
|
||||
# and _handle_web_search_cost() is never called.
|
||||
if hasattr(usage, "server_tool_use") and _get_web_search_requests(usage.server_tool_use) is not None:
|
||||
if get_web_search_requests_from_usage(usage) is not None:
|
||||
return True
|
||||
# xAI reports usage.server_side_tool_usage_details.web_search_calls; a searched
|
||||
# answer with no url_citation annotations has no other chat-path signal
|
||||
|
|
@ -429,16 +431,12 @@ class StandardBuiltInToolCostTracking:
|
|||
response_object=response_object, output_type="web_search_call"
|
||||
)
|
||||
elif usage is not None:
|
||||
if (
|
||||
hasattr(usage, "server_tool_use")
|
||||
and _get_web_search_requests(usage.server_tool_use) is not None
|
||||
or (
|
||||
hasattr(usage, "prompt_tokens_details")
|
||||
and usage.prompt_tokens_details is not None
|
||||
and isinstance(usage.prompt_tokens_details, PromptTokensDetailsWrapper)
|
||||
and hasattr(usage.prompt_tokens_details, "web_search_requests")
|
||||
and usage.prompt_tokens_details.web_search_requests is not None
|
||||
)
|
||||
if get_web_search_requests_from_usage(usage) is not None or (
|
||||
hasattr(usage, "prompt_tokens_details")
|
||||
and usage.prompt_tokens_details is not None
|
||||
and isinstance(usage.prompt_tokens_details, PromptTokensDetailsWrapper)
|
||||
and hasattr(usage.prompt_tokens_details, "web_search_requests")
|
||||
and usage.prompt_tokens_details.web_search_requests is not None
|
||||
):
|
||||
return True
|
||||
if _usage_reports_server_side_web_search_calls(usage):
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ def _get_token_detail_value(details: object, key: str) -> int | None:
|
|||
return value if isinstance(value, int) else None
|
||||
|
||||
|
||||
def _get_web_search_requests(server_tool_use: Any) -> int | None:
|
||||
def get_web_search_requests(server_tool_use: Any) -> int | None:
|
||||
"""
|
||||
Tolerantly read ``web_search_requests`` from a ``server_tool_use`` value
|
||||
that may be ``None``, a ``dict``, a ``ServerToolUse`` pydantic instance,
|
||||
|
|
@ -92,6 +92,16 @@ def _get_web_search_requests(server_tool_use: Any) -> int | None:
|
|||
return getattr(server_tool_use, "web_search_requests", None)
|
||||
|
||||
|
||||
def get_web_search_requests_from_usage(usage: Usage) -> int | None:
|
||||
"""Read ``web_search_requests`` from a ``Usage``'s ``server_tool_use``.
|
||||
|
||||
``Usage`` deletes unset optional fields from ``__dict__`` (see
|
||||
``SafeAttributeModel``), so direct attribute access can raise
|
||||
``AttributeError``; ``getattr`` with a default is required here.
|
||||
"""
|
||||
return get_web_search_requests(getattr(usage, "server_tool_use", None))
|
||||
|
||||
|
||||
def _is_above_128k(tokens: float) -> bool:
|
||||
if tokens > 128000:
|
||||
return True
|
||||
|
|
@ -889,11 +899,22 @@ def generic_cost_per_token(
|
|||
total_details: Final = text_tokens + cache_hit + audio_tokens + cache_creation + image_tokens + video_tokens
|
||||
has_double_counting: Final = (cache_hit > 0 or cache_creation > 0) and total_details > usage.prompt_tokens
|
||||
|
||||
if (text_tokens == 0 and prompt_tokens_details["image_count"] == 0) or has_double_counting:
|
||||
text_tokens = usage.prompt_tokens - cache_hit - audio_tokens - cache_creation - image_tokens - video_tokens
|
||||
if has_double_counting:
|
||||
# cached and per-modality counts are both subsets of prompt_tokens and may overlap, so a
|
||||
# modality can only bill what the cache did not already cover or the overlap is billed twice
|
||||
uncached_budget: Final = max(usage.prompt_tokens - cache_hit - cache_creation, 0)
|
||||
billable_audio: Final = min(audio_tokens, uncached_budget)
|
||||
billable_image: Final = min(image_tokens, uncached_budget - billable_audio)
|
||||
billable_video: Final = min(video_tokens, uncached_budget - billable_audio - billable_image)
|
||||
prompt_tokens_details["audio_tokens"] = billable_audio
|
||||
prompt_tokens_details["image_tokens"] = billable_image
|
||||
prompt_tokens_details["video_tokens"] = billable_video
|
||||
prompt_tokens_details["text_tokens"] = uncached_budget - billable_audio - billable_image - billable_video
|
||||
elif text_tokens == 0 and prompt_tokens_details["image_count"] == 0:
|
||||
# Clamp to zero: inconsistent streaming usage
|
||||
text_tokens = max(text_tokens, 0)
|
||||
prompt_tokens_details["text_tokens"] = text_tokens
|
||||
prompt_tokens_details["text_tokens"] = max(
|
||||
usage.prompt_tokens - cache_hit - audio_tokens - cache_creation - image_tokens - video_tokens, 0
|
||||
)
|
||||
|
||||
(
|
||||
prompt_base_cost,
|
||||
|
|
|
|||
|
|
@ -955,6 +955,7 @@ class RealTimeStreaming:
|
|||
transcript = event.get("transcript", "")
|
||||
self._collect_user_input_from_backend_event(cast(dict, event))
|
||||
self.store_message(event_str)
|
||||
self._capture_transcription_usage(event)
|
||||
await self._send_event_to_client(event, event_str)
|
||||
blocked = await self.run_realtime_guardrails(
|
||||
cast(str, transcript),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import json
|
|||
from typing import Any, Final
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import _DEFAULT_TTL_FOR_HTTPX_CLIENTS
|
||||
|
||||
from ...caching import InMemoryCache
|
||||
|
|
@ -46,6 +47,15 @@ class LangfuseInMemoryCache(InMemoryCache):
|
|||
_created_langfuse_logger.Langfuse.flush()
|
||||
_created_langfuse_logger.Langfuse.shutdown()
|
||||
|
||||
# Loggers with a periodic flush task (e.g. NewRelicMetricsLogger) expose
|
||||
# stop() so eviction actually ends the task instead of leaking it.
|
||||
_evicted_stop: Final = getattr(self.cache_dict[key], "stop", None)
|
||||
if callable(_evicted_stop):
|
||||
try:
|
||||
_evicted_stop()
|
||||
except Exception: # noqa: BLE001 # a failing stop() must not block eviction
|
||||
verbose_logger.debug("DynamicLoggingCache: stop() raised during eviction", exc_info=True)
|
||||
|
||||
#########################################################
|
||||
# Call parent class to remove key from cache
|
||||
#########################################################
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ from typing import TYPE_CHECKING, Final, Optional
|
|||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import (
|
||||
_get_web_search_requests,
|
||||
generic_cost_per_token,
|
||||
get_provider_specific_geo_multiplier,
|
||||
get_web_search_requests_from_usage,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -104,7 +104,7 @@ def get_cost_for_anthropic_web_search(
|
|||
|
||||
if usage is None:
|
||||
return 0.0
|
||||
web_search_requests: Final = _get_web_search_requests(getattr(usage, "server_tool_use", None))
|
||||
web_search_requests: Final = get_web_search_requests_from_usage(usage)
|
||||
if web_search_requests is None:
|
||||
return 0.0
|
||||
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ from litellm.types.llms.anthropic import (
|
|||
ContextManagementResponse,
|
||||
MessageBlockDelta,
|
||||
MessageDelta,
|
||||
ServerToolUsage,
|
||||
StreamingContentBlockDeltaType,
|
||||
UsageDelta,
|
||||
UsageIteration,
|
||||
|
|
@ -1354,10 +1355,22 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
return explicit_value
|
||||
return cls._first_positive_prompt_tokens_detail_value(usage, ("cache_creation_tokens", "cache_write_tokens"))
|
||||
|
||||
@classmethod
|
||||
def _get_web_search_request_count(cls, usage: Usage) -> int:
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import (
|
||||
get_web_search_requests_from_usage,
|
||||
)
|
||||
|
||||
from_server_tool_use: Final = cls._positive_int(get_web_search_requests_from_usage(usage))
|
||||
if from_server_tool_use > 0:
|
||||
return from_server_tool_use
|
||||
return cls._first_positive_prompt_tokens_detail_value(usage, ("web_search_requests",))
|
||||
|
||||
@classmethod
|
||||
def _translate_openai_usage_to_anthropic_usage_delta(cls, usage: Usage) -> UsageDelta:
|
||||
cache_read_input_tokens: Final = cls._get_cache_read_input_tokens(usage)
|
||||
cache_creation_input_tokens: Final = cls._get_cache_creation_input_tokens(usage)
|
||||
web_search_requests: Final = cls._get_web_search_request_count(usage)
|
||||
input_tokens: Final = max(
|
||||
(usage.prompt_tokens or 0) - cache_read_input_tokens - cache_creation_input_tokens,
|
||||
0,
|
||||
|
|
@ -1371,6 +1384,11 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
usage_delta["cache_creation_input_tokens"] = cache_creation_input_tokens
|
||||
if cache_read_input_tokens > 0:
|
||||
usage_delta["cache_read_input_tokens"] = cache_read_input_tokens
|
||||
if web_search_requests > 0:
|
||||
return UsageDelta(
|
||||
**usage_delta,
|
||||
server_tool_use=ServerToolUsage(web_search_requests=web_search_requests),
|
||||
)
|
||||
return usage_delta
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -1434,9 +1434,12 @@ class BaseAWSLLM:
|
|||
data: str | bytes,
|
||||
headers: dict,
|
||||
api_key: str | None = None,
|
||||
supports_bearer_token: bool = True,
|
||||
) -> AWSPreparedRequest:
|
||||
if api_key is not None:
|
||||
aws_bearer_token: str | None = api_key
|
||||
if not supports_bearer_token:
|
||||
aws_bearer_token: str | None = None
|
||||
elif api_key is not None:
|
||||
aws_bearer_token = api_key
|
||||
else:
|
||||
aws_bearer_token = get_secret_str("AWS_BEARER_TOKEN_BEDROCK")
|
||||
|
||||
|
|
|
|||
|
|
@ -138,11 +138,6 @@ class BedrockRerankHandler(BaseAWSLLM):
|
|||
data: dict,
|
||||
optional_params: dict,
|
||||
) -> BedrockPreparedRequest:
|
||||
try:
|
||||
from botocore.auth import SigV4Auth
|
||||
from botocore.awsrequest import AWSRequest
|
||||
except ImportError:
|
||||
raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.")
|
||||
boto3_credentials_info: Final = self._get_boto_credentials_from_optional_params(optional_params, model)
|
||||
|
||||
### SET RUNTIME ENDPOINT ###
|
||||
|
|
@ -153,24 +148,21 @@ class BedrockRerankHandler(BaseAWSLLM):
|
|||
)
|
||||
proxy_endpoint_url = proxy_endpoint_url.replace("bedrock-runtime", "bedrock-agent-runtime")
|
||||
proxy_endpoint_url = f"{proxy_endpoint_url}/rerank"
|
||||
sigv4: Final = SigV4Auth(
|
||||
boto3_credentials_info.credentials,
|
||||
"bedrock",
|
||||
boto3_credentials_info.aws_region_name,
|
||||
)
|
||||
# Make POST Request
|
||||
body: Final = json.dumps(data).encode("utf-8")
|
||||
|
||||
body: Final = json.dumps(data).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if extra_headers is not None:
|
||||
headers = {"Content-Type": "application/json", **extra_headers}
|
||||
request: Final = AWSRequest(method="POST", url=proxy_endpoint_url, data=body, headers=headers)
|
||||
sigv4.add_auth(request)
|
||||
if (
|
||||
extra_headers is not None and "Authorization" in extra_headers
|
||||
): # prevent sigv4 from overwriting the auth header
|
||||
request.headers["Authorization"] = extra_headers["Authorization"]
|
||||
prepped: Final = request.prepare()
|
||||
|
||||
prepped: Final = self.get_request_headers(
|
||||
credentials=boto3_credentials_info.credentials,
|
||||
aws_region_name=boto3_credentials_info.aws_region_name,
|
||||
extra_headers=extra_headers,
|
||||
endpoint_url=proxy_endpoint_url,
|
||||
data=body,
|
||||
headers=headers,
|
||||
supports_bearer_token=False,
|
||||
)
|
||||
|
||||
return BedrockPreparedRequest(
|
||||
endpoint_url=proxy_endpoint_url,
|
||||
|
|
|
|||
0
litellm/llms/gemini/audio_transcription/__init__.py
Normal file
0
litellm/llms/gemini/audio_transcription/__init__.py
Normal file
250
litellm/llms/gemini/audio_transcription/transformation.py
Normal file
250
litellm/llms/gemini/audio_transcription/transformation.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import base64
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Final
|
||||
|
||||
from httpx import Headers, Response
|
||||
|
||||
from litellm.litellm_core_utils.audio_utils.utils import (
|
||||
normalize_transcription_language_to_bcp47,
|
||||
process_audio_file,
|
||||
)
|
||||
from litellm.llms.base_llm.audio_transcription.transformation import (
|
||||
AudioTranscriptionRequestData,
|
||||
BaseAudioTranscriptionConfig,
|
||||
)
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.gemini.common_utils import GeminiError, GeminiModelInfo
|
||||
from litellm.types.llms.gemini_audio_transcription import (
|
||||
GeminiTranscriptionAudioInput,
|
||||
GeminiTranscriptionConfig,
|
||||
GeminiTranscriptionInteractionRequest,
|
||||
GeminiTranscriptionInteractionResponse,
|
||||
GeminiTranscriptionWordAnnotation,
|
||||
)
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
OpenAIAudioTranscriptionOptionalParams,
|
||||
)
|
||||
from litellm.types.utils import (
|
||||
FileTypes,
|
||||
TranscriptionResponse,
|
||||
TranscriptionUsageInputTokenDetailsObject,
|
||||
TranscriptionUsageTokensObject,
|
||||
)
|
||||
|
||||
INTERACTIONS_API_REVISION: Final = "2026-05-20"
|
||||
WORD_INFO_ANNOTATION_TYPE: Final = "word_info"
|
||||
|
||||
|
||||
class GeminiAudioTranscriptionConfig(BaseAudioTranscriptionConfig):
|
||||
"""
|
||||
Maps OpenAI /v1/audio/transcriptions onto the Gemini Interactions API
|
||||
(POST /v1beta/interactions) for transcription models like
|
||||
gemini-3.5-transcribe. https://ai.google.dev/gemini-api/docs/transcribe
|
||||
"""
|
||||
|
||||
def get_supported_openai_params(
|
||||
self, model: str
|
||||
) -> list[OpenAIAudioTranscriptionOptionalParams]: # mutable-ok: BaseAudioTranscriptionConfig signature
|
||||
return ["language", "response_format", "timestamp_granularities"] # mutable-ok: base contract returns a list
|
||||
|
||||
def map_openai_params(
|
||||
self,
|
||||
non_default_params: Mapping[str, object],
|
||||
optional_params: Mapping[str, object],
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
) -> dict: # mutable-ok: BaseAudioTranscriptionConfig signature
|
||||
supported_params: Final = frozenset(self.get_supported_openai_params(model))
|
||||
accepted: Final = tuple((k, v) for k, v in non_default_params.items() if k in supported_params)
|
||||
return dict((*optional_params.items(), *accepted)) # mutable-ok: base contract returns a plain dict
|
||||
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int,
|
||||
headers: dict | Headers, # mutable-ok: base signature and BaseLLMException take dict | Headers
|
||||
) -> BaseLLMException:
|
||||
return GeminiError(status_code=status_code, message=error_message, headers=headers)
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: Mapping[str, str],
|
||||
model: str,
|
||||
messages: Sequence[AllMessageValues],
|
||||
optional_params: Mapping[str, object],
|
||||
litellm_params: Mapping[str, object],
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> dict: # mutable-ok: BaseAudioTranscriptionConfig signature
|
||||
resolved_api_key: Final = GeminiModelInfo.get_api_key(api_key)
|
||||
if not resolved_api_key:
|
||||
raise GeminiError(
|
||||
status_code=401,
|
||||
message="Google API key is required. Set GOOGLE_API_KEY or GEMINI_API_KEY environment variable.",
|
||||
)
|
||||
return { # mutable-ok: the http handler passes these headers straight to httpx
|
||||
**headers,
|
||||
"Content-Type": "application/json",
|
||||
"x-goog-api-key": resolved_api_key,
|
||||
"Api-Revision": INTERACTIONS_API_REVISION,
|
||||
}
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
api_base: str | None,
|
||||
api_key: str | None,
|
||||
model: str,
|
||||
optional_params: Mapping[str, object],
|
||||
litellm_params: Mapping[str, object],
|
||||
stream: bool | None = None,
|
||||
) -> str:
|
||||
resolved_api_base: Final = GeminiModelInfo.get_api_base(api_base)
|
||||
return f"{resolved_api_base}/v1beta/interactions"
|
||||
|
||||
def transform_audio_transcription_request(
|
||||
self,
|
||||
model: str,
|
||||
audio_file: FileTypes,
|
||||
optional_params: Mapping[str, object],
|
||||
litellm_params: Mapping[str, object],
|
||||
) -> AudioTranscriptionRequestData:
|
||||
processed_audio: Final = process_audio_file(audio_file)
|
||||
audio_input: Final = GeminiTranscriptionAudioInput(
|
||||
type="audio",
|
||||
data=base64.b64encode(processed_audio.file_content).decode("utf-8"),
|
||||
mime_type=processed_audio.content_type,
|
||||
)
|
||||
request: Final = _build_interaction_request(
|
||||
model=model,
|
||||
audio_input=audio_input,
|
||||
transcription_config=_build_transcription_config(optional_params),
|
||||
)
|
||||
return AudioTranscriptionRequestData(data=dict(request)) # mutable-ok: AudioTranscriptionRequestData wants dict
|
||||
|
||||
def transform_audio_transcription_response(
|
||||
self,
|
||||
raw_response: Response,
|
||||
) -> TranscriptionResponse:
|
||||
try:
|
||||
response_json: Final = raw_response.json()
|
||||
except ValueError:
|
||||
raise GeminiError(
|
||||
status_code=raw_response.status_code,
|
||||
message=f"Received non-JSON response from Gemini Interactions API: {raw_response.text}",
|
||||
)
|
||||
parsed: Final = GeminiTranscriptionInteractionResponse.model_validate(response_json)
|
||||
if parsed.status != "completed":
|
||||
raise GeminiError(
|
||||
status_code=raw_response.status_code,
|
||||
message=f"Gemini transcription interaction did not complete (status={parsed.status}): {raw_response.text}",
|
||||
)
|
||||
text_contents: Final = tuple(
|
||||
content
|
||||
for step in parsed.steps
|
||||
for content in step.content
|
||||
if content.type == "text" and content.text is not None
|
||||
)
|
||||
response: Final = TranscriptionResponse(text=" ".join(content.text or "" for content in text_contents))
|
||||
response["task"] = "transcribe"
|
||||
words: Final = tuple(
|
||||
word
|
||||
for content in text_contents
|
||||
for annotation in content.annotations
|
||||
if (word := _annotation_to_word(annotation)) is not None
|
||||
)
|
||||
if words:
|
||||
response["words"] = list(words) # mutable-ok: verbose_json words is a JSON array
|
||||
last_word_end: Final = words[-1].get("end")
|
||||
if last_word_end is not None:
|
||||
response["duration"] = last_word_end
|
||||
if parsed.usage is not None:
|
||||
audio_tokens: Final = sum(
|
||||
by_modality.tokens
|
||||
for by_modality in parsed.usage.input_tokens_by_modality
|
||||
if by_modality.modality == "audio"
|
||||
)
|
||||
response.usage = TranscriptionUsageTokensObject(
|
||||
type="tokens",
|
||||
input_tokens=parsed.usage.total_input_tokens,
|
||||
output_tokens=parsed.usage.total_output_tokens,
|
||||
total_tokens=parsed.usage.total_tokens,
|
||||
input_token_details=TranscriptionUsageInputTokenDetailsObject(
|
||||
audio_tokens=audio_tokens,
|
||||
text_tokens=parsed.usage.total_input_tokens - audio_tokens,
|
||||
),
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
_EMPTY_TRANSCRIPTION_CONFIG: Final[GeminiTranscriptionConfig] = {}
|
||||
_WORD_TIMESTAMP_CONFIG: Final[GeminiTranscriptionConfig] = {
|
||||
"mode": {
|
||||
"type": "verbatim",
|
||||
"timestamp_granularities": ("word",),
|
||||
"diarization_mode": "speaker",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_interaction_request(
|
||||
model: str,
|
||||
audio_input: GeminiTranscriptionAudioInput,
|
||||
transcription_config: GeminiTranscriptionConfig,
|
||||
) -> GeminiTranscriptionInteractionRequest:
|
||||
if not transcription_config:
|
||||
bare_request: Final[GeminiTranscriptionInteractionRequest] = {
|
||||
"model": model.removeprefix("gemini/"),
|
||||
"input": (audio_input,),
|
||||
}
|
||||
return bare_request
|
||||
configured_request: Final[GeminiTranscriptionInteractionRequest] = {
|
||||
"model": model.removeprefix("gemini/"),
|
||||
"input": (audio_input,),
|
||||
"generation_config": {"transcription_config": transcription_config},
|
||||
}
|
||||
return configured_request
|
||||
|
||||
|
||||
def _language_config(language: object) -> GeminiTranscriptionConfig:
|
||||
if not isinstance(language, str) or not language:
|
||||
return _EMPTY_TRANSCRIPTION_CONFIG
|
||||
language_config: Final[GeminiTranscriptionConfig] = {
|
||||
"language_codes": (normalize_transcription_language_to_bcp47(language),),
|
||||
}
|
||||
return language_config
|
||||
|
||||
|
||||
def _timestamp_config(timestamp_granularities: object) -> GeminiTranscriptionConfig:
|
||||
if isinstance(timestamp_granularities, list) and "word" in timestamp_granularities:
|
||||
return _WORD_TIMESTAMP_CONFIG
|
||||
return _EMPTY_TRANSCRIPTION_CONFIG
|
||||
|
||||
|
||||
def _build_transcription_config(optional_params: Mapping[str, object]) -> GeminiTranscriptionConfig:
|
||||
transcription_config: Final[GeminiTranscriptionConfig] = {
|
||||
**_language_config(optional_params.get("language")),
|
||||
**_timestamp_config(optional_params.get("timestamp_granularities")),
|
||||
}
|
||||
return transcription_config
|
||||
|
||||
|
||||
def _annotation_to_word(annotation: GeminiTranscriptionWordAnnotation) -> Mapping[str, str | float] | None:
|
||||
if annotation.type != WORD_INFO_ANNOTATION_TYPE or annotation.text is None:
|
||||
return None
|
||||
entries: Final = (
|
||||
("word", annotation.text),
|
||||
("start", _parse_offset_seconds(annotation.start_offset)),
|
||||
("end", _parse_offset_seconds(annotation.end_offset)),
|
||||
("speaker", annotation.speaker),
|
||||
)
|
||||
return {key: value for key, value in entries if value is not None} # mutable-ok: word entries serialize to JSON
|
||||
|
||||
|
||||
def _parse_offset_seconds(offset: str | None) -> float | None:
|
||||
if offset is None or not offset.endswith("s"):
|
||||
return None
|
||||
try:
|
||||
return float(offset[:-1])
|
||||
except ValueError:
|
||||
return None
|
||||
|
|
@ -39,28 +39,35 @@ def cost_per_web_search_request(usage: "Usage", model_info: "ModelInfo") -> floa
|
|||
``model_info`` when available, falling back to $0.035 for models not
|
||||
yet updated in the pricing JSON.
|
||||
"""
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import (
|
||||
get_web_search_requests_from_usage,
|
||||
)
|
||||
from litellm.types.utils import PromptTokensDetailsWrapper
|
||||
|
||||
_DEFAULT_COST: Final = 35e-3
|
||||
search_costs: Final = model_info.get("search_context_cost_per_query") or {}
|
||||
_cost: Final = search_costs.get("search_context_size_medium", _DEFAULT_COST)
|
||||
|
||||
number_of_web_search_requests = 0
|
||||
if (
|
||||
usage is not None
|
||||
and usage.prompt_tokens_details is not None
|
||||
and isinstance(usage.prompt_tokens_details, PromptTokensDetailsWrapper)
|
||||
and hasattr(usage.prompt_tokens_details, "web_search_requests")
|
||||
and usage.prompt_tokens_details.web_search_requests is not None
|
||||
):
|
||||
number_of_web_search_requests = usage.prompt_tokens_details.web_search_requests
|
||||
requests_from_prompt_details: Final = (
|
||||
usage.prompt_tokens_details.web_search_requests
|
||||
if (
|
||||
usage is not None
|
||||
and usage.prompt_tokens_details is not None
|
||||
and isinstance(usage.prompt_tokens_details, PromptTokensDetailsWrapper)
|
||||
and hasattr(usage.prompt_tokens_details, "web_search_requests")
|
||||
and usage.prompt_tokens_details.web_search_requests is not None
|
||||
)
|
||||
else None
|
||||
)
|
||||
requests_from_server_tool_use: Final = get_web_search_requests_from_usage(usage)
|
||||
number_of_web_search_requests: Final = requests_from_prompt_details or requests_from_server_tool_use or 0
|
||||
|
||||
# per_prompt billing: clamp to 1 (flat fee per grounded API call)
|
||||
billing_mode: Final = model_info.get("web_search_billing_unit") or "per_prompt"
|
||||
if number_of_web_search_requests > 0 and billing_mode == "per_prompt":
|
||||
number_of_web_search_requests = 1
|
||||
billable_requests: Final = (
|
||||
1 if (number_of_web_search_requests > 0 and billing_mode == "per_prompt") else number_of_web_search_requests
|
||||
)
|
||||
|
||||
return _cost * number_of_web_search_requests
|
||||
return _cost * billable_requests
|
||||
|
||||
|
||||
GOOGLE_MAPS_GROUNDING_DEFAULT_COST_PER_QUERY: Final = 14e-3
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ This file contains the transformation logic for the Gemini realtime API.
|
|||
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, Final, cast
|
||||
|
||||
import litellm
|
||||
|
|
@ -53,6 +53,7 @@ from litellm.types.llms.vertex_ai import (
|
|||
)
|
||||
from litellm.types.realtime import (
|
||||
ALL_DELTA_TYPES,
|
||||
RealtimeInputAudioTranscriptionUsage,
|
||||
RealtimeModalityResponseTransformOutput,
|
||||
RealtimeResponseTransformInput,
|
||||
RealtimeResponseTypedDict,
|
||||
|
|
@ -95,6 +96,18 @@ def _gemini_live_speech_config(voice: object) -> Mapping[str, object] | None:
|
|||
return VertexGeminiConfig()._map_audio_params({"voice": voice})
|
||||
|
||||
|
||||
# Google bills Live transcription at an estimated 25 audio tokens/sec of input and
|
||||
# 175 text tokens/min of output (ai.google.dev/gemini-api/docs/pricing).
|
||||
GEMINI_LIVE_TRANSCRIBE_AUDIO_TOKENS_PER_SECOND: Final = 25
|
||||
GEMINI_LIVE_TRANSCRIBE_OUTPUT_TEXT_TOKENS_PER_MINUTE: Final = 175
|
||||
PCM16_INPUT_AUDIO_BYTES_PER_SECOND: Final = 48000
|
||||
|
||||
|
||||
def _base64_decoded_byte_count(data: str) -> int:
|
||||
padding: Final = 2 if data.endswith("==") else 1 if data.endswith("=") else 0
|
||||
return max(len(data) * 3 // 4 - padding, 0)
|
||||
|
||||
|
||||
class GeminiRealtimeConfig(BaseRealtimeConfig):
|
||||
_TOOL_CALL_ID_TO_NAME_MAX = 256 # LRU cap for call_id→name mapping
|
||||
|
||||
|
|
@ -104,6 +117,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
# Gemini Live sometimes emits usageMetadata in a standalone frame between
|
||||
# turns; buffer it here so the next response.done carries the token counts.
|
||||
self._pending_usage_metadata: dict | None = None
|
||||
self._unbilled_input_audio_bytes: int = 0
|
||||
|
||||
def is_setup_message(self, msg_obj: dict) -> bool:
|
||||
return "setup" in msg_obj
|
||||
|
|
@ -384,17 +398,25 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
return bool(entry.get("gemini_native_audio") or entry.get("gemini_audio_only_live"))
|
||||
|
||||
@staticmethod
|
||||
def _coerce_response_modalities(model: str, modalities: list[Any]) -> list[str]:
|
||||
"""Map unsupported TEXT responseModalities to AUDIO for audio-only Live models."""
|
||||
normalized: Final = [
|
||||
def _is_text_only_live_model(model: str) -> bool:
|
||||
return GeminiRealtimeConfig._model_cost_entry(model).get("mode") == "audio_transcription"
|
||||
|
||||
@staticmethod
|
||||
def _default_response_modality(model: str) -> GeminiResponseModalities:
|
||||
return "TEXT" if GeminiRealtimeConfig._is_text_only_live_model(model) else "AUDIO"
|
||||
|
||||
@staticmethod
|
||||
def _coerce_response_modalities(model: str, modalities: Sequence[Any]) -> tuple[str, ...]:
|
||||
"""Swap responseModalities a Live model cannot produce: TEXT to AUDIO for
|
||||
audio-only models, AUDIO to TEXT for text-only ones (e.g. transcribe-live)."""
|
||||
normalized: Final = tuple(
|
||||
modality.upper() if isinstance(modality, str) else str(modality).upper() for modality in modalities
|
||||
]
|
||||
if not GeminiRealtimeConfig._is_audio_only_live_model(model):
|
||||
return normalized
|
||||
if "TEXT" not in normalized:
|
||||
return normalized
|
||||
without_text: Final = [modality for modality in normalized if modality != "TEXT"]
|
||||
return without_text if without_text else ["AUDIO"]
|
||||
)
|
||||
if GeminiRealtimeConfig._is_audio_only_live_model(model) and "TEXT" in normalized:
|
||||
return tuple(modality for modality in normalized if modality != "TEXT") or ("AUDIO",)
|
||||
if GeminiRealtimeConfig._is_text_only_live_model(model) and "AUDIO" in normalized:
|
||||
return tuple(modality for modality in normalized if modality != "AUDIO") or ("TEXT",)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _finalize_gemini_live_setup(model: str, setup: dict[str, Any]) -> dict[str, Any]:
|
||||
|
|
@ -436,7 +458,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
|
||||
if session_configuration_request is None:
|
||||
generation_config: Final = new_overrides.setdefault("generationConfig", {})
|
||||
generation_config.setdefault("responseModalities", ["AUDIO"])
|
||||
generation_config.setdefault("responseModalities", [GeminiRealtimeConfig._default_response_modality(model)])
|
||||
new_overrides.setdefault("inputAudioTranscription", {})
|
||||
new_overrides["model"] = f"models/{model}"
|
||||
verbose_logger.debug("Gemini Realtime: Sending initial setup with tools to backend")
|
||||
|
|
@ -558,9 +580,10 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
return self._handle_conversation_item(json_message)
|
||||
|
||||
if msg_type == "input_audio_buffer.append":
|
||||
realtime_input_dict["audio"] = HttpxBlobType(
|
||||
mimeType=self.get_audio_mime_type(), data=json_message["audio"]
|
||||
)
|
||||
audio_b64: Final = json_message["audio"]
|
||||
if isinstance(audio_b64, str):
|
||||
self._unbilled_input_audio_bytes += _base64_decoded_byte_count(audio_b64)
|
||||
realtime_input_dict["audio"] = HttpxBlobType(mimeType=self.get_audio_mime_type(), data=audio_b64)
|
||||
|
||||
realtime_input_dict = cast(
|
||||
BidiGenerateContentRealtimeInput,
|
||||
|
|
@ -1151,6 +1174,23 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
raise ValueError(f"Unknown openai event: {key}, value: {value}")
|
||||
return openai_event
|
||||
|
||||
def _consume_input_transcription_usage_estimate(self, model: str) -> RealtimeInputAudioTranscriptionUsage | None:
|
||||
"""Gemini Live sends no usageMetadata for transcribe sessions; estimate billing from streamed audio duration."""
|
||||
if self._unbilled_input_audio_bytes <= 0 or not self._is_text_only_live_model(model):
|
||||
return None
|
||||
audio_seconds: Final = self._unbilled_input_audio_bytes / PCM16_INPUT_AUDIO_BYTES_PER_SECOND
|
||||
self._unbilled_input_audio_bytes = 0
|
||||
audio_tokens: Final = round(audio_seconds * GEMINI_LIVE_TRANSCRIBE_AUDIO_TOKENS_PER_SECOND)
|
||||
output_tokens: Final = round(audio_seconds * GEMINI_LIVE_TRANSCRIBE_OUTPUT_TEXT_TOKENS_PER_MINUTE / 60)
|
||||
usage: Final[RealtimeInputAudioTranscriptionUsage] = {
|
||||
"type": "tokens",
|
||||
"input_tokens": audio_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"total_tokens": audio_tokens + output_tokens,
|
||||
"input_token_details": {"text_tokens": 0, "audio_tokens": audio_tokens},
|
||||
}
|
||||
return usage
|
||||
|
||||
def transform_realtime_response(
|
||||
self,
|
||||
message: str | bytes,
|
||||
|
|
@ -1190,6 +1230,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
if isinstance(server_content, dict):
|
||||
input_tx: Final = server_content.get("inputTranscription")
|
||||
if isinstance(input_tx, dict) and input_tx.get("text"):
|
||||
transcription_usage: Final = self._consume_input_transcription_usage_estimate(model)
|
||||
returned_message.append(
|
||||
cast(
|
||||
OpenAIRealtimeEvents,
|
||||
|
|
@ -1199,6 +1240,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
"transcript": input_tx["text"],
|
||||
"item_id": f"item_{uuid.uuid4()}",
|
||||
"content_index": 0,
|
||||
**({} if transcription_usage is None else {"usage": transcription_usage}),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
@ -1235,6 +1277,12 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
)
|
||||
)
|
||||
|
||||
# Transcription-only models emit generationComplete with no prior
|
||||
# modelTurn delta; there is no started OpenAI response to close, so
|
||||
# drop it and let siblings (turnComplete, usageMetadata) process.
|
||||
if current_delta_type is None and "modelTurn" not in server_content:
|
||||
server_content.pop("generationComplete", None)
|
||||
|
||||
# Mark transcription-only serverContent as handled so the main loop
|
||||
# skips it; sibling keys like toolCall are still processed below.
|
||||
_model_content_keys: Final = {
|
||||
|
|
@ -1583,7 +1631,9 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
```
|
||||
"""
|
||||
|
||||
response_modalities: Final[list[GeminiResponseModalities]] = ["AUDIO"]
|
||||
response_modalities: Final[list[GeminiResponseModalities]] = [
|
||||
GeminiRealtimeConfig._default_response_modality(model)
|
||||
]
|
||||
output_audio_transcription: Final = False
|
||||
# if "audio" in model: ## UNCOMMENT THIS WHEN AUDIO IS SUPPORTED
|
||||
# output_audio_transcription = True
|
||||
|
|
|
|||
|
|
@ -1219,6 +1219,7 @@ def _register_custom_pricing_for_request(
|
|||
shared_key: CustomPricingLiteLLMParams.strip_custom_pricing_fields(entry),
|
||||
},
|
||||
persist_across_reloads=False,
|
||||
warning_display_name=shared_key,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -51340,6 +51340,47 @@
|
|||
"supports_audio_output": true,
|
||||
"tpm": 250000
|
||||
},
|
||||
"gemini/gemini-3.5-transcribe": {
|
||||
"input_cost_per_audio_token": 2e-06,
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "gemini",
|
||||
"mode": "audio_transcription",
|
||||
"output_cost_per_token": 1.2e-05,
|
||||
"source": "https://ai.google.dev/gemini-api/docs/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/audio/transcriptions"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_audio_input": true,
|
||||
"tpm": 800000,
|
||||
"rpm": 2000
|
||||
},
|
||||
"gemini/gemini-3.5-transcribe-live": {
|
||||
"input_cost_per_audio_token": 3.5e-06,
|
||||
"input_cost_per_token": 3.5e-06,
|
||||
"litellm_provider": "gemini",
|
||||
"mode": "audio_transcription",
|
||||
"output_cost_per_token": 2.1e-05,
|
||||
"source": "https://ai.google.dev/gemini-api/docs/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/realtime"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"audio"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_audio_input": true,
|
||||
"tpm": 250000,
|
||||
"rpm": 10
|
||||
},
|
||||
"perplexity/pplx-embed-context-v1-0.6b": {
|
||||
"input_cost_per_token": 8e-09,
|
||||
"litellm_provider": "perplexity",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ from litellm.proxy._types import (
|
|||
)
|
||||
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
|
||||
from litellm.proxy.auth.user_api_key_auth import (
|
||||
_get_bearer_token_or_received_api_key, # pyright: ignore[reportPrivateUsage] # shared x-litellm-api-key parser lives with user_api_key_auth
|
||||
_run_centralized_common_checks,
|
||||
user_api_key_auth,
|
||||
)
|
||||
|
|
@ -429,7 +430,10 @@ class MCPRequestHandler:
|
|||
# An explicit x-litellm-api-key is always a LiteLLM credential, even
|
||||
# for a delegated server, so validate it: identity / spend / rate
|
||||
# limits resolve and any stored upstream token can be forwarded.
|
||||
validated_user_api_key_auth = await user_api_key_auth(api_key=litellm_api_key, request=request)
|
||||
validated_user_api_key_auth = await user_api_key_auth(
|
||||
api_key=f"Bearer {_get_bearer_token_or_received_api_key(litellm_api_key)}",
|
||||
request=request,
|
||||
)
|
||||
elif MCPRequestHandler._target_servers_delegate_auth_to_upstream(
|
||||
path=request_route,
|
||||
mcp_servers=mcp_servers,
|
||||
|
|
|
|||
|
|
@ -5256,7 +5256,9 @@ class MCPServerManager:
|
|||
proxy_logging_obj: Optional ProxyLogging object for hook integration
|
||||
host_progress_callback: Optional callback for progress updates
|
||||
hook_extra_headers: Optional headers injected by pre_mcp_call guardrail
|
||||
hooks. Merged last (highest priority) into outbound request headers.
|
||||
hooks. Merged last into outbound request headers, except a hook
|
||||
Authorization header is dropped when an upstream credential already
|
||||
occupies the Authorization slot.
|
||||
|
||||
Returns:
|
||||
CallToolResult from the MCP server
|
||||
|
|
@ -5347,27 +5349,26 @@ class MCPServerManager:
|
|||
if hook_extra_headers:
|
||||
if extra_headers is None:
|
||||
extra_headers = {}
|
||||
if "Authorization" in hook_extra_headers:
|
||||
if "Authorization" in extra_headers:
|
||||
verbose_logger.warning(
|
||||
"MCPServerManager: hook_extra_headers 'Authorization' will overwrite "
|
||||
"the existing Authorization header from static_headers. "
|
||||
"The hook JWT will take precedence."
|
||||
)
|
||||
elif server_auth_header is not None:
|
||||
# server_auth_header is passed separately to _create_mcp_client as
|
||||
# auth_value. Both will reach the upstream server — warn so admins
|
||||
# know two Authorization credentials are being sent.
|
||||
verbose_logger.warning(
|
||||
"MCPServerManager: hook_extra_headers injects 'Authorization' while "
|
||||
"server '%s' already has a configured authentication_token. "
|
||||
"Both credentials will be sent; the hook header is in extra_headers "
|
||||
"and the server token is in auth_value — the upstream server decides "
|
||||
"which one wins. Consider unsetting authentication_token if you want "
|
||||
"the hook JWT to be the sole credential.",
|
||||
mcp_server.server_name or mcp_server.name,
|
||||
)
|
||||
extra_headers.update(hook_extra_headers)
|
||||
hook_has_authorization: Final = any(k.lower() == "authorization" for k in hook_extra_headers)
|
||||
existing_has_authorization: Final = any(k.lower() == "authorization" for k in extra_headers)
|
||||
server_auth_occupies_authorization: Final = (
|
||||
any(k.lower() == "authorization" for k in server_auth_header)
|
||||
if isinstance(server_auth_header, dict)
|
||||
else server_auth_header is not None and mcp_server.auth_type != MCPAuth.api_key
|
||||
)
|
||||
if hook_has_authorization and (existing_has_authorization or server_auth_occupies_authorization):
|
||||
# Mirror the tools/list signer guard: an upstream credential (user OAuth,
|
||||
# static header, or configured authentication_token) already occupies the
|
||||
# Authorization slot, so the hook must not replace it.
|
||||
verbose_logger.warning(
|
||||
"MCPServerManager: dropping hook-injected 'Authorization' header for "
|
||||
"server '%s' because an upstream credential already occupies the "
|
||||
"Authorization slot; the existing credential is kept.",
|
||||
mcp_server.server_name or mcp_server.name,
|
||||
)
|
||||
extra_headers.update({k: v for k, v in hook_extra_headers.items() if k.lower() != "authorization"})
|
||||
else:
|
||||
extra_headers.update(hook_extra_headers)
|
||||
|
||||
# Reset to None if no headers were actually added
|
||||
if extra_headers is not None and len(extra_headers) == 0:
|
||||
|
|
|
|||
|
|
@ -239,5 +239,6 @@ def resolve_bridge_envelope(
|
|||
if opened.identity.server_id != expected_server_id:
|
||||
return BridgeEnvelopeInvalid()
|
||||
grant: Final = opened.grant
|
||||
upstream_authorization: Final = f"{grant.token_type} {grant.access_token.get_secret_value()}"
|
||||
authorization_scheme: Final = "Bearer" if grant.token_type.lower() == "bearer" else grant.token_type
|
||||
upstream_authorization: Final = f"{authorization_scheme} {grant.access_token.get_secret_value()}"
|
||||
return BridgeEnvelopeAdmitted(identity=opened.identity, upstream_authorization=SecretStr(upstream_authorization))
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,18 +3,27 @@ Per-feature OpenAPI snapshot for lazy-loaded routers.
|
|||
|
||||
The committed JSON is generated by `python -m litellm.proxy._lazy_openapi_snapshot`
|
||||
and consumed at runtime so /openapi.json can show full route info for unloaded
|
||||
features without importing them. No CI job regenerates this file; drift surfaces
|
||||
only indirectly through check-ui-api-types.yml, which rebuilds schema.d.ts from
|
||||
app.openapi() with the committed snapshot injected. After changing any lazily
|
||||
loaded route or this generator, rerun the module and commit the JSON, then run
|
||||
`npm run gen:api` in ui/litellm-dashboard and commit schema.d.ts.
|
||||
features without importing them. check-ui-api-types.yml (mirrored locally by
|
||||
`make check`) regenerates this file and fails when the committed copy differs,
|
||||
then rebuilds schema.d.ts from app.openapi() with the snapshot injected. After
|
||||
changing any lazily loaded route or this generator, rerun the module and commit
|
||||
the JSON, then run `npm run gen:api` in ui/litellm-dashboard and commit schema.d.ts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import FastAPI
|
||||
|
||||
from litellm.proxy._lazy_features import LazyFeature
|
||||
|
||||
SNAPSHOT_FILE: Final = Path(__file__).parent / "_lazy_openapi_snapshot.json"
|
||||
HTTP_METHOD_SUFFIXES: Final = {
|
||||
|
|
@ -83,51 +92,84 @@ def _normalize_operation_ids(paths: dict[str, dict]) -> None:
|
|||
break
|
||||
|
||||
|
||||
def generate_snapshot() -> dict[str, dict]:
|
||||
class SnapshotFragment(TypedDict):
|
||||
paths: ReadOnly[Mapping[str, Mapping[str, object]]]
|
||||
components: ReadOnly[Mapping[str, Mapping[str, object]]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SnapshotResult:
|
||||
fragments: Mapping[str, SnapshotFragment]
|
||||
skipped: tuple[str, ...]
|
||||
|
||||
|
||||
def _register_feature(app: "FastAPI", feat: "LazyFeature") -> str | None:
|
||||
import importlib
|
||||
|
||||
try:
|
||||
feat.register_fn(app, importlib.import_module(feat.module_path))
|
||||
except Exception as exc:
|
||||
sys.stderr.write(f"warning: skip {feat.name}: {exc}\n")
|
||||
return feat.name
|
||||
return None
|
||||
|
||||
|
||||
def _feature_fragment(app: "FastAPI", feat: "LazyFeature", used_operation_ids: set[str]) -> SnapshotFragment | None:
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
|
||||
from litellm.proxy.proxy_server import ensure_unique_openapi_operation_ids
|
||||
|
||||
feat_routes: Final = [r for r in app.routes if feat.matches(getattr(r, "path", ""))]
|
||||
if not feat_routes:
|
||||
return None
|
||||
_stabilize_multi_method_route_ids(feat_routes)
|
||||
full: Final = get_openapi(title=app.title, version=app.version, routes=feat_routes)
|
||||
paths: Final = full.get("paths", {})
|
||||
_normalize_operation_ids(paths)
|
||||
# Group all of a feature's routes under one tag.
|
||||
for path_ops in paths.values():
|
||||
for method, op in path_ops.items():
|
||||
if isinstance(op, dict):
|
||||
operation_id = op.get("operationId")
|
||||
if isinstance(operation_id, str):
|
||||
for suffix in HTTP_METHOD_SUFFIXES:
|
||||
if operation_id.endswith(f"_{suffix}"):
|
||||
op["operationId"] = operation_id[: -len(suffix)] + method
|
||||
break
|
||||
op["tags"] = [feat.name]
|
||||
unique: Final = ensure_unique_openapi_operation_ids(full, used_operation_ids)
|
||||
return {
|
||||
"paths": paths,
|
||||
"components": {"schemas": unique.get("components", {}).get("schemas", {})},
|
||||
}
|
||||
|
||||
|
||||
def generate_snapshot() -> SnapshotResult:
|
||||
from litellm.proxy._lazy_features import LAZY_FEATURES
|
||||
from litellm.proxy.proxy_server import app, ensure_unique_openapi_operation_ids
|
||||
from litellm.proxy.proxy_server import app
|
||||
|
||||
for feat in LAZY_FEATURES:
|
||||
try:
|
||||
module = importlib.import_module(feat.module_path)
|
||||
feat.register_fn(app, module)
|
||||
except Exception as exc:
|
||||
sys.stderr.write(f"warning: skip {feat.name}: {exc}\n")
|
||||
|
||||
fragments: Final[dict[str, dict]] = {}
|
||||
skipped: Final = tuple(name for feat in LAZY_FEATURES if (name := _register_feature(app, feat)) is not None)
|
||||
used_operation_ids: Final[set[str]] = set()
|
||||
for feat in LAZY_FEATURES:
|
||||
feat_routes = [r for r in app.routes if feat.matches(getattr(r, "path", ""))]
|
||||
if not feat_routes:
|
||||
continue
|
||||
_stabilize_multi_method_route_ids(feat_routes)
|
||||
full = get_openapi(title=app.title, version=app.version, routes=feat_routes)
|
||||
paths = full.get("paths", {})
|
||||
_normalize_operation_ids(paths)
|
||||
# Group all of a feature's routes under one tag.
|
||||
for path_ops in full.get("paths", {}).values():
|
||||
for method, op in path_ops.items():
|
||||
if isinstance(op, dict):
|
||||
operation_id = op.get("operationId")
|
||||
if isinstance(operation_id, str):
|
||||
for suffix in HTTP_METHOD_SUFFIXES:
|
||||
if operation_id.endswith(f"_{suffix}"):
|
||||
op["operationId"] = operation_id[: -len(suffix)] + method
|
||||
break
|
||||
op["tags"] = [feat.name]
|
||||
full = ensure_unique_openapi_operation_ids(full, used_operation_ids)
|
||||
fragments[feat.name] = {
|
||||
"paths": paths,
|
||||
"components": {"schemas": full.get("components", {}).get("schemas", {})},
|
||||
}
|
||||
return fragments
|
||||
fragments: Final = {
|
||||
feat.name: fragment
|
||||
for feat in LAZY_FEATURES
|
||||
if (fragment := _feature_fragment(app, feat, used_operation_ids)) is not None
|
||||
}
|
||||
return SnapshotResult(fragments=fragments, skipped=skipped)
|
||||
|
||||
|
||||
def main(snapshot_file: Path = SNAPSHOT_FILE, generate: Callable[[], SnapshotResult] = generate_snapshot) -> int:
|
||||
result: Final = generate()
|
||||
if result.skipped:
|
||||
sys.stderr.write(
|
||||
f"error: {len(result.skipped)} feature(s) failed to import, so their fragments would vanish from the "
|
||||
f"snapshot: {', '.join(result.skipped)}\n"
|
||||
)
|
||||
return 1
|
||||
snapshot_file.write_text(json.dumps(result.fragments, indent=2, sort_keys=True) + "\n")
|
||||
sys.stdout.write(f"wrote {len(result.fragments)} feature fragments to {snapshot_file}\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
fragments: Final = generate_snapshot()
|
||||
SNAPSHOT_FILE.write_text(json.dumps(fragments, indent=2, sort_keys=True) + "\n")
|
||||
sys.stdout.write(f"wrote {len(fragments)} feature fragments to {SNAPSHOT_FILE}\n")
|
||||
sys.exit(main())
|
||||
|
|
|
|||
|
|
@ -2510,6 +2510,18 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
|||
"are skipped for on-demand GET /health as well as the background health loop."
|
||||
),
|
||||
)
|
||||
background_health_check_model_groups: tuple[str, ...] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Opt-in allowlist of model group names for background health checks and "
|
||||
"health-check routing. When set, the background loop probes only deployments "
|
||||
"whose model_name is listed, and enable_health_check_routing filters unhealthy "
|
||||
"deployments only within the listed groups; every other group, including newly "
|
||||
"added deployments, is skipped and keeps its configured routing strategy. "
|
||||
"When unset, all deployments participate (opt out per deployment via "
|
||||
"model_info.disable_background_health_check)."
|
||||
),
|
||||
)
|
||||
model_list_healthy_only: bool | None = Field(
|
||||
None,
|
||||
description=(
|
||||
|
|
|
|||
|
|
@ -1769,7 +1769,12 @@ async def _user_api_key_auth_builder(
|
|||
|
||||
return valid_token
|
||||
|
||||
if valid_token is not None and isinstance(valid_token, UserAPIKeyAuth) and valid_token.team_id is not None:
|
||||
if (
|
||||
valid_token is not None
|
||||
and isinstance(valid_token, UserAPIKeyAuth)
|
||||
and valid_token.team_id is not None
|
||||
and valid_token.team_id != UI_TEAM_ID
|
||||
):
|
||||
## UPDATE TEAM VALUES BASED ON CACHED TEAM OBJECT - allows `/team/update` values to work for cached token
|
||||
try:
|
||||
team_obj: Final[LiteLLM_TeamTableCachedObj] = await get_team_object(
|
||||
|
|
@ -2149,6 +2154,8 @@ async def _user_api_key_auth_builder(
|
|||
# Check 6: Additional Common Checks across jwt + key auth
|
||||
if valid_token.team_id is not None:
|
||||
try:
|
||||
if valid_token.team_id == UI_TEAM_ID:
|
||||
raise TeamNotFoundError(team_id=UI_TEAM_ID)
|
||||
with tracer.trace("litellm.proxy.auth.get_team_object"):
|
||||
_team_obj = await get_team_object(
|
||||
team_id=valid_token.team_id,
|
||||
|
|
@ -2443,7 +2450,7 @@ async def _run_centralized_common_checks(
|
|||
)
|
||||
|
||||
fetch_coros: Final = []
|
||||
if user_api_key_auth_obj.team_id is not None:
|
||||
if user_api_key_auth_obj.team_id is not None and user_api_key_auth_obj.team_id != UI_TEAM_ID:
|
||||
fetch_coros.append(
|
||||
_safe_fetch(
|
||||
"team",
|
||||
|
|
@ -2567,7 +2574,9 @@ async def _run_centralized_common_checks(
|
|||
else:
|
||||
raise team_result
|
||||
else:
|
||||
team_object = team_result
|
||||
team_object = (
|
||||
_team_obj_from_token(user_api_key_auth_obj) if user_api_key_auth_obj.team_id == UI_TEAM_ID else team_result
|
||||
)
|
||||
|
||||
user_object: LiteLLM_UserTable | None = None if isinstance(user_result, BaseException) else user_result
|
||||
project_object: Final[LiteLLM_ProjectTableCachedObj | None] = (
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from litellm.constants import (
|
|||
LITELLM_DETAILED_TIMING,
|
||||
LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED,
|
||||
MAX_PAYLOAD_SIZE_FOR_DEBUG_LOG,
|
||||
NON_INFERENCE_CALL_TYPES,
|
||||
RETURN_RAW_MODEL_NAME_METADATA_KEY,
|
||||
STREAM_SSE_DATA_PREFIX,
|
||||
STREAM_SSE_KEEPALIVE_PING_BYTES,
|
||||
|
|
@ -37,6 +38,7 @@ from litellm.litellm_core_utils.dd_tracing import NullTracer, tracer
|
|||
from litellm.litellm_core_utils.get_supported_openai_params import (
|
||||
get_supported_openai_params,
|
||||
)
|
||||
from litellm.litellm_core_utils.internal_call_metadata import is_unbilled_non_inference_call_from_params
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import guardrail_information_cost
|
||||
from litellm.litellm_core_utils.llm_response_utils.get_headers import (
|
||||
|
|
@ -1300,15 +1302,51 @@ def _uncached_input_cost(
|
|||
return input_cost - (cache_read_cost or 0.0) - (cache_creation_cost or 0.0)
|
||||
|
||||
|
||||
_ZERO_COST_BREAKDOWN: Final = CostBreakdownHeaderValues(
|
||||
original_cost=0.0,
|
||||
discount_amount=0.0,
|
||||
margin_total_amount=0.0,
|
||||
margin_percent=0.0,
|
||||
input_cost=0.0,
|
||||
output_cost=0.0,
|
||||
tool_usage_cost=0.0,
|
||||
)
|
||||
"""The component split a call priced at zero advertises, so a client reading the cost headers off a
|
||||
read or management route still finds the whole family rather than a partially populated one."""
|
||||
|
||||
|
||||
def _totals_to_zero(response_cost: float | str | None) -> bool:
|
||||
"""Whether the total these headers carry is zero, counting a total no route ever priced as one.
|
||||
|
||||
A component split is only reported as zero alongside a total that agrees with it, so a read
|
||||
that did price normally never advertises a real total beside an all-zero split.
|
||||
"""
|
||||
if response_cost is None or response_cost == "":
|
||||
return True
|
||||
try:
|
||||
return float(response_cost) == 0.0
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _get_cost_breakdown_from_logging_obj(
|
||||
litellm_logging_obj: LiteLLMLoggingObj | None,
|
||||
response_cost: float | str | None = None,
|
||||
) -> CostBreakdownHeaderValues:
|
||||
"""Extract discount, margin, and per-component cost information from logging object's cost breakdown."""
|
||||
"""Extract discount, margin, and per-component cost information from logging object's cost breakdown.
|
||||
|
||||
A non-inference call that priced at zero never records a breakdown, so its components are
|
||||
reported as zero here. Any such call that did price normally (retrieving a background response,
|
||||
and the cost poller's read of one) reports the breakdown it stored, or nothing at all when the
|
||||
breakdown has not landed yet.
|
||||
"""
|
||||
if not litellm_logging_obj or not hasattr(litellm_logging_obj, "cost_breakdown"):
|
||||
return CostBreakdownHeaderValues()
|
||||
|
||||
cost_breakdown: Final = litellm_logging_obj.cost_breakdown
|
||||
if not cost_breakdown:
|
||||
if litellm_logging_obj.call_type in NON_INFERENCE_CALL_TYPES and _totals_to_zero(response_cost):
|
||||
return _ZERO_COST_BREAKDOWN
|
||||
return CostBreakdownHeaderValues()
|
||||
|
||||
return CostBreakdownHeaderValues(
|
||||
|
|
@ -1457,7 +1495,9 @@ class ProxyBaseLLMRequestProcessing:
|
|||
exclude_values: Final = {"", None, "None"}
|
||||
hidden_params = hidden_params or {}
|
||||
|
||||
cost_breakdown: Final = _get_cost_breakdown_from_logging_obj(litellm_logging_obj=litellm_logging_obj)
|
||||
cost_breakdown: Final = _get_cost_breakdown_from_logging_obj(
|
||||
litellm_logging_obj=litellm_logging_obj, response_cost=response_cost
|
||||
)
|
||||
|
||||
# Calculate updated spend for header (include current response_cost)
|
||||
current_spend: Final = user_api_key_dict.spend or 0.0
|
||||
|
|
@ -2537,11 +2577,16 @@ class ProxyBaseLLMRequestProcessing:
|
|||
additional_headers = hidden_params.get("additional_headers", {}) or {}
|
||||
|
||||
recover_response_cost: Final = not response_cost and hidden_params.get("response_cost") is None
|
||||
llm_cost_for_headers: Final = (
|
||||
computed_cost_for_headers: Final = (
|
||||
self._response_cost_from_logging_obj(response=response, logging_obj=logging_obj) or ""
|
||||
if recover_response_cost
|
||||
else response_cost
|
||||
)
|
||||
llm_cost_for_headers: Final = (
|
||||
0.0
|
||||
if is_unbilled_non_inference_call_from_params(logging_obj.call_type, logging_obj.litellm_params, response)
|
||||
else computed_cost_for_headers
|
||||
)
|
||||
_, request_metadata_bucket = get_or_create_metadata_bucket(self.data)
|
||||
guardrail_cost_for_headers: Final = guardrail_information_cost(
|
||||
request_metadata_bucket.get("standard_logging_guardrail_information")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -45,6 +46,7 @@ from litellm.repositories.table_repositories import (
|
|||
)
|
||||
from litellm.repositories.team_repository import TeamRepository
|
||||
from litellm.repositories.unit_of_work import (
|
||||
LinkedSpendResetWrites,
|
||||
budget_cascade_unit_of_work,
|
||||
spend_reset_unit_of_work,
|
||||
)
|
||||
|
|
@ -59,7 +61,15 @@ _LINKED_KEYS_WHERE: Final[Mapping[str, object]] = MappingProxyType({"budget_dura
|
|||
_SPENT_ROWS_WHERE: Final[Mapping[str, object]] = MappingProxyType({"spend": {"gt": 0}})
|
||||
|
||||
|
||||
class _TeamMembershipRow(Protocol):
|
||||
class _BudgetLinkedRow(Protocol):
|
||||
@property
|
||||
def spend(self) -> float | None: ...
|
||||
|
||||
@property
|
||||
def budget_id(self) -> str | None: ...
|
||||
|
||||
|
||||
class _TeamMembershipRow(_BudgetLinkedRow, Protocol):
|
||||
@property
|
||||
def user_id(self) -> str: ...
|
||||
|
||||
|
|
@ -67,26 +77,48 @@ class _TeamMembershipRow(Protocol):
|
|||
def team_id(self) -> str: ...
|
||||
|
||||
|
||||
class _KeyRow(Protocol):
|
||||
class _KeyRow(_BudgetLinkedRow, Protocol):
|
||||
@property
|
||||
def token(self) -> str: ...
|
||||
|
||||
|
||||
class _OrgRow(Protocol):
|
||||
class _OrgRow(_BudgetLinkedRow, Protocol):
|
||||
@property
|
||||
def organization_id(self) -> str: ...
|
||||
|
||||
|
||||
class _TagRow(Protocol):
|
||||
class _TagRow(_BudgetLinkedRow, Protocol):
|
||||
@property
|
||||
def tag_name(self) -> str: ...
|
||||
|
||||
|
||||
class _EndUserRow(Protocol):
|
||||
class _EndUserRow(_BudgetLinkedRow, Protocol):
|
||||
@property
|
||||
def user_id(self) -> str: ...
|
||||
|
||||
|
||||
def _rollover_enabled() -> bool:
|
||||
return litellm.budget_rollover is True
|
||||
|
||||
|
||||
def _rollover_cap(max_budget: float | None) -> float | None:
|
||||
if max_budget is None or not math.isfinite(max_budget):
|
||||
return None
|
||||
return max_budget
|
||||
|
||||
|
||||
def _carried_spend(spend: float | None, cap: float | None) -> float:
|
||||
if cap is None:
|
||||
return 0.0
|
||||
return max(0.0, (spend or 0.0) - cap)
|
||||
|
||||
|
||||
def _row_carried_spend(row: _BudgetLinkedRow, caps: Mapping[str, float]) -> float:
|
||||
if not caps:
|
||||
return 0.0
|
||||
return _carried_spend(row.spend, caps.get(row.budget_id) if row.budget_id is not None else None)
|
||||
|
||||
|
||||
def _team_membership_counter_key(row: _TeamMembershipRow) -> str:
|
||||
return f"spend:team_member:{row.user_id}:{row.team_id}"
|
||||
|
||||
|
|
@ -129,6 +161,59 @@ def _budget_link_where(
|
|||
return {"budget_id": {"in": list(budget_ids)}, **extra}
|
||||
|
||||
|
||||
def _queue_budget_linked_resets(
|
||||
writes: LinkedSpendResetWrites,
|
||||
cascade: "_BudgetCascade",
|
||||
extra: Mapping[str, object] = MappingProxyType({}),
|
||||
) -> None:
|
||||
"""Reset one linked table's spend for every expiring tier: tiers with a
|
||||
rollover cap keep spend beyond the cap (decrement preserves writes racing
|
||||
the reset), everything else is zeroed as before. Zero the under-cap rows
|
||||
BEFORE decrementing the over-cap ones: the statements run sequentially in
|
||||
one transaction, so the reverse order lets the zero re-match a row the
|
||||
decrement just moved into the (0, cap] range and erase its carried spend."""
|
||||
for budget_id, cap in cascade.rollover_caps.items():
|
||||
writes.queue_spend_zero(
|
||||
where={"budget_id": budget_id, **extra, "spend": {"gt": 0, "lte": cap}}
|
||||
) # mutable-ok: prisma where filter must be a dict
|
||||
writes.queue_spend_decrement(
|
||||
where={"budget_id": budget_id, **extra, "spend": {"gt": cap}}, amount=cap
|
||||
) # mutable-ok: prisma where filter must be a dict
|
||||
plain_ids: Final = tuple(bid for bid in cascade.budget_ids if bid not in cascade.rollover_caps)
|
||||
if plain_ids:
|
||||
writes.queue_spend_zero(where=_budget_link_where(plain_ids, extra))
|
||||
|
||||
|
||||
def _queue_enduser_resets(writes: LinkedSpendResetWrites, cascade: "_BudgetCascade") -> None:
|
||||
"""End users are matched by id rather than budget link: rows with no
|
||||
budget_id ride the default budget tier (litellm.max_end_user_budget_id).
|
||||
Zero-before-decrement ordering matters here too (see
|
||||
_queue_budget_linked_resets)."""
|
||||
if not cascade.rollover_caps:
|
||||
if cascade.endusers:
|
||||
writes.queue_spend_zero(
|
||||
where={"user_id": {"in": [row.user_id for row in cascade.endusers]}}
|
||||
) # mutable-ok: prisma where filter must be a dict
|
||||
return
|
||||
tiered: Final = tuple((row.budget_id or litellm.max_end_user_budget_id, row.user_id) for row in cascade.endusers)
|
||||
for budget_id, cap in cascade.rollover_caps.items():
|
||||
if not (
|
||||
user_ids := [uid for bid, uid in tiered if bid == budget_id]
|
||||
): # mutable-ok: prisma "in" filter takes a list
|
||||
continue
|
||||
writes.queue_spend_zero(
|
||||
where={"user_id": {"in": user_ids}, "spend": {"lte": cap}}
|
||||
) # mutable-ok: prisma where filter must be a dict
|
||||
writes.queue_spend_decrement(
|
||||
where={"user_id": {"in": user_ids}, "spend": {"gt": cap}}, amount=cap
|
||||
) # mutable-ok: prisma where filter must be a dict
|
||||
plain: Final = [
|
||||
uid for bid, uid in tiered if bid is None or bid not in cascade.rollover_caps
|
||||
] # mutable-ok: prisma "in" filter takes a list
|
||||
if plain:
|
||||
writes.queue_spend_zero(where={"user_id": {"in": plain}}) # mutable-ok: prisma where filter must be a dict
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _BudgetCascade:
|
||||
"""Everything one budget-tier reset touches, resolved before any write."""
|
||||
|
|
@ -137,8 +222,9 @@ class _BudgetCascade:
|
|||
budget_ids: tuple[str, ...] = ()
|
||||
budget_resets: tuple[tuple[str, datetime], ...] = ()
|
||||
endusers: tuple[_EndUserRow, ...] = ()
|
||||
counter_keys: tuple[str, ...] = ()
|
||||
counter_resets: tuple[tuple[str, float], ...] = ()
|
||||
cache_keys: tuple[str, ...] = ()
|
||||
rollover_caps: Mapping[str, float] = MappingProxyType({})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -404,8 +490,10 @@ class ResetBudgetJob:
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
async def _invalidate_spend_counter(counter_key: str) -> None:
|
||||
"""Zero a spend counter so a DB-row reset takes effect immediately.
|
||||
async def _invalidate_spend_counter(counter_key: str, new_spend: float = 0.0) -> None:
|
||||
"""Overwrite a spend counter with the post-reset value (0, or the carried
|
||||
overage when budget rollover is enabled) so a DB-row reset takes effect
|
||||
immediately.
|
||||
|
||||
Call AFTER the DB write commits. Clearing Redis before the DB
|
||||
commit opens a window where get_current_spend reads 0 from Redis
|
||||
|
|
@ -414,10 +502,10 @@ class ResetBudgetJob:
|
|||
try:
|
||||
from litellm.proxy.proxy_server import spend_counter_cache
|
||||
|
||||
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=0.0, ttl=60)
|
||||
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=new_spend, ttl=60)
|
||||
if spend_counter_cache.redis_cache is not None:
|
||||
try:
|
||||
await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=0.0, ttl=60)
|
||||
await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=new_spend, ttl=60)
|
||||
except Exception as redis_err:
|
||||
verbose_proxy_logger.warning(
|
||||
"Failed to reset spend counter %s in Redis: %s. "
|
||||
|
|
@ -522,6 +610,15 @@ class ResetBudgetJob:
|
|||
where=_budget_link_where(budget_ids, _SPENT_ROWS_WHERE),
|
||||
log_subject="tags",
|
||||
)
|
||||
rollover_caps: Final[Mapping[str, float]] = MappingProxyType(
|
||||
{ # mutable-ok: MappingProxyType wraps a one-shot dict comprehension
|
||||
b.budget_id: cap
|
||||
for b in budgets_to_reset
|
||||
if b.budget_id is not None and (cap := _rollover_cap(b.max_budget)) is not None
|
||||
}
|
||||
if _rollover_enabled()
|
||||
else {} # mutable-ok: empty sentinel immediately frozen by MappingProxyType
|
||||
)
|
||||
return _BudgetCascade(
|
||||
budgets=tuple(budgets_to_reset),
|
||||
budget_ids=budget_ids,
|
||||
|
|
@ -534,12 +631,16 @@ class ResetBudgetJob:
|
|||
if b.budget_id is not None and b.budget_duration is not None
|
||||
),
|
||||
endusers=await self._collect_endusers_to_reset(budget_ids),
|
||||
counter_keys=(
|
||||
*(_team_membership_counter_key(row) for row in team_memberships),
|
||||
*(_key_counter_key(row) for row in keys),
|
||||
*(_org_counter_key(row) for row in orgs),
|
||||
*(_tag_counter_key(row) for row in tags),
|
||||
counter_resets=(
|
||||
*(
|
||||
(_team_membership_counter_key(row), _row_carried_spend(row, rollover_caps))
|
||||
for row in team_memberships
|
||||
),
|
||||
*((_key_counter_key(row), _row_carried_spend(row, rollover_caps)) for row in keys),
|
||||
*((_org_counter_key(row), _row_carried_spend(row, rollover_caps)) for row in orgs),
|
||||
*((_tag_counter_key(row), _row_carried_spend(row, rollover_caps)) for row in tags),
|
||||
),
|
||||
rollover_caps=rollover_caps,
|
||||
cache_keys=(
|
||||
*(key for row in team_memberships for key in _team_membership_cache_keys(row)),
|
||||
*(key for row in keys for key in _key_cache_keys(row)),
|
||||
|
|
@ -565,20 +666,18 @@ class ResetBudgetJob:
|
|||
)
|
||||
|
||||
async def _commit_budget_cascade_once(self, cascade: _BudgetCascade) -> None:
|
||||
enduser_ids: Final = tuple(row.user_id for row in cascade.endusers)
|
||||
async with budget_cascade_unit_of_work(self.prisma_client.db.batch_) as uow:
|
||||
uow.team_memberships.queue_spend_zero(where=_budget_link_where(cascade.budget_ids))
|
||||
uow.keys.queue_spend_zero(where=_budget_link_where(cascade.budget_ids, _LINKED_KEYS_WHERE))
|
||||
uow.organizations.queue_spend_zero(where=_budget_link_where(cascade.budget_ids, _SPENT_ROWS_WHERE))
|
||||
uow.tags.queue_spend_zero(where=_budget_link_where(cascade.budget_ids, _SPENT_ROWS_WHERE))
|
||||
if enduser_ids:
|
||||
uow.endusers.queue_spend_zero(where={"user_id": {"in": list(enduser_ids)}})
|
||||
_queue_budget_linked_resets(uow.team_memberships, cascade)
|
||||
_queue_budget_linked_resets(uow.keys, cascade, extra=_LINKED_KEYS_WHERE)
|
||||
_queue_budget_linked_resets(uow.organizations, cascade, extra=_SPENT_ROWS_WHERE)
|
||||
_queue_budget_linked_resets(uow.tags, cascade, extra=_SPENT_ROWS_WHERE)
|
||||
_queue_enduser_resets(uow.endusers, cascade)
|
||||
for budget_id, budget_reset_at in cascade.budget_resets:
|
||||
uow.budgets.queue_window_advance(budget_id=budget_id, budget_reset_at=budget_reset_at)
|
||||
|
||||
async def _invalidate_budget_cascade_caches(self, cascade: _BudgetCascade) -> None:
|
||||
for counter_key in cascade.counter_keys:
|
||||
await self._invalidate_spend_counter(counter_key)
|
||||
for counter_key, new_spend in cascade.counter_resets:
|
||||
await self._invalidate_spend_counter(counter_key, new_spend=new_spend)
|
||||
for cache_key in cascade.cache_keys:
|
||||
await self._invalidate_user_api_key_cache_entry(cache_key)
|
||||
|
||||
|
|
@ -708,7 +807,11 @@ class ResetBudgetJob:
|
|||
for k in updated_keys:
|
||||
if k.token is None:
|
||||
continue
|
||||
uow.keys.queue_spend_reset(token=k.token, budget_reset_at=k.budget_reset_at)
|
||||
uow.keys.queue_spend_reset(
|
||||
token=k.token,
|
||||
budget_reset_at=k.budget_reset_at,
|
||||
spend_decrement=k.max_budget if (k.spend or 0.0) > 0.0 else None,
|
||||
)
|
||||
|
||||
async def _write_user_reset_updates(self, updated_users: list[LiteLLM_UserTable]) -> None:
|
||||
"""
|
||||
|
|
@ -726,7 +829,11 @@ class ResetBudgetJob:
|
|||
async def _write_user_reset_updates_once(self, updated_users: list[LiteLLM_UserTable]) -> None:
|
||||
async with spend_reset_unit_of_work(self.prisma_client.db.batch_) as uow:
|
||||
for u in updated_users:
|
||||
uow.users.queue_spend_reset(user_id=u.user_id, budget_reset_at=u.budget_reset_at)
|
||||
uow.users.queue_spend_reset(
|
||||
user_id=u.user_id,
|
||||
budget_reset_at=u.budget_reset_at,
|
||||
spend_decrement=u.max_budget if (u.spend or 0.0) > 0.0 else None,
|
||||
)
|
||||
|
||||
async def _write_team_reset_updates(self, updated_teams: list[LiteLLM_TeamTable]) -> None:
|
||||
"""
|
||||
|
|
@ -744,7 +851,11 @@ class ResetBudgetJob:
|
|||
async def _write_team_reset_updates_once(self, updated_teams: list[LiteLLM_TeamTable]) -> None:
|
||||
async with spend_reset_unit_of_work(self.prisma_client.db.batch_) as uow:
|
||||
for t in updated_teams:
|
||||
uow.teams.queue_spend_reset(team_id=t.team_id, budget_reset_at=t.budget_reset_at)
|
||||
uow.teams.queue_spend_reset(
|
||||
team_id=t.team_id,
|
||||
budget_reset_at=t.budget_reset_at,
|
||||
spend_decrement=t.max_budget if (t.spend or 0.0) > 0.0 else None,
|
||||
)
|
||||
|
||||
def _emit_phase_failure(
|
||||
self,
|
||||
|
|
@ -820,7 +931,7 @@ class ResetBudgetJob:
|
|||
for k in updated_keys:
|
||||
token = getattr(k, "token", None)
|
||||
if token:
|
||||
await self._invalidate_spend_counter(f"spend:key:{token}")
|
||||
await self._invalidate_spend_counter(f"spend:key:{token}", new_spend=k.spend or 0.0)
|
||||
|
||||
end_time = time.time()
|
||||
outcome: Final = _ChunkOutcome(
|
||||
|
|
@ -925,7 +1036,7 @@ class ResetBudgetJob:
|
|||
for u in updated_users:
|
||||
user_id = getattr(u, "user_id", None)
|
||||
if user_id:
|
||||
await self._invalidate_spend_counter(f"spend:user:{user_id}")
|
||||
await self._invalidate_spend_counter(f"spend:user:{user_id}", new_spend=u.spend or 0.0)
|
||||
if user_id == LITELLM_PROXY_BUDGET_NAME:
|
||||
await self._invalidate_global_proxy_spend_cache()
|
||||
|
||||
|
|
@ -1034,7 +1145,7 @@ class ResetBudgetJob:
|
|||
for t in updated_teams:
|
||||
team_id = getattr(t, "team_id", None)
|
||||
if team_id:
|
||||
await self._invalidate_spend_counter(f"spend:team:{team_id}")
|
||||
await self._invalidate_spend_counter(f"spend:team:{team_id}", new_spend=t.spend or 0.0)
|
||||
|
||||
end_time = time.time()
|
||||
outcome: Final = _ChunkOutcome(
|
||||
|
|
@ -1107,10 +1218,11 @@ class ResetBudgetJob:
|
|||
reset_at: Final = datetime.fromisoformat(reset_at_str.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
if reset_at > now:
|
||||
return False
|
||||
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=0.0)
|
||||
new_value: Final = await ResetBudgetJob._window_carried_spend(window, counter_key, spend_counter_cache)
|
||||
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=new_value)
|
||||
if spend_counter_cache.redis_cache is not None:
|
||||
try:
|
||||
await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=0.0)
|
||||
await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=new_value)
|
||||
except Exception as redis_err:
|
||||
verbose_proxy_logger.warning("Failed to reset Redis counter %s: %s", counter_key, redis_err)
|
||||
window["reset_at"] = compute_budget_reset_at(
|
||||
|
|
@ -1118,6 +1230,27 @@ class ResetBudgetJob:
|
|||
).isoformat()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def _window_carried_spend(
|
||||
window: Mapping[str, object], counter_key: str, spend_counter_cache: DualCache
|
||||
) -> float:
|
||||
"""Per-window spend lives only in the counter, so the carried overage is
|
||||
read from it before the reset overwrites it."""
|
||||
if not _rollover_enabled():
|
||||
return 0.0
|
||||
window_max: Final = window.get("max_budget")
|
||||
cap: Final = _rollover_cap(window_max) if isinstance(window_max, (int, float)) else None
|
||||
if cap is None:
|
||||
return 0.0
|
||||
try:
|
||||
current: Final = await spend_counter_cache.async_get_cache(key=counter_key)
|
||||
except Exception as e: # noqa: BLE001 # an unreadable counter falls back to a plain zero reset
|
||||
verbose_proxy_logger.warning("Failed to read spend counter %s for rollover: %s", counter_key, e)
|
||||
return 0.0
|
||||
if not isinstance(current, (int, float)):
|
||||
return 0.0
|
||||
return _carried_spend(float(current), cap)
|
||||
|
||||
async def reset_budget_windows(self) -> None:
|
||||
"""
|
||||
For keys and teams with budget_limits, reset any individual windows where
|
||||
|
|
@ -1222,7 +1355,7 @@ class ResetBudgetJob:
|
|||
still holds the pre-reset value, admitting requests past the cap.
|
||||
"""
|
||||
try:
|
||||
item.spend = 0.0
|
||||
item.spend = _carried_spend(item.spend, _rollover_cap(item.max_budget)) if _rollover_enabled() else 0.0
|
||||
if hasattr(item, "budget_duration") and item.budget_duration is not None:
|
||||
item.budget_reset_at = compute_budget_reset_at(
|
||||
budget_duration=item.budget_duration, settings=reset_settings
|
||||
|
|
|
|||
|
|
@ -887,6 +887,22 @@ class PrismaManager:
|
|||
return
|
||||
ProxyExtrasDBManager.apply_replica_identity_full_if_requested()
|
||||
|
||||
@staticmethod
|
||||
def _raise_if_partitioned_spend_logs() -> None:
|
||||
"""`prisma db push` rewrites a doc-partitioned LiteLLM_SpendLogs
|
||||
primary key back to ("request_id"), which Postgres rejects. Fail fast
|
||||
with guidance instead of retrying into that raw error. No-op when
|
||||
litellm-proxy-extras is absent."""
|
||||
try:
|
||||
from litellm_proxy_extras.utils import (
|
||||
PARTITIONED_SPEND_LOGS_PUSH_ERROR,
|
||||
ProxyExtrasDBManager,
|
||||
)
|
||||
except ImportError:
|
||||
return
|
||||
if ProxyExtrasDBManager.spend_logs_is_partitioned():
|
||||
raise RuntimeError(PARTITIONED_SPEND_LOGS_PUSH_ERROR)
|
||||
|
||||
@staticmethod
|
||||
def setup_database(use_migrate: bool = False, use_v2_resolver: bool = False) -> bool:
|
||||
"""
|
||||
|
|
@ -921,6 +937,7 @@ class PrismaManager:
|
|||
use_v2_resolver=use_v2_resolver,
|
||||
)
|
||||
else:
|
||||
PrismaManager._raise_if_partitioned_spend_logs()
|
||||
# Use prisma db push with increased timeout
|
||||
subprocess.run(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@ import sys
|
|||
import threading
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Set as AbstractSet
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final
|
||||
from typing import TYPE_CHECKING, Final, TypeVar
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
import litellm
|
||||
|
||||
|
|
@ -16,6 +19,7 @@ if TYPE_CHECKING:
|
|||
from litellm.router import Router
|
||||
|
||||
logger: Final = logging.getLogger(__name__)
|
||||
_DeploymentT: Final = TypeVar("_DeploymentT", bound=Mapping[str, object])
|
||||
from litellm.constants import (
|
||||
BACKGROUND_HEALTH_CHECK_MAX_TOKENS,
|
||||
BACKGROUND_HEALTH_CHECK_MAX_TOKENS_REASONING,
|
||||
|
|
@ -167,6 +171,38 @@ def health_check_filter_kwargs_from_general_settings(
|
|||
}
|
||||
|
||||
|
||||
def parse_background_health_check_model_groups(
|
||||
general_settings: Mapping[str, object] | None,
|
||||
) -> frozenset[str] | None:
|
||||
"""
|
||||
Read ``general_settings.background_health_check_model_groups``.
|
||||
|
||||
``None`` means the allowlist is unset and every deployment participates
|
||||
(legacy behavior). A list scopes background health checks and health-check
|
||||
routing to deployments whose ``model_name`` is listed. A malformed value
|
||||
raises so the proxy fails at startup instead of silently probing everything.
|
||||
"""
|
||||
raw: Final = (general_settings or {}).get("background_health_check_model_groups")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return frozenset(TypeAdapter(list[str]).validate_python(raw))
|
||||
except ValidationError as e:
|
||||
raise ValueError(
|
||||
"general_settings.background_health_check_model_groups must be a list of model group names"
|
||||
) from e
|
||||
|
||||
|
||||
def filter_deployments_to_model_groups(
|
||||
model_list: Sequence[_DeploymentT],
|
||||
model_groups: AbstractSet[str] | None,
|
||||
) -> tuple[_DeploymentT, ...]:
|
||||
"""Deployments whose ``model_name`` is in ``model_groups``; all of them when unset."""
|
||||
if model_groups is None:
|
||||
return tuple(model_list)
|
||||
return tuple(x for x in model_list if x.get("model_name") in model_groups)
|
||||
|
||||
|
||||
def filter_deployments_by_id(
|
||||
model_list: Sequence[Mapping[str, object]],
|
||||
) -> list:
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from litellm.constants import (
|
|||
CONSUMED_REQUEST_TAGS_METADATA_KEY,
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY,
|
||||
LITELLM_PROXY_MASTER_KEY_ALIAS,
|
||||
OTEL_SERVICE_NAME_METADATA_KEYS,
|
||||
PRE_CALL_EXECUTED_GUARDRAILS_KEY,
|
||||
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
|
||||
)
|
||||
|
|
@ -2003,6 +2004,19 @@ async def add_litellm_data_to_request(
|
|||
_metadata_variable_name=_metadata_variable_name,
|
||||
)
|
||||
|
||||
# A key's OTel service name outranks its team's, so the key's values are
|
||||
# re-applied after the last-writer-wins team metadata merge above
|
||||
_key_otel_service_names: Final = {
|
||||
field: value
|
||||
for field, value in (key_metadata or {}).items()
|
||||
if field in OTEL_SERVICE_NAME_METADATA_KEYS and isinstance(value, str) and value.strip()
|
||||
}
|
||||
data = LiteLLMProxyRequestSetup.add_management_endpoint_metadata_to_request_metadata(
|
||||
data=data,
|
||||
management_endpoint_metadata=_key_otel_service_names,
|
||||
_metadata_variable_name=_metadata_variable_name,
|
||||
)
|
||||
|
||||
# Team spend, budget - used by prometheus.py
|
||||
data[_metadata_variable_name]["user_api_key_team_max_budget"] = user_api_key_dict.team_max_budget
|
||||
data[_metadata_variable_name]["user_api_key_team_spend"] = user_api_key_dict.team_spend
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ def _models_this_test_can_call(config: RequestComplexityRouterConfig) -> tuple[s
|
|||
model
|
||||
for model in (
|
||||
config.classifier_llm_config.model
|
||||
if config.classifier_type == "llm" and config.classifier_llm_config is not None
|
||||
if config.uses_llm_classifier and config.classifier_llm_config is not None
|
||||
else None,
|
||||
config.embedding_model if config.semantic_keyword_matching else None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ All /budget management endpoints
|
|||
|
||||
#### BUDGET TABLE MANAGEMENT ####
|
||||
import math
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
|
@ -178,13 +179,17 @@ async def update_budget(
|
|||
else {}
|
||||
)
|
||||
|
||||
response: Final = await BudgetRepository(prisma_client).table.update(
|
||||
where={"budget_id": budget_obj.budget_id},
|
||||
data={
|
||||
budget_obj_jsonified: Final[Mapping[str, object]] = jsonify_object(
|
||||
{
|
||||
**budget_obj.model_dump(exclude_unset=True),
|
||||
**recomputed_reset_at,
|
||||
"updated_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
response: Final = await BudgetRepository(prisma_client).table.update(
|
||||
where={"budget_id": budget_obj.budget_id},
|
||||
data=budget_obj_jsonified,
|
||||
)
|
||||
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -2190,7 +2190,7 @@ async def _get_and_validate_existing_key(
|
|||
|
||||
existing_key_row: Final[LiteLLM_VerificationToken | None] = await _prisma_table(
|
||||
VerificationTokenRepository(prisma_client)
|
||||
).find_unique(where={"token": hashed_token})
|
||||
).find_unique(where={"token": hashed_token}, include={"object_permission": True})
|
||||
|
||||
if existing_key_row is None:
|
||||
raise ProxyException(
|
||||
|
|
@ -2442,11 +2442,13 @@ async def _validate_mcp_servers_for_key_update(
|
|||
check_db_only=True,
|
||||
)
|
||||
object_permission_dict: Final = _object_permission_to_dict(data.object_permission)
|
||||
team_unchanged: Final = data.team_id is None or data.team_id == existing_key_row.team_id
|
||||
normalized_object_permission: Final = await validate_key_mcp_servers_against_team(
|
||||
object_permission=object_permission_dict,
|
||||
team_obj=effective_team_obj,
|
||||
prisma_client=prisma_client,
|
||||
is_proxy_admin=is_proxy_admin,
|
||||
existing_key_object_permission=existing_key_row.object_permission if team_unchanged else None,
|
||||
)
|
||||
await validate_key_search_tools_against_team(
|
||||
object_permission=object_permission_dict,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ This is an enterprise feature and requires a premium license.
|
|||
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from itertools import chain
|
||||
|
|
@ -2375,6 +2376,37 @@ async def get_group(
|
|||
raise handle_exception_on_proxy(e)
|
||||
|
||||
|
||||
def _new_team_request_with_defaults(
|
||||
team_id: str,
|
||||
team_alias: str | None,
|
||||
members_with_roles: Sequence[Member],
|
||||
) -> NewTeamRequest:
|
||||
"""Build the SCIM group's team request, applying litellm.default_team_params
|
||||
(including models) the same way SSO auto-created teams do."""
|
||||
default_params: Final = litellm.default_team_params
|
||||
defaults: Final[Mapping[str, object]] = (
|
||||
deepcopy(default_params)
|
||||
if isinstance(default_params, dict)
|
||||
else default_params.model_dump(exclude_none=True)
|
||||
if default_params is not None
|
||||
else {}
|
||||
)
|
||||
default_metadata: Final = defaults.get("metadata")
|
||||
metadata: Final = {
|
||||
**(default_metadata if isinstance(default_metadata, dict) else {}),
|
||||
SCIM_MANAGED_TEAM_METADATA_KEY: True,
|
||||
}
|
||||
return NewTeamRequest.model_validate(
|
||||
{
|
||||
**defaults,
|
||||
"team_id": team_id,
|
||||
"team_alias": team_alias,
|
||||
"members_with_roles": members_with_roles,
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@scim_router.post(
|
||||
"/Groups",
|
||||
response_model=SCIMGroup,
|
||||
|
|
@ -2412,11 +2444,10 @@ async def create_group(
|
|||
|
||||
# Create team in database
|
||||
created_team: Final = await new_team(
|
||||
data=NewTeamRequest(
|
||||
data=_new_team_request_with_defaults(
|
||||
team_id=team_id,
|
||||
team_alias=group.displayName,
|
||||
members_with_roles=members_with_roles,
|
||||
metadata={SCIM_MANAGED_TEAM_METADATA_KEY: True},
|
||||
),
|
||||
http_request=Request(scope={"type": "http", "path": "/scim/v2/Groups"}),
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
||||
|
|
|
|||
|
|
@ -808,6 +808,15 @@ def normalize_email(email: str | None) -> str | None:
|
|||
return email.lower() if isinstance(email, str) else email
|
||||
|
||||
|
||||
# Ordered highest to lowest privilege
|
||||
LITELLM_USER_ROLE_HIERARCHY: Final = (
|
||||
LitellmUserRoles.PROXY_ADMIN,
|
||||
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
|
||||
LitellmUserRoles.INTERNAL_USER,
|
||||
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
|
||||
)
|
||||
|
||||
|
||||
def determine_role_from_groups(
|
||||
user_groups: list[str],
|
||||
role_mappings: "RoleMappings",
|
||||
|
|
@ -832,19 +841,11 @@ def determine_role_from_groups(
|
|||
# No role mappings configured, return default_role
|
||||
return role_mappings.default_role
|
||||
|
||||
# Role hierarchy (highest to lowest)
|
||||
role_hierarchy: Final = [
|
||||
LitellmUserRoles.PROXY_ADMIN,
|
||||
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
|
||||
LitellmUserRoles.INTERNAL_USER,
|
||||
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
|
||||
]
|
||||
|
||||
# Convert user_groups to a set for efficient lookup
|
||||
user_groups_set: Final = set(user_groups) if isinstance(user_groups, list) else set()
|
||||
|
||||
# Find the highest privilege role the user belongs to
|
||||
for role in role_hierarchy:
|
||||
for role in LITELLM_USER_ROLE_HIERARCHY:
|
||||
if role in role_mappings.roles:
|
||||
role_groups = role_mappings.roles[role]
|
||||
if isinstance(role_groups, list) and user_groups_set.intersection(set(role_groups)):
|
||||
|
|
@ -4236,15 +4237,7 @@ class MicrosoftSSOHandler:
|
|||
verbose_proxy_logger.debug("Extracted app roles from id_token: %s", app_roles)
|
||||
|
||||
# Combine groups and app roles
|
||||
user_role: LitellmUserRoles | None = None
|
||||
if app_roles:
|
||||
# Check if any app role is a valid LitellmUserRoles
|
||||
for role_str in app_roles:
|
||||
role = get_litellm_user_role(role_str)
|
||||
if role is not None:
|
||||
user_role = role
|
||||
verbose_proxy_logger.debug("Found valid LitellmUserRoles '%s' in app_roles", role.value)
|
||||
break
|
||||
user_role: Final = MicrosoftSSOHandler.get_user_role_from_app_roles(app_roles)
|
||||
|
||||
verbose_proxy_logger.debug("Combined team_ids (groups + app roles): %s", user_team_ids)
|
||||
|
||||
|
|
@ -4282,6 +4275,27 @@ class MicrosoftSSOHandler:
|
|||
verbose_proxy_logger.debug("Microsoft SSO OpenID Response: %s", openid_response)
|
||||
return openid_response
|
||||
|
||||
@staticmethod
|
||||
def get_user_role_from_app_roles(
|
||||
app_roles: Sequence[str] | None,
|
||||
) -> LitellmUserRoles | None:
|
||||
"""
|
||||
Resolve the one role LiteLLM stores for a user from their Entra app roles.
|
||||
|
||||
Entra does not guarantee `roles` claim ordering, so a user holding several app
|
||||
roles resolves to the highest privilege one rather than whichever the claim
|
||||
listed first. Roles the hierarchy does not rank (org_admin, team, customer)
|
||||
resolve by name to stay deterministic
|
||||
"""
|
||||
resolved: Final = frozenset(
|
||||
role for role in (get_litellm_user_role(role_str) for role_str in app_roles or ()) if role is not None
|
||||
)
|
||||
if not resolved:
|
||||
return None
|
||||
|
||||
ranked: Final = next((role for role in LITELLM_USER_ROLE_HIERARCHY if role in resolved), None)
|
||||
return ranked if ranked is not None else min(resolved, key=lambda role: role.value)
|
||||
|
||||
@staticmethod
|
||||
def get_app_roles_from_id_token(id_token: str | None) -> list[str]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -447,6 +447,36 @@ async def enforce_all_proxy_mcp_servers_grant_is_admin_only(
|
|||
)
|
||||
|
||||
|
||||
async def _get_grandfathered_key_mcp_server_ids(
|
||||
existing_object_permission: Optional["LiteLLM_ObjectPermissionTable"],
|
||||
prisma_client: PrismaClient | None,
|
||||
) -> frozenset[str]:
|
||||
"""
|
||||
Resolve the canonical MCP server IDs a key's stored object_permission already
|
||||
grants. Updates that keep or shrink those grants stay valid even when the
|
||||
team allowlist has since changed; sentinels are excluded so they cannot
|
||||
grandfather anything.
|
||||
"""
|
||||
if existing_object_permission is None or prisma_client is None:
|
||||
return frozenset()
|
||||
raw_tool_perms: Final = existing_object_permission.mcp_tool_permissions or {}
|
||||
tool_perm_keys: Final[frozenset[str]] = frozenset(
|
||||
json.loads(raw_tool_perms).keys() if isinstance(raw_tool_perms, str) else raw_tool_perms.keys()
|
||||
)
|
||||
identifiers: Final = (frozenset(existing_object_permission.mcp_servers or []) | tool_perm_keys) - {
|
||||
SpecialMCPServerNames.no_mcp_servers.value,
|
||||
SpecialMCPServerName.all_proxy_servers.value,
|
||||
}
|
||||
return frozenset(
|
||||
_flatten_resolved_mcp_server_ids(
|
||||
await _resolve_mcp_server_identifiers_to_ids(
|
||||
identifiers=set(identifiers),
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _get_team_allowed_mcp_servers(
|
||||
team_obj: Optional["LiteLLM_TeamTableCachedObj"],
|
||||
prisma_client: PrismaClient | None = None,
|
||||
|
|
@ -527,10 +557,16 @@ async def validate_key_mcp_servers_against_team(
|
|||
team_obj: Optional["LiteLLM_TeamTableCachedObj"],
|
||||
prisma_client: PrismaClient | None = None,
|
||||
is_proxy_admin: bool = False,
|
||||
existing_key_object_permission: Optional["LiteLLM_ObjectPermissionTable"] = None,
|
||||
) -> ObjectPermissionDict | None:
|
||||
"""
|
||||
Validate that MCP servers requested on a key are within the allowed scope.
|
||||
|
||||
When ``existing_key_object_permission`` is provided (key updates), servers
|
||||
the key already holds are grandfathered: keeping or removing them stays valid
|
||||
even if the team allowlist has since shrunk, while adding new servers outside
|
||||
the allowlist is still rejected.
|
||||
|
||||
Rules:
|
||||
- If key is in a team: key's mcp_servers must be a subset of
|
||||
(team's allowed servers + allow_all_keys servers)
|
||||
|
|
@ -589,7 +625,11 @@ async def validate_key_mcp_servers_against_team(
|
|||
if teamless_admin_assignment:
|
||||
allowed_servers = all_allowed_servers | active_requested_servers
|
||||
|
||||
disallowed_servers: Final = active_requested_servers - allowed_servers
|
||||
grandfathered_servers: Final = await _get_grandfathered_key_mcp_server_ids(
|
||||
existing_object_permission=existing_key_object_permission,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
disallowed_servers: Final = active_requested_servers - allowed_servers - grandfathered_servers
|
||||
if disallowed_servers:
|
||||
if team_obj is not None:
|
||||
team_id = team_obj.team_id
|
||||
|
|
|
|||
|
|
@ -1021,19 +1021,7 @@ async def delete_prompt(
|
|||
# Delete versions from the database (scoped to environment if provided)
|
||||
await _prompt_table(prisma_client).delete_many(where=delete_where)
|
||||
|
||||
# Remove matching prompts from memory — scope to environment if provided
|
||||
if environment:
|
||||
prompts_to_delete: Final = [
|
||||
pid
|
||||
for pid, prompt in IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS.items()
|
||||
if get_base_prompt_id(prompt_id=pid) == base_prompt_id and prompt.environment == environment
|
||||
]
|
||||
for pid in prompts_to_delete:
|
||||
del IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS[pid]
|
||||
if pid in IN_MEMORY_PROMPT_REGISTRY.prompt_id_to_custom_prompt:
|
||||
del IN_MEMORY_PROMPT_REGISTRY.prompt_id_to_custom_prompt[pid]
|
||||
else:
|
||||
IN_MEMORY_PROMPT_REGISTRY.delete_prompts_by_base_id(base_prompt_id)
|
||||
IN_MEMORY_PROMPT_REGISTRY.delete_prompts_by_base_id(base_prompt_id, environment=environment or None)
|
||||
|
||||
env_msg: Final = f" from {environment}" if environment else ""
|
||||
return {"message": f"Prompt {base_prompt_id} deleted successfully{env_msg}"}
|
||||
|
|
|
|||
|
|
@ -195,12 +195,22 @@ class InMemoryPromptRegistry:
|
|||
"""
|
||||
return self.prompt_id_to_custom_prompt.get(prompt_id)
|
||||
|
||||
def delete_prompts_by_base_id(self, base_prompt_id: str) -> list[str]:
|
||||
def remove_prompt(self, prompt_id: str) -> None:
|
||||
import litellm
|
||||
|
||||
self.IN_MEMORY_PROMPTS.pop(prompt_id, None)
|
||||
stale_callback: Final = self.prompt_id_to_custom_prompt.pop(prompt_id, None)
|
||||
if stale_callback is not None:
|
||||
litellm.logging_callback_manager.remove_callback_from_all_lists(stale_callback)
|
||||
|
||||
def delete_prompts_by_base_id(self, base_prompt_id: str, environment: str | None = None) -> list[str]:
|
||||
"""
|
||||
Delete all prompts matching the given base prompt ID from memory.
|
||||
Delete all prompts matching the given base prompt ID from memory, along with their
|
||||
registered callbacks; scoped to one environment when given.
|
||||
|
||||
Args:
|
||||
base_prompt_id: The base prompt ID (without version suffix)
|
||||
environment: When set, only delete prompts deployed to this environment
|
||||
|
||||
Returns:
|
||||
List of prompt IDs that were deleted
|
||||
|
|
@ -208,13 +218,14 @@ class InMemoryPromptRegistry:
|
|||
from litellm.proxy.prompts.prompt_endpoints import get_base_prompt_id
|
||||
|
||||
prompts_to_delete: Final = [
|
||||
pid for pid in self.IN_MEMORY_PROMPTS if get_base_prompt_id(prompt_id=pid) == base_prompt_id
|
||||
pid
|
||||
for pid, prompt in self.IN_MEMORY_PROMPTS.items()
|
||||
if get_base_prompt_id(prompt_id=pid) == base_prompt_id
|
||||
and (environment is None or prompt.environment == environment)
|
||||
]
|
||||
|
||||
for pid in prompts_to_delete:
|
||||
del self.IN_MEMORY_PROMPTS[pid]
|
||||
if pid in self.prompt_id_to_custom_prompt:
|
||||
del self.prompt_id_to_custom_prompt[pid]
|
||||
self.remove_prompt(prompt_id=pid)
|
||||
|
||||
return prompts_to_delete
|
||||
|
||||
|
|
|
|||
|
|
@ -1321,10 +1321,10 @@ def run_server(
|
|||
use_v2_resolver=use_v2_migration_resolver,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
# v2 resolver raises on unrecoverable migration errors
|
||||
# (e.g. non-idempotent failures, permission issues).
|
||||
# v1 never raises here, so this only fires when the
|
||||
# operator opted into v2.
|
||||
# Raised on unrecoverable migration errors: the v2
|
||||
# resolver's non-idempotent failures and permission
|
||||
# issues, and any `prisma db push` against a
|
||||
# partitioned LiteLLM_SpendLogs.
|
||||
print(
|
||||
f"\033[1;31mLiteLLM Proxy: Database migration cannot proceed. {e}\033[0m",
|
||||
file=sys.stderr,
|
||||
|
|
|
|||
|
|
@ -412,7 +412,9 @@ from litellm.proxy.guardrails.init_guardrails import (
|
|||
initialize_guardrails,
|
||||
)
|
||||
from litellm.proxy.health_check import (
|
||||
filter_deployments_to_model_groups,
|
||||
health_check_filter_kwargs_from_general_settings,
|
||||
parse_background_health_check_model_groups,
|
||||
perform_health_check,
|
||||
)
|
||||
from litellm.proxy.health_endpoints._health_endpoints import router as health_router
|
||||
|
|
@ -3661,6 +3663,13 @@ async def _run_background_health_check():
|
|||
_llm_model_list = [
|
||||
m for m in _llm_model_list if not m.get("model_info", {}).get("disable_background_health_check", False)
|
||||
]
|
||||
scoped_model_groups = llm_router.background_health_check_model_groups if llm_router is not None else None
|
||||
_llm_model_list = list(filter_deployments_to_model_groups(_llm_model_list, scoped_model_groups))
|
||||
if scoped_model_groups is not None and not _llm_model_list:
|
||||
verbose_proxy_logger.warning(
|
||||
"background_health_check_model_groups matched no deployments; groups=%s",
|
||||
sorted(scoped_model_groups),
|
||||
)
|
||||
model_count_enabled = len(_llm_model_list)
|
||||
expected_peak_in_flight = model_count_enabled
|
||||
if isinstance(health_check_concurrency, int) and health_check_concurrency > 0 and model_count_enabled > 0:
|
||||
|
|
@ -5240,6 +5249,7 @@ class ProxyConfig:
|
|||
general_settings = config.get("general_settings", {})
|
||||
if general_settings is None:
|
||||
general_settings = {}
|
||||
_bg_hc_model_groups: Final = parse_background_health_check_model_groups(general_settings)
|
||||
_enable_hc_routing = False
|
||||
_hc_staleness = None
|
||||
_hc_ignore_transient = False
|
||||
|
|
@ -5435,13 +5445,14 @@ class ProxyConfig:
|
|||
_hc_staleness = general_settings.get("health_check_staleness_threshold", None)
|
||||
_hc_ignore_transient = general_settings.get("health_check_ignore_transient_errors", False)
|
||||
verbose_proxy_logger.info(
|
||||
"background_health_check_config enabled=%s shared=%s interval_seconds=%s max_concurrency=%s details=%s health_check_routing=%s",
|
||||
"background_health_check_config enabled=%s shared=%s interval_seconds=%s max_concurrency=%s details=%s health_check_routing=%s model_groups=%s",
|
||||
use_background_health_checks,
|
||||
use_shared_health_check,
|
||||
health_check_interval,
|
||||
health_check_concurrency,
|
||||
health_check_details,
|
||||
_enable_hc_routing,
|
||||
sorted(_bg_hc_model_groups) if _bg_hc_model_groups is not None else None,
|
||||
)
|
||||
|
||||
### RBAC ###
|
||||
|
|
@ -5473,6 +5484,8 @@ class ProxyConfig:
|
|||
router_params["health_check_staleness_threshold"] = _hc_staleness
|
||||
if _hc_ignore_transient:
|
||||
router_params["health_check_ignore_transient_errors"] = True
|
||||
if _bg_hc_model_groups is not None:
|
||||
router_params["background_health_check_model_groups"] = sorted(_bg_hc_model_groups)
|
||||
## MODEL LIST
|
||||
model_list: Final = config.get("model_list", None)
|
||||
if model_list:
|
||||
|
|
@ -7269,6 +7282,7 @@ class ProxyConfig:
|
|||
return None
|
||||
|
||||
try:
|
||||
prompt_ids_loaded_before_db_read: Final = frozenset(IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS)
|
||||
prompts_in_db: Final[Sequence[object]] = await PromptRepository(prisma_client).table.find_many()
|
||||
parsed_specs: Final[tuple[PromptSpec, ...]] = tuple(
|
||||
spec for row in prompts_in_db if (spec := parse_row(row)) is not None
|
||||
|
|
@ -7291,6 +7305,18 @@ class ProxyConfig:
|
|||
prompt_spec.prompt_id,
|
||||
prompt_sync_error,
|
||||
)
|
||||
# An unparsable row still exists in the DB, so skip the sweep rather than unload its in-memory copy
|
||||
every_row_parsed: Final = len(parsed_specs) == len(prompts_in_db)
|
||||
if every_row_parsed:
|
||||
deleted_db_prompt_ids: Final = tuple(
|
||||
prompt_id
|
||||
for prompt_id in prompt_ids_loaded_before_db_read
|
||||
if (loaded_spec := IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS.get(prompt_id)) is not None
|
||||
and loaded_spec.prompt_info.prompt_type == "db"
|
||||
and prompt_id not in newest_spec_per_id
|
||||
)
|
||||
for deleted_prompt_id in deleted_db_prompt_ids:
|
||||
IN_MEMORY_PROMPT_REGISTRY.remove_prompt(prompt_id=deleted_prompt_id)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.debug("litellm.proxy.proxy_server.py::ProxyConfig:_init_prompts_in_db - %s", e)
|
||||
|
||||
|
|
@ -16425,6 +16451,13 @@ _GENERAL_SETTINGS_UI_LITELLM_FIELDS: Final[dict[str, GeneralSettingsUILiteLLMFie
|
|||
"tab": "prompt_caching",
|
||||
"description": "Empty uses Anthropic's 5m default. 1h suits long sessions but doubles the cache write cost.",
|
||||
},
|
||||
"budget_rollover": { # mutable-ok: registry literal, frozen with its siblings below
|
||||
"type": "Boolean",
|
||||
"description": (
|
||||
"Carry spend beyond max_budget into the next window when budgets reset, instead of "
|
||||
"forgiving it. Applies to key, user, team, team member, org, tag and end-user budgets."
|
||||
),
|
||||
},
|
||||
"max_ui_session_budget": {
|
||||
"type": "Dollar",
|
||||
"default": 1.0,
|
||||
|
|
|
|||
|
|
@ -18,11 +18,12 @@ from typing import (
|
|||
)
|
||||
|
||||
import fastapi
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from typing_extensions import ReadOnly
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy._types import ProviderBudgetResponse, ProviderBudgetResponseObject
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
|
@ -54,6 +55,11 @@ router: Final = APIRouter()
|
|||
|
||||
SPEND_LOGS_PAGINATION_COUNT_CAP: Final = 10000
|
||||
|
||||
_INTERNAL_HEALTH_CHECK_API_KEYS: Final = (
|
||||
LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME,
|
||||
hash_token(token=LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME),
|
||||
)
|
||||
|
||||
_RowT = TypeVar("_RowT")
|
||||
|
||||
|
||||
|
|
@ -208,9 +214,18 @@ async def _find_spend_logs(
|
|||
prisma_client: PrismaClient,
|
||||
where: Mapping[str, object],
|
||||
order: Mapping[str, str],
|
||||
take: int,
|
||||
http_response: Response,
|
||||
) -> Sequence[_SupportsModelDump]:
|
||||
"""Read spend log rows as Prisma model instances."""
|
||||
return await _spend_logs_table(prisma_client).find_many(where=where, order=order)
|
||||
"""Read spend log rows as Prisma model instances, capped at ``take`` rows."""
|
||||
rows: Final = await _spend_logs_table(prisma_client).find_many(where=where, order=order, take=take)
|
||||
if len(rows) == take:
|
||||
http_response.headers["x-litellm-spend-logs-truncated"] = "true"
|
||||
verbose_proxy_logger.warning(
|
||||
"/spend/logs result truncated to the %s most recent rows; use /spend/logs/v2 for paginated access",
|
||||
take,
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
async def _find_spend_log_row(prisma_client: PrismaClient, request_id: str) -> _SpendLogOwnershipRow | None:
|
||||
|
|
@ -2239,6 +2254,10 @@ async def ui_view_spend_logs(
|
|||
status_filter: str | None = fastapi.Query(
|
||||
default=None, description="Filter logs by status (e.g., success, failure)"
|
||||
),
|
||||
cache_hit_filter: str | None = fastapi.Query(
|
||||
default=None,
|
||||
description="Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state",
|
||||
),
|
||||
model: str | None = fastapi.Query(default=None, description="Filter logs by model"),
|
||||
model_id: str | None = fastapi.Query(
|
||||
default=None,
|
||||
|
|
@ -2259,6 +2278,10 @@ async def ui_view_spend_logs(
|
|||
default="desc",
|
||||
description="Sort order: asc or desc",
|
||||
),
|
||||
exclude_internal_health_checks: bool = fastapi.Query(
|
||||
default=False,
|
||||
description="Exclude LiteLLM internal health check requests from results",
|
||||
),
|
||||
):
|
||||
"""
|
||||
View spend logs with pagination support.
|
||||
|
|
@ -2311,6 +2334,13 @@ async def ui_view_spend_logs(
|
|||
param="sort_order",
|
||||
code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if isinstance(cache_hit_filter, str) and cache_hit_filter not in {"hit", "miss"}:
|
||||
raise ProxyException(
|
||||
message=f"Invalid cache_hit_filter: {cache_hit_filter}. Must be one of: hit, miss",
|
||||
type="bad_request",
|
||||
param="cache_hit_filter",
|
||||
code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
is_admin_view: Final = _is_admin_view_safe(user_api_key_dict=user_api_key_dict)
|
||||
|
|
@ -2551,6 +2581,16 @@ async def ui_view_spend_logs(
|
|||
sql_params.append(status_filter)
|
||||
p += 1
|
||||
|
||||
if cache_hit_filter == "hit":
|
||||
sql_conditions.append("LOWER(cache_hit) = 'true'")
|
||||
elif cache_hit_filter == "miss":
|
||||
sql_conditions.append("(cache_hit IS NULL OR LOWER(cache_hit) != 'true')")
|
||||
|
||||
if exclude_internal_health_checks:
|
||||
sql_conditions.append(f"api_key NOT IN (${p}, ${p + 1})")
|
||||
sql_params.extend(_INTERNAL_HEALTH_CHECK_API_KEYS)
|
||||
p += 2 # rebind-ok: advances the file's shared $N placeholder counter
|
||||
|
||||
# Spend range
|
||||
if min_spend is not None:
|
||||
sql_conditions.append(f"spend >= ${p}")
|
||||
|
|
@ -2851,6 +2891,7 @@ async def ui_view_request_response_for_request_id(
|
|||
},
|
||||
)
|
||||
async def view_spend_logs(
|
||||
fastapi_response: Response,
|
||||
api_key: str | None = fastapi.Query(
|
||||
default=None,
|
||||
description="Get spend logs based on api key",
|
||||
|
|
@ -2881,6 +2922,8 @@ async def view_spend_logs(
|
|||
[DEPRECATED] This endpoint is not paginated and can cause performance issues.
|
||||
Please use `/spend/logs/v2` instead for paginated access to spend logs.
|
||||
|
||||
Row results are capped at 10,000 most recent entries per response.
|
||||
|
||||
View all spend logs, if request_id is provided, only logs for that request_id will be returned
|
||||
|
||||
When start_date and end_date are provided:
|
||||
|
|
@ -2931,7 +2974,6 @@ async def view_spend_logs(
|
|||
raise Exception(
|
||||
"Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys"
|
||||
)
|
||||
spend_logs = []
|
||||
if (
|
||||
start_date is not None
|
||||
and isinstance(start_date, str)
|
||||
|
|
@ -2970,6 +3012,8 @@ async def view_spend_logs(
|
|||
prisma_client,
|
||||
where=filter_query,
|
||||
order={"startTime": "desc"},
|
||||
take=SPEND_LOGS_PAGINATION_COUNT_CAP,
|
||||
http_response=fastapi_response,
|
||||
)
|
||||
return data
|
||||
|
||||
|
|
@ -3040,14 +3084,12 @@ async def view_spend_logs(
|
|||
if user_id is not None and isinstance(user_id, str):
|
||||
scoped_filter["user"] = user_id
|
||||
|
||||
if not scoped_filter:
|
||||
spend_logs = await prisma_client.get_data(table_name="spend", query_type="find_all")
|
||||
return spend_logs
|
||||
|
||||
data = await _find_spend_logs(
|
||||
prisma_client,
|
||||
where=scoped_filter,
|
||||
order={"startTime": "desc"},
|
||||
take=SPEND_LOGS_PAGINATION_COUNT_CAP,
|
||||
http_response=fastapi_response,
|
||||
)
|
||||
return data
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from litellm.litellm_core_utils.core_helpers import (
|
|||
get_litellm_metadata_from_kwargs,
|
||||
reconstruct_model_name,
|
||||
)
|
||||
from litellm.litellm_core_utils.internal_call_metadata import is_unbilled_non_inference_call
|
||||
from litellm.litellm_core_utils.litellm_logging import is_valid_sha256_hash
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps, strip_null_bytes
|
||||
from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload
|
||||
|
|
@ -277,7 +278,7 @@ def get_logging_payload(kwargs, response_obj, start_time, end_time) -> SpendLogs
|
|||
usage: dict = {}
|
||||
if call_type in ["ocr", "aocr"]:
|
||||
usage = _extract_usage_for_ocr_call(response_obj, response_obj_dict)
|
||||
else:
|
||||
elif not is_unbilled_non_inference_call(call_type, metadata, response_obj_dict):
|
||||
# Use response_obj_dict instead of response_obj to avoid calling .get() on Pydantic models
|
||||
_usage: Final = response_obj_dict.get("usage", None) or {}
|
||||
if isinstance(_usage, litellm.Usage):
|
||||
|
|
|
|||
|
|
@ -19,32 +19,57 @@ from collections.abc import AsyncGenerator, Callable, Mapping
|
|||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Final
|
||||
|
||||
from litellm.repositories.prisma_protocols import BatchTable, PrismaBatch
|
||||
|
||||
|
||||
def _spend_reset_data(budget_reset_at: datetime | None, spend_decrement: float | None) -> Mapping[str, object]:
|
||||
spend: Final[object] = (
|
||||
{"decrement": spend_decrement} # mutable-ok: prisma update payload must be a dict
|
||||
if spend_decrement is not None
|
||||
else 0
|
||||
)
|
||||
return {"spend": spend, "budget_reset_at": budget_reset_at} # mutable-ok: prisma update payload must be a dict
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class KeySpendResetWrites:
|
||||
table: BatchTable
|
||||
|
||||
def queue_spend_reset(self, token: str, budget_reset_at: datetime | None) -> None:
|
||||
self.table.update(where={"token": token}, data={"spend": 0, "budget_reset_at": budget_reset_at})
|
||||
def queue_spend_reset(
|
||||
self, token: str, budget_reset_at: datetime | None, spend_decrement: float | None = None
|
||||
) -> None:
|
||||
self.table.update(
|
||||
where={"token": token}, # mutable-ok: prisma where filter must be a dict
|
||||
data=_spend_reset_data(budget_reset_at, spend_decrement),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UserSpendResetWrites:
|
||||
table: BatchTable
|
||||
|
||||
def queue_spend_reset(self, user_id: str, budget_reset_at: datetime | None) -> None:
|
||||
self.table.update(where={"user_id": user_id}, data={"spend": 0, "budget_reset_at": budget_reset_at})
|
||||
def queue_spend_reset(
|
||||
self, user_id: str, budget_reset_at: datetime | None, spend_decrement: float | None = None
|
||||
) -> None:
|
||||
self.table.update(
|
||||
where={"user_id": user_id}, # mutable-ok: prisma where filter must be a dict
|
||||
data=_spend_reset_data(budget_reset_at, spend_decrement),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TeamSpendResetWrites:
|
||||
table: BatchTable
|
||||
|
||||
def queue_spend_reset(self, team_id: str, budget_reset_at: datetime | None) -> None:
|
||||
self.table.update(where={"team_id": team_id}, data={"spend": 0, "budget_reset_at": budget_reset_at})
|
||||
def queue_spend_reset(
|
||||
self, team_id: str, budget_reset_at: datetime | None, spend_decrement: float | None = None
|
||||
) -> None:
|
||||
self.table.update(
|
||||
where={"team_id": team_id}, # mutable-ok: prisma where filter must be a dict
|
||||
data=_spend_reset_data(budget_reset_at, spend_decrement),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -54,6 +79,14 @@ class LinkedSpendResetWrites:
|
|||
def queue_spend_zero(self, where: Mapping[str, object]) -> None:
|
||||
self.table.update_many(where=where, data={"spend": 0})
|
||||
|
||||
def queue_spend_decrement(self, where: Mapping[str, object], amount: float) -> None:
|
||||
"""``decrement`` rather than a read-then-set, so spend written between the
|
||||
cascade's read and its commit survives the reset instead of being erased."""
|
||||
self.table.update_many(
|
||||
where=where,
|
||||
data={"spend": {"decrement": amount}}, # mutable-ok: prisma update payload must be a dict
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BudgetWindowWrites:
|
||||
|
|
|
|||
|
|
@ -602,6 +602,7 @@ class Router:
|
|||
enable_health_check_routing: bool = False,
|
||||
health_check_staleness_threshold: int | None = None,
|
||||
health_check_ignore_transient_errors: bool = False,
|
||||
background_health_check_model_groups: Sequence[str] | None = None,
|
||||
enable_weighted_failover: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
|
|
@ -811,6 +812,11 @@ class Router:
|
|||
self.enable_health_check_routing = enable_health_check_routing
|
||||
self.enable_weighted_failover = enable_weighted_failover
|
||||
self.health_check_ignore_transient_errors = health_check_ignore_transient_errors
|
||||
self.background_health_check_model_groups: frozenset[str] | None = (
|
||||
frozenset(background_health_check_model_groups)
|
||||
if background_health_check_model_groups is not None
|
||||
else None
|
||||
)
|
||||
_staleness: Final = health_check_staleness_threshold or (
|
||||
DEFAULT_HEALTH_CHECK_INTERVAL * DEFAULT_HEALTH_CHECK_STALENESS_MULTIPLIER
|
||||
)
|
||||
|
|
@ -9210,7 +9216,11 @@ class Router:
|
|||
}
|
||||
|
||||
if model_id is not None:
|
||||
litellm.register_model(model_cost={model_id: model_info}, persist_across_reloads=False)
|
||||
litellm.register_model(
|
||||
model_cost={model_id: model_info},
|
||||
persist_across_reloads=False,
|
||||
warning_display_name=model,
|
||||
)
|
||||
|
||||
## OLD MODEL REGISTRATION ## Kept to prevent breaking changes
|
||||
backend_keys: Final = Router._backend_cost_map_keys(model=model, custom_llm_provider=custom_llm_provider)
|
||||
|
|
@ -12719,6 +12729,10 @@ class Router:
|
|||
"""
|
||||
Filter out deployments marked unhealthy by background health checks.
|
||||
No-op when enable_health_check_routing is False.
|
||||
When background_health_check_model_groups is set, only deployments in the
|
||||
listed model groups are filtered; every other group keeps its configured
|
||||
routing strategy untouched, and a router-level allowed_fails_policy no
|
||||
longer disables the filter for the listed groups.
|
||||
Returns all deployments if health state is unavailable, stale, or would
|
||||
exclude every candidate (safety net).
|
||||
"""
|
||||
|
|
@ -12727,8 +12741,10 @@ class Router:
|
|||
|
||||
# When allowed_fails_policy is set, cooldown is the sole routing exclusion
|
||||
# mechanism -- skip the binary health check filter so the policy threshold
|
||||
# is respected before any deployment is excluded.
|
||||
if self.allowed_fails_policy is not None:
|
||||
# is respected before any deployment is excluded. With a model-group
|
||||
# allowlist the filter is already scoped, so listed groups keep it.
|
||||
scoped_groups: Final = self.background_health_check_model_groups
|
||||
if self.allowed_fails_policy is not None and scoped_groups is None:
|
||||
return healthy_deployments
|
||||
|
||||
unhealthy_ids: Final = await self.health_state_cache.async_get_unhealthy_deployment_ids(
|
||||
|
|
@ -12737,7 +12753,12 @@ class Router:
|
|||
if not unhealthy_ids:
|
||||
return healthy_deployments
|
||||
|
||||
filtered: Final = [d for d in healthy_deployments if d["model_info"]["id"] not in unhealthy_ids]
|
||||
filtered: Final = [
|
||||
d
|
||||
for d in healthy_deployments
|
||||
if d["model_info"]["id"] not in unhealthy_ids
|
||||
or (scoped_groups is not None and d["model_name"] not in scoped_groups)
|
||||
]
|
||||
|
||||
if not filtered:
|
||||
verbose_router_logger.warning("All deployments marked unhealthy by health checks, bypassing health filter")
|
||||
|
|
@ -12754,14 +12775,20 @@ class Router:
|
|||
if not self.enable_health_check_routing:
|
||||
return healthy_deployments
|
||||
|
||||
if self.allowed_fails_policy is not None:
|
||||
scoped_groups: Final = self.background_health_check_model_groups
|
||||
if self.allowed_fails_policy is not None and scoped_groups is None:
|
||||
return healthy_deployments
|
||||
|
||||
unhealthy_ids: Final = self.health_state_cache.get_unhealthy_deployment_ids(parent_otel_span=parent_otel_span)
|
||||
if not unhealthy_ids:
|
||||
return healthy_deployments
|
||||
|
||||
filtered: Final = [d for d in healthy_deployments if d["model_info"]["id"] not in unhealthy_ids]
|
||||
filtered: Final = [
|
||||
d
|
||||
for d in healthy_deployments
|
||||
if d["model_info"]["id"] not in unhealthy_ids
|
||||
or (scoped_groups is not None and d["model_name"] not in scoped_groups)
|
||||
]
|
||||
|
||||
if not filtered:
|
||||
verbose_router_logger.warning("All deployments marked unhealthy by health checks, bypassing health filter")
|
||||
|
|
|
|||
|
|
@ -178,6 +178,50 @@ response = litellm.completion(
|
|||
|
||||
## Special Behaviors
|
||||
|
||||
### Heuristic-first chaining
|
||||
|
||||
`classifier_type: heuristic_first` runs the local scorer on every request and only calls the LLM
|
||||
classifier for the ones the scorer could not place cheaply. It takes the same classifier settings as
|
||||
`classifier_type: llm`, plus `heuristic_first_max_tier`:
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: smart-router
|
||||
litellm_params:
|
||||
model: auto_router/complexity_router
|
||||
complexity_router_config:
|
||||
classifier_type: heuristic_first
|
||||
heuristic_first_max_tier: SIMPLE
|
||||
classifier_llm_config:
|
||||
model: gpt-4o-mini
|
||||
tiers:
|
||||
SIMPLE: gpt-4o-mini
|
||||
MEDIUM: gpt-4o
|
||||
COMPLEX: claude-sonnet-4
|
||||
REASONING: o1-preview
|
||||
```
|
||||
|
||||
A request short-circuits, meaning it routes on the scorer's own tier with no classifier call, when
|
||||
two things hold: the scorer landed at or below `heuristic_first_max_tier`, and it produced at least
|
||||
one signal. Everything else goes to the classifier, which then decides as it normally would.
|
||||
|
||||
The signal requirement is what keeps this from quietly routing everything to your cheapest model.
|
||||
A prompt where no dimension fires scores exactly 0.0, which is below `simple_medium`, so the score
|
||||
to tier mapping calls it SIMPLE by default rather than by evidence. Around half of general traffic
|
||||
scores that way. Those requests reach the classifier instead, which is the whole reason to configure
|
||||
one. Note the converse too: the score is not a confidence, and a prompt that fires a single weak
|
||||
signal and still lands under the boundary does short-circuit, so a lower threshold buys accuracy and
|
||||
a higher one buys savings.
|
||||
|
||||
`heuristic_first_max_tier` names a built-in tier and may not name the highest one, since that would
|
||||
short-circuit everything and leave the classifier unreachable. Operator-defined tier sets
|
||||
(`tier_definitions`) are not supported here, because the scorer only produces the built-in tiers.
|
||||
When the classifier call fails, the fallback works exactly as it does under `classifier_type: llm`,
|
||||
except that the heuristic outcome is the one already computed rather than a second scoring pass.
|
||||
|
||||
Spend logs record `routing_decision.cause` as `heuristic_first_short_circuit` when the classifier
|
||||
was skipped, and `llm_classifier` when it ran, so the two are told apart per request.
|
||||
|
||||
### Reasoning Override
|
||||
|
||||
If 2+ reasoning markers are detected in the user message, the request is promoted to the REASONING tier even when the weighted score maps lower, so complex reasoning tasks get the appropriate model. The promotion requires the score to reach `reasoning_override_min_score`, which tracks `tier_boundaries.simple_medium` unless set, so stock phrases on an otherwise trivial prompt cannot buy the top tier. Set it to `0` to promote on the markers alone.
|
||||
|
|
|
|||
|
|
@ -719,6 +719,7 @@ class ClassificationOutcome(NamedTuple):
|
|||
"heuristic_scorer",
|
||||
"reasoning_override",
|
||||
"llm_classifier",
|
||||
"heuristic_first_short_circuit",
|
||||
"classifier_plugin",
|
||||
"classifier_fallback",
|
||||
"default_model_fallback",
|
||||
|
|
@ -859,7 +860,7 @@ class ComplexityRouter(CustomLogger):
|
|||
|
||||
# Both are pure functions of the config, so building them per classifier call would
|
||||
# re-run create_model and the schema conversion on every request for the same result.
|
||||
llm_classifier_configured: Final = self.config.classifier_type == "llm" and (
|
||||
llm_classifier_configured: Final = self.config.uses_llm_classifier and (
|
||||
self.config.classifier_llm_config is not None
|
||||
)
|
||||
self._classifier_system_prompt: str | None = (
|
||||
|
|
@ -1237,17 +1238,63 @@ class ComplexityRouter(CustomLogger):
|
|||
"""
|
||||
Classify a prompt by complexity, using the LLM classifier when configured.
|
||||
|
||||
Falls back to the local heuristic scorer if classifier_type is "heuristic". If the LLM call
|
||||
or the classifier plugin fails, times out, or produces no usable tier, the configured
|
||||
fallback_tier wins on a custom tier set, and classifier_fallback otherwise decides between
|
||||
the heuristic scorer and default_model. The outcome's `cause` reports which path actually ran.
|
||||
Falls back to the local heuristic scorer if classifier_type is "heuristic". Under
|
||||
"heuristic_first" the scorer runs first and the classifier is called only for requests it
|
||||
could not place at or below heuristic_first_max_tier. If the LLM call or the classifier
|
||||
plugin fails, times out, or produces no usable tier, the configured fallback_tier wins on a
|
||||
custom tier set, and classifier_fallback otherwise decides between the heuristic scorer and
|
||||
default_model. The outcome's `cause` reports which path actually ran.
|
||||
"""
|
||||
if self.config.classifier_type == "custom":
|
||||
return await self._classify_with_plugin(prompt, system_prompt, request_kwargs, raw_messages)
|
||||
if self.config.classifier_type == "heuristic_first" and self.config.classifier_llm_config is not None:
|
||||
return await self._classify_heuristic_first(prompt, system_prompt, request_kwargs, messages)
|
||||
if self.config.classifier_type != "llm" or self.config.classifier_llm_config is None:
|
||||
tier, score, signals, cause = self._score_and_classify(prompt, system_prompt)
|
||||
return ClassificationOutcome(tier=tier, score=score, signals=signals, cause=cause)
|
||||
return await self._llm_classifier_outcome(prompt, system_prompt, request_kwargs, messages)
|
||||
|
||||
async def _classify_heuristic_first(
|
||||
self,
|
||||
prompt: str,
|
||||
system_prompt: str | None,
|
||||
request_kwargs: dict[str, Any] | None, # mutable-ok: handed to _classify_with_llm as-is
|
||||
messages: Sequence[Mapping[str, object]] | None,
|
||||
) -> ClassificationOutcome:
|
||||
"""Score locally, and only pay for the classifier call when the scorer did not confidently
|
||||
place the request at or below heuristic_first_max_tier.
|
||||
|
||||
Confidence is `signals`, not `score`. A prompt where no dimension fired scores exactly 0.0,
|
||||
which is below simple_medium and so lands SIMPLE by default rather than by evidence, and a
|
||||
threshold check alone would hand that traffic to the cheapest model without ever consulting
|
||||
the classifier. Scores also go negative when simple indicators fire, so a score threshold
|
||||
would reject exactly the trivial prompts this path exists to serve.
|
||||
"""
|
||||
tier, score, signals, cause = self._score_and_classify(prompt, system_prompt)
|
||||
scored: Final = ClassificationOutcome(tier=tier, score=score, signals=signals, cause=cause)
|
||||
threshold: Final = self.config.heuristic_first_max_tier
|
||||
decided_cheaply: Final = (
|
||||
threshold is not None
|
||||
and bool(signals)
|
||||
and self._active_tier_severity(tier) <= self._active_tier_severity(threshold)
|
||||
)
|
||||
if decided_cheaply:
|
||||
return ClassificationOutcome(tier=tier, score=score, signals=signals, cause="heuristic_first_short_circuit")
|
||||
return await self._llm_classifier_outcome(prompt, system_prompt, request_kwargs, messages, scored=scored)
|
||||
|
||||
async def _llm_classifier_outcome(
|
||||
self,
|
||||
prompt: str,
|
||||
system_prompt: str | None,
|
||||
request_kwargs: dict[str, Any] | None, # mutable-ok: handed to _classify_with_llm as-is
|
||||
messages: Sequence[Mapping[str, object]] | None,
|
||||
scored: ClassificationOutcome | None = None,
|
||||
) -> ClassificationOutcome:
|
||||
"""Call the LLM classifier and turn its verdict, or its failure, into an outcome.
|
||||
|
||||
`scored` is the heuristic outcome the caller already computed, which only "heuristic_first"
|
||||
has. It is handed to the failure path so a classifier error does not re-run the scorer.
|
||||
"""
|
||||
try:
|
||||
tier, classifier_cost = await self._classify_with_llm(prompt, system_prompt, request_kwargs, messages)
|
||||
return ClassificationOutcome(
|
||||
|
|
@ -1258,11 +1305,20 @@ class ComplexityRouter(CustomLogger):
|
|||
classifier_cost=classifier_cost,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 -- external LLM call can fail in many distinct ways (timeout, provider error, validation, parse error); any failure must fall back to the configured fallback path
|
||||
return self._classifier_failure_outcome(f"LLM classifier failed ({e})", prompt, system_prompt)
|
||||
return self._classifier_failure_outcome(f"LLM classifier failed ({e})", prompt, system_prompt, scored)
|
||||
|
||||
def _classifier_failure_outcome(self, reason: str, prompt: str, system_prompt: str | None) -> ClassificationOutcome:
|
||||
def _classifier_failure_outcome(
|
||||
self,
|
||||
reason: str,
|
||||
prompt: str,
|
||||
system_prompt: str | None,
|
||||
scored: ClassificationOutcome | None = None,
|
||||
) -> ClassificationOutcome:
|
||||
"""The outcome when the LLM classifier or classifier plugin produced no usable tier:
|
||||
fallback_tier on a custom tier set, classifier_fallback otherwise."""
|
||||
fallback_tier on a custom tier set, classifier_fallback otherwise.
|
||||
|
||||
A caller that already scored the prompt passes `scored` so the heuristic arm returns that
|
||||
verdict instead of running the same scan again on the request path."""
|
||||
fallback_tier: Final = self.config.fallback_tier
|
||||
if fallback_tier is not None:
|
||||
verbose_router_logger.warning("ComplexityRouter: %s, routing to fallback_tier %s", reason, fallback_tier)
|
||||
|
|
@ -1277,6 +1333,8 @@ class ComplexityRouter(CustomLogger):
|
|||
)
|
||||
if self.config.classifier_fallback == "default_model":
|
||||
return self._default_model_fallback_outcome()
|
||||
if scored is not None:
|
||||
return scored
|
||||
tier, score, signals, cause = self._score_and_classify(prompt, system_prompt)
|
||||
return ClassificationOutcome(tier=tier, score=score, signals=signals, cause=cause)
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ class ClassificationRubric(str, Enum):
|
|||
# routers get the calibrated rubric without changing what is already running.
|
||||
DEFAULT_CLASSIFICATION_RUBRIC: Final[ClassificationRubric] = ClassificationRubric.LEGACY
|
||||
|
||||
# The classifier_type values that can call classifier_llm_config.model. Every consumer asking
|
||||
# "is the classifier model a real dependency of this router" resolves it here, including the ones
|
||||
# that only hold the raw config mapping and cannot reach ComplexityRouterConfig.uses_llm_classifier.
|
||||
LLM_CLASSIFIER_TYPES: Final[frozenset[str]] = frozenset({"llm", "heuristic_first"})
|
||||
|
||||
|
||||
TIER_SEVERITY_ORDER: Final[tuple[ComplexityTier, ...]] = (
|
||||
ComplexityTier.SIMPLE,
|
||||
|
|
@ -591,13 +596,30 @@ class ComplexityRouterConfig(BaseModel):
|
|||
)
|
||||
|
||||
# Classifier strategy
|
||||
classifier_type: Literal["heuristic", "llm", "custom"] = Field(
|
||||
classifier_type: Literal["heuristic", "llm", "custom", "heuristic_first"] = Field(
|
||||
default="heuristic",
|
||||
description="Classification strategy: local regex/keyword scoring, an LLM call, or a custom classifier plugin",
|
||||
description=(
|
||||
"Classification strategy: local regex/keyword scoring, an LLM call, a custom classifier "
|
||||
"plugin, or 'heuristic_first', which scores locally and only pays for the LLM classifier "
|
||||
"when the local scorer does not confidently land a cheap tier"
|
||||
),
|
||||
)
|
||||
classifier_llm_config: ClassifierLLMConfig | None = Field(
|
||||
default=None,
|
||||
description="Configuration for the LLM classifier; required when classifier_type is 'llm'",
|
||||
description="Configuration for the LLM classifier; required when classifier_type is 'llm' or 'heuristic_first'",
|
||||
)
|
||||
heuristic_first_max_tier: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"The highest tier the local scorer may decide on its own; required when classifier_type is "
|
||||
"'heuristic_first' and rejected otherwise. A request whose heuristic tier is at or below this "
|
||||
"one skips the LLM classifier and routes straight to that heuristic tier, so the classifier "
|
||||
"call is only paid for on traffic the scorer could not place cheaply. The scorer must also "
|
||||
"have produced at least one signal: a prompt where no dimension fired scores 0.0 and would "
|
||||
"otherwise land SIMPLE by default rather than by evidence, which is how a chained router "
|
||||
"would silently send unclassified traffic to the cheapest model. Names a built-in tier, and "
|
||||
"may not name the highest one, since that would make the LLM classifier unreachable."
|
||||
),
|
||||
)
|
||||
classifier_plugin: ClassifierPlugin | None = Field(
|
||||
default=None,
|
||||
|
|
@ -626,7 +648,7 @@ class ComplexityRouterConfig(BaseModel):
|
|||
"which is what a classifier on some other taxonomy wants: a prompt that grades data "
|
||||
"sensitivity has no use for a complexity score, and scoring one produces a tier unrelated to "
|
||||
"what the operator configured. Requires default_model when set to 'default_model'. Only "
|
||||
"applies when classifier_type is 'llm' or 'custom'."
|
||||
"applies when classifier_type is 'llm', 'custom', or 'heuristic_first'."
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -936,8 +958,8 @@ class ComplexityRouterConfig(BaseModel):
|
|||
|
||||
@model_validator(mode="after")
|
||||
def _validate_classifier_config(self) -> "ComplexityRouterConfig":
|
||||
if self.classifier_type == "llm" and self.classifier_llm_config is None:
|
||||
raise ValueError("classifier_llm_config is required when classifier_type is 'llm'")
|
||||
if self.uses_llm_classifier and self.classifier_llm_config is None:
|
||||
raise ValueError(f"classifier_llm_config is required when classifier_type is {self.classifier_type!r}")
|
||||
if self.classifier_type == "custom" and self.classifier_plugin is None:
|
||||
raise ValueError("classifier_plugin is required when classifier_type is 'custom'")
|
||||
if self.classifier_plugin is not None and self.classifier_type != "custom":
|
||||
|
|
@ -947,6 +969,49 @@ class ComplexityRouterConfig(BaseModel):
|
|||
)
|
||||
return self
|
||||
|
||||
@field_validator("heuristic_first_max_tier", mode="before")
|
||||
@classmethod
|
||||
def _coerce_heuristic_first_max_tier(cls, value: object) -> object:
|
||||
if isinstance(value, ComplexityTier):
|
||||
return value.value
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_heuristic_first_max_tier(self) -> "ComplexityRouterConfig":
|
||||
if self.classifier_type != "heuristic_first":
|
||||
if self.heuristic_first_max_tier is not None:
|
||||
raise ValueError(
|
||||
f"heuristic_first_max_tier is set but classifier_type is {self.classifier_type!r}; "
|
||||
"the local scorer would never gate the classifier. Set classifier_type "
|
||||
"'heuristic_first' or remove heuristic_first_max_tier"
|
||||
)
|
||||
return self
|
||||
threshold: Final = self.heuristic_first_max_tier
|
||||
if threshold is None:
|
||||
raise ValueError(
|
||||
"heuristic_first_max_tier is required when classifier_type is 'heuristic_first': without a "
|
||||
"threshold there is nothing to decide whether a request escalates to the LLM classifier"
|
||||
)
|
||||
names: Final = self.tier_names()
|
||||
if threshold not in names:
|
||||
raise ValueError(
|
||||
f"heuristic_first_max_tier {threshold!r} is not an active tier: it must name one of {', '.join(names)}"
|
||||
)
|
||||
if threshold == names[-1]:
|
||||
raise ValueError(
|
||||
f"heuristic_first_max_tier {threshold} is the highest tier, so every request would short-circuit "
|
||||
"and the LLM classifier would never run; name a lower tier or use classifier_type 'heuristic'"
|
||||
)
|
||||
if threshold not in self.tiers:
|
||||
raise ValueError(
|
||||
f"heuristic_first_max_tier {threshold} has no model configured in tiers; a threshold pointing at "
|
||||
"an unconfigured tier would route short-circuited requests to the default fallback instead of the "
|
||||
"pool the operator intended"
|
||||
)
|
||||
return self
|
||||
|
||||
@field_validator("fallback_tier", "classification_prompt")
|
||||
@classmethod
|
||||
def _reject_blank_optional_text(cls, value: str | None) -> str | None:
|
||||
|
|
@ -969,6 +1034,14 @@ class ComplexityRouterConfig(BaseModel):
|
|||
"""True when the operator replaced the built-in tier set via tier_definitions."""
|
||||
return self.tier_definitions is not None
|
||||
|
||||
@property
|
||||
def uses_llm_classifier(self) -> bool:
|
||||
"""True when this router can call classifier_llm_config.model, so the model is a real
|
||||
dependency: authorized against the caller's key, counted in the health graph, and given a
|
||||
prebuilt rubric. 'heuristic_first' only calls it for traffic the local scorer escalates,
|
||||
which still makes it a dependency on every one of those requests."""
|
||||
return self.classifier_type in LLM_CLASSIFIER_TYPES
|
||||
|
||||
def tier_names(self) -> tuple[str, ...]:
|
||||
"""The active tier names: the defined names, or the built-in set in severity order."""
|
||||
if self.tier_definitions is not None:
|
||||
|
|
@ -1063,7 +1136,7 @@ class ComplexityRouterConfig(BaseModel):
|
|||
)
|
||||
if duplicated:
|
||||
raise ValueError(f"tier_definitions names must be unique (case-insensitive): {', '.join(duplicated)}")
|
||||
if self.classifier_type == "heuristic":
|
||||
if self.classifier_type in ("heuristic", "heuristic_first"):
|
||||
raise ValueError(
|
||||
"tier_definitions requires classifier_type 'llm' or 'custom': the heuristic scorer only "
|
||||
"produces the built-in tiers"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ from dataclasses import dataclass
|
|||
from types import MappingProxyType
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from litellm.router_strategy.complexity_router.config import LLM_CLASSIFIER_TYPES
|
||||
|
||||
AUTO_ROUTER_MODEL_PREFIX: Final = "auto_router/"
|
||||
|
||||
StrategyRouterKind = Literal["semantic", "complexity", "adaptive", "quality"]
|
||||
|
|
@ -144,7 +146,11 @@ def strategy_router_dependencies(
|
|||
dict.fromkeys(
|
||||
tuple(dep for tier in _mapping(complexity.get("tiers")).values() for dep in _pool(tier, "tier"))
|
||||
+ _named(litellm_params.get("complexity_router_default_model"), "default")
|
||||
+ (_named(classifier.get("model"), "classifier") if complexity.get("classifier_type") == "llm" else ())
|
||||
+ (
|
||||
_named(classifier.get("model"), "classifier")
|
||||
if complexity.get("classifier_type") in LLM_CLASSIFIER_TYPES
|
||||
else ()
|
||||
)
|
||||
+ (
|
||||
_named(complexity.get("embedding_model"), "embedding")
|
||||
if complexity.get("semantic_keyword_matching")
|
||||
|
|
|
|||
|
|
@ -43,12 +43,33 @@ class DeploymentHealthCache:
|
|||
self.staleness_threshold = staleness_threshold
|
||||
|
||||
def set_deployment_health_states(self, states: dict[str, DeploymentHealthStateValue]) -> None:
|
||||
"""Bulk-write all deployment health states as a single cache entry."""
|
||||
"""Merge the given states into the shared cache entry, pruning expired ones.
|
||||
|
||||
Merging instead of replacing lets writers probing different deployment
|
||||
scopes (e.g. pods with different background health check allowlists)
|
||||
coexist on the one shared entry without erasing each other's results.
|
||||
The snapshot is read from Redis when available, since a pod-local read
|
||||
would only ever see this writer's own previous merge. When the Redis
|
||||
read comes back empty (a miss, or a swallowed connection error), the
|
||||
pod-local copy of the last merge is used so peers are not erased.
|
||||
"""
|
||||
try:
|
||||
redis_raw: Final = (
|
||||
self.cache.redis_cache.get_cache(self.CACHE_KEY) if self.cache.redis_cache is not None else None
|
||||
)
|
||||
raw: Final = redis_raw if isinstance(redis_raw, dict) else self.cache.get_cache(key=self.CACHE_KEY)
|
||||
existing: Final = raw if isinstance(raw, dict) else {}
|
||||
expiry_seconds: Final = self.staleness_threshold * 1.5
|
||||
now: Final = time.time()
|
||||
merged: Final = {
|
||||
model_id: state
|
||||
for model_id, state in {**existing, **states}.items()
|
||||
if isinstance(state, dict) and (now - state.get("timestamp", 0)) < expiry_seconds
|
||||
}
|
||||
self.cache.set_cache(
|
||||
key=self.CACHE_KEY,
|
||||
value=states,
|
||||
ttl=int(self.staleness_threshold * 1.5),
|
||||
value=merged,
|
||||
ttl=int(expiry_seconds),
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.error(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal
|
||||
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm.types.integrations.custom_logger import StandardCustomLoggerInitParams
|
||||
|
||||
|
||||
|
|
@ -5,3 +12,110 @@ class NewRelicInitParams(StandardCustomLoggerInitParams):
|
|||
"""
|
||||
Params for initializing a New Relic logger on litellm
|
||||
"""
|
||||
|
||||
|
||||
#: Region -> Metric API endpoint. A fixed table by design: team config picks a
|
||||
#: region enum rather than a free-form endpoint, so callback vars can never
|
||||
#: redirect metrics to an arbitrary host.
|
||||
NEWRELIC_METRIC_ENDPOINT_BY_REGION: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"us": "https://metric-api.newrelic.com/metric/v1",
|
||||
"eu": "https://metric-api.eu.newrelic.com/metric/v1",
|
||||
}
|
||||
)
|
||||
|
||||
NEWRELIC_DEFAULT_REGION: Final = "us"
|
||||
|
||||
#: Metric API caps a payload at 2000 data points / 1MB compressed; each queued
|
||||
#: record expands to at most 6 metrics, so cap the per-flush record count well
|
||||
#: below that.
|
||||
NEWRELIC_METRICS_MAX_BATCH_SIZE: Final = 250
|
||||
|
||||
#: Hard cap on records retained across failed flushes (5xx/network requeue).
|
||||
#: Beyond this the oldest records are dropped.
|
||||
NEWRELIC_METRICS_MAX_RETRY_QUEUE_SIZE: Final = 10_000
|
||||
# Outer passes over a stopped logger's queue: each pass retries the whole
|
||||
# queue, so records that arrive mid-drain still get attempts before the bounded
|
||||
# terminal drop. Serialized by a per-logger drain lock, so this bounds work.
|
||||
NEWRELIC_METRICS_MAX_DRAIN_PASSES: Final = 3
|
||||
# Metric API caps attribute values; 255 keeps caller-controlled model strings
|
||||
# from inflating the shared batch payload into a 413
|
||||
NEWRELIC_METRIC_ATTRIBUTE_MAX_LEN: Final = 255
|
||||
|
||||
NEWRELIC_METRIC_REQUESTS: Final = "litellm.requests"
|
||||
NEWRELIC_METRIC_COST_USD: Final = "litellm.cost.usd"
|
||||
NEWRELIC_METRIC_PROMPT_TOKENS: Final = "litellm.tokens.prompt"
|
||||
NEWRELIC_METRIC_COMPLETION_TOKENS: Final = "litellm.tokens.completion"
|
||||
NEWRELIC_METRIC_TOTAL_TOKENS: Final = "litellm.tokens.total"
|
||||
NEWRELIC_METRIC_REQUEST_DURATION_MS: Final = "litellm.request.duration_ms"
|
||||
|
||||
|
||||
class NewRelicSummaryValue(TypedDict):
|
||||
"""Value shape of a Metric API ``summary`` data point."""
|
||||
|
||||
count: ReadOnly[int]
|
||||
sum: ReadOnly[float]
|
||||
min: ReadOnly[float]
|
||||
max: ReadOnly[float]
|
||||
|
||||
|
||||
class NewRelicCountMetric(TypedDict):
|
||||
name: ReadOnly[str]
|
||||
type: ReadOnly[Literal["count"]]
|
||||
value: ReadOnly[float]
|
||||
attributes: ReadOnly[Mapping[str, str]]
|
||||
|
||||
|
||||
class NewRelicSummaryMetric(TypedDict):
|
||||
name: ReadOnly[str]
|
||||
type: ReadOnly[Literal["summary"]]
|
||||
value: ReadOnly[NewRelicSummaryValue]
|
||||
attributes: ReadOnly[Mapping[str, str]]
|
||||
|
||||
|
||||
NewRelicMetric = NewRelicCountMetric | NewRelicSummaryMetric
|
||||
|
||||
|
||||
#: ``interval.ms`` has a dot in it, so the functional TypedDict form is required.
|
||||
NewRelicMetricCommon = TypedDict(
|
||||
"NewRelicMetricCommon",
|
||||
{ # mutable-ok: functional TypedDict requires a dict-literal fields argument ("interval.ms" key)
|
||||
"timestamp": ReadOnly[int],
|
||||
"interval.ms": ReadOnly[int],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class NewRelicMetricEnvelope(TypedDict):
|
||||
"""One element of the Metric API request body (``[{common, metrics}]``)."""
|
||||
|
||||
common: ReadOnly[NewRelicMetricCommon]
|
||||
metrics: ReadOnly[Sequence[NewRelicMetric]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NewRelicMetricRecord:
|
||||
"""One request's contribution to the per-flush aggregation."""
|
||||
|
||||
team_id: str
|
||||
team_alias: str
|
||||
model_group: str
|
||||
model: str
|
||||
custom_llm_provider: str
|
||||
status: str
|
||||
response_cost: float
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
total_tokens: int
|
||||
duration_ms: float
|
||||
|
||||
@property
|
||||
def bucket_key(self) -> tuple[str, str, str, str, str, str]:
|
||||
return (
|
||||
self.team_id,
|
||||
self.team_alias,
|
||||
self.model_group,
|
||||
self.model,
|
||||
self.custom_llm_provider,
|
||||
self.status,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -502,11 +502,16 @@ class MessageDelta(TypedDict, total=False):
|
|||
stop_reason: str | None
|
||||
|
||||
|
||||
class ServerToolUsage(TypedDict, total=False):
|
||||
web_search_requests: ReadOnly[int]
|
||||
|
||||
|
||||
class UsageDelta(TypedDict, total=False):
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cache_creation_input_tokens: int
|
||||
cache_read_input_tokens: int
|
||||
server_tool_use: ReadOnly[ServerToolUsage]
|
||||
|
||||
|
||||
class AppliedEdit(TypedDict, total=False):
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
from typing import Any, Literal, TypeAlias
|
||||
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict
|
||||
|
||||
from litellm.types.llms.anthropic import (
|
||||
AnthropicResponseContentBlockText,
|
||||
AnthropicResponseContentBlockToolUse,
|
||||
ContextManagementResponse,
|
||||
ServerToolUsage,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -71,6 +72,11 @@ class AnthropicUsage(TypedDict, total=False):
|
|||
cache_creation_input_tokens: int
|
||||
cache_read_input_tokens: int
|
||||
|
||||
"""
|
||||
Server-side tool usage (e.g. web search request counts)
|
||||
"""
|
||||
server_tool_use: NotRequired[ReadOnly[ServerToolUsage]]
|
||||
|
||||
|
||||
class AnthropicMessagesResponse(TypedDict, total=False):
|
||||
"""
|
||||
|
|
|
|||
81
litellm/types/llms/gemini_audio_transcription.py
Normal file
81
litellm/types/llms/gemini_audio_transcription.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
from typing import Literal, Required
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
|
||||
class GeminiTranscriptionAudioInput(TypedDict):
|
||||
type: ReadOnly[Literal["audio"]]
|
||||
data: ReadOnly[str]
|
||||
mime_type: ReadOnly[str]
|
||||
|
||||
|
||||
class GeminiTranscriptionVerbatimMode(TypedDict, total=False):
|
||||
type: ReadOnly[Required[Literal["verbatim"]]]
|
||||
timestamp_granularities: ReadOnly[tuple[Literal["word"], ...]]
|
||||
diarization_mode: ReadOnly[Literal["speaker"]]
|
||||
|
||||
|
||||
class GeminiTranscriptionConfig(TypedDict, total=False):
|
||||
language_codes: ReadOnly[tuple[str, ...]]
|
||||
mode: ReadOnly[GeminiTranscriptionVerbatimMode]
|
||||
|
||||
|
||||
class GeminiTranscriptionGenerationConfig(TypedDict):
|
||||
transcription_config: ReadOnly[GeminiTranscriptionConfig]
|
||||
|
||||
|
||||
class GeminiTranscriptionInteractionRequest(TypedDict, total=False):
|
||||
model: ReadOnly[Required[str]]
|
||||
input: ReadOnly[Required[tuple[GeminiTranscriptionAudioInput, ...]]]
|
||||
generation_config: ReadOnly[GeminiTranscriptionGenerationConfig]
|
||||
|
||||
|
||||
class GeminiTranscriptionWordAnnotation(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
type: str | None = None
|
||||
text: str | None = None
|
||||
speaker: str | None = None
|
||||
start_offset: str | None = None
|
||||
end_offset: str | None = None
|
||||
|
||||
|
||||
class GeminiTranscriptionContent(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
type: str | None = None
|
||||
text: str | None = None
|
||||
annotations: tuple[GeminiTranscriptionWordAnnotation, ...] = ()
|
||||
|
||||
|
||||
class GeminiTranscriptionStep(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
type: str | None = None
|
||||
content: tuple[GeminiTranscriptionContent, ...] = ()
|
||||
|
||||
|
||||
class GeminiTranscriptionModalityTokens(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
modality: str | None = None
|
||||
tokens: int = 0
|
||||
|
||||
|
||||
class GeminiTranscriptionUsage(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
total_tokens: int = 0
|
||||
total_input_tokens: int = 0
|
||||
total_output_tokens: int = 0
|
||||
input_tokens_by_modality: tuple[GeminiTranscriptionModalityTokens, ...] = ()
|
||||
|
||||
|
||||
class GeminiTranscriptionInteractionResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
id: str | None = None
|
||||
status: str | None = None
|
||||
usage: GeminiTranscriptionUsage | None = None
|
||||
steps: tuple[GeminiTranscriptionStep, ...] = ()
|
||||
|
|
@ -162,3 +162,16 @@ class RealtimeErrorDetail(TypedDict):
|
|||
class RealtimeErrorEvent(TypedDict):
|
||||
type: ReadOnly[Literal["error"]]
|
||||
error: ReadOnly[RealtimeErrorDetail]
|
||||
|
||||
|
||||
class RealtimeInputAudioTranscriptionUsageInputTokenDetails(TypedDict):
|
||||
text_tokens: ReadOnly[int]
|
||||
audio_tokens: ReadOnly[int]
|
||||
|
||||
|
||||
class RealtimeInputAudioTranscriptionUsage(TypedDict):
|
||||
type: ReadOnly[Literal["tokens"]]
|
||||
input_tokens: ReadOnly[int]
|
||||
output_tokens: ReadOnly[int]
|
||||
total_tokens: ReadOnly[int]
|
||||
input_token_details: ReadOnly[RealtimeInputAudioTranscriptionUsageInputTokenDetails]
|
||||
|
|
|
|||
|
|
@ -2808,6 +2808,12 @@ RoutingDecisionCause = Literal[
|
|||
# meant anything that filtered `signals` silently changed what the row claimed.
|
||||
"reasoning_override",
|
||||
"llm_classifier",
|
||||
# classifier_type 'heuristic_first': the local scorer produced at least one signal and landed at
|
||||
# or below heuristic_first_max_tier, so it decided the tier and the LLM classifier was never
|
||||
# called. Distinct from "heuristic_scorer", which is a router whose only classifier IS the
|
||||
# scorer, and from "classifier_fallback", which is the scorer running because a call failed:
|
||||
# only this cause means an LLM classifier was configured, reachable, and deliberately skipped.
|
||||
"heuristic_first_short_circuit",
|
||||
# The operator's classifier plugin (classifier_type 'custom') decided the tier.
|
||||
"classifier_plugin",
|
||||
# The LLM classifier or classifier plugin failed on a router with an operator-defined
|
||||
|
|
@ -2834,13 +2840,19 @@ RoutingDecisionCause = Literal[
|
|||
]
|
||||
|
||||
|
||||
InternalCallOrigin = Literal["autorouter_classifier", "shadow_eval_router", "shadow_eval_judge"]
|
||||
InternalCallOrigin = Literal[
|
||||
"autorouter_classifier",
|
||||
"shadow_eval_router",
|
||||
"shadow_eval_judge",
|
||||
"background_response_cost_poll",
|
||||
]
|
||||
"""Which internal litellm feature originated a billed sub-call, so a spend log row
|
||||
records that it is not traffic the caller sent."""
|
||||
|
||||
AUTOROUTER_CLASSIFIER_CALL_ORIGIN: Final[InternalCallOrigin] = "autorouter_classifier"
|
||||
SHADOW_EVAL_ROUTER_CALL_ORIGIN: Final[InternalCallOrigin] = "shadow_eval_router"
|
||||
SHADOW_EVAL_JUDGE_CALL_ORIGIN: Final[InternalCallOrigin] = "shadow_eval_judge"
|
||||
BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN: Final[InternalCallOrigin] = "background_response_cost_poll"
|
||||
|
||||
|
||||
class StandardLoggingRoutingDecision(TypedDict, total=False):
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ from litellm.constants import (
|
|||
MAX_RETRY_DELAY,
|
||||
MAX_TOKEN_TRIMMING_ATTEMPTS,
|
||||
MINIMUM_PROMPT_CACHE_TOKEN_COUNT_OVERRIDE,
|
||||
NON_INFERENCE_CALL_TYPES,
|
||||
OPENAI_EMBEDDING_PARAMS,
|
||||
TOOL_CHOICE_OBJECT_TOKEN_COUNT,
|
||||
)
|
||||
|
|
@ -1109,6 +1110,8 @@ def function_setup(
|
|||
except Exception as e:
|
||||
verbose_logger.debug("Error extracting messages from Google contents: %s", e)
|
||||
messages = "default-message-value"
|
||||
elif call_type in NON_INFERENCE_CALL_TYPES:
|
||||
messages = [] # mutable-ok: loggers require a list here and Logging copies it
|
||||
else:
|
||||
messages = "default-message-value"
|
||||
stream = False
|
||||
|
|
@ -2945,7 +2948,12 @@ def reapply_runtime_model_cost_registrations() -> None:
|
|||
register_model(model_cost=dict(_runtime_registered_model_cost)) # mutable-ok: snapshot, replay rewrites it
|
||||
|
||||
|
||||
def register_model(model_cost: str | dict, *, persist_across_reloads: bool = True):
|
||||
def register_model(
|
||||
model_cost: str | dict,
|
||||
*,
|
||||
persist_across_reloads: bool = True,
|
||||
warning_display_name: str | None = None,
|
||||
):
|
||||
"""
|
||||
Register new / Override existing models (and their pricing) to specific providers.
|
||||
Provide EITHER a model cost dictionary or a url to a hosted json blob
|
||||
|
|
@ -2965,6 +2973,10 @@ def register_model(model_cost: str | dict, *, persist_across_reloads: bool = Tru
|
|||
registering a model is declaring durable intent. Pass False for a
|
||||
registration that only describes one request, so it is dropped rather than
|
||||
re-asserted over every future catalog.
|
||||
|
||||
``warning_display_name`` names the model in the missing-cache-pricing
|
||||
warning instead of the registered key, for callers that register under an
|
||||
opaque key (e.g. the router's hashed deployment ids).
|
||||
"""
|
||||
|
||||
loaded_model_cost = {}
|
||||
|
|
@ -3011,10 +3023,15 @@ def register_model(model_cost: str | dict, *, persist_across_reloads: bool = Tru
|
|||
elif (
|
||||
value.get("cache_creation_input_token_cost") is None
|
||||
and value.get("cache_read_input_token_cost") is None
|
||||
and value.get("tiered_pricing") is None
|
||||
and (
|
||||
value.get("input_cost_per_token") is not None
|
||||
or value.get("output_cost_per_token") is not None
|
||||
)
|
||||
):
|
||||
verbose_logger.warning(
|
||||
"register_model: model=%s not in built-in cost map and no prefix/region variant matched; cache cost fields will default to 0. To track cache cost, add cache_creation_input_token_cost and cache_read_input_token_cost to model_info",
|
||||
key,
|
||||
"register_model: model=%s has custom pricing but not in built-in cost map and no prefix/region variant matched; cache_creation_input_token_cost and cache_read_input_token_cost will default to 0 for this model (input/output cost tracking is unaffected). To track cache cost, add them to model_info",
|
||||
warning_display_name or key,
|
||||
)
|
||||
# ``get_model_info`` returns ``litellm_provider: None`` when the
|
||||
# provider is unknown (e.g. custom deployments registered via
|
||||
|
|
@ -8500,6 +8517,12 @@ class ProviderConfigManager:
|
|||
)
|
||||
|
||||
return VertexAIAudioTranscriptionConfig()
|
||||
elif litellm.LlmProviders.GEMINI == provider:
|
||||
from litellm.llms.gemini.audio_transcription.transformation import (
|
||||
GeminiAudioTranscriptionConfig,
|
||||
)
|
||||
|
||||
return GeminiAudioTranscriptionConfig()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -51340,6 +51340,47 @@
|
|||
"supports_audio_output": true,
|
||||
"tpm": 250000
|
||||
},
|
||||
"gemini/gemini-3.5-transcribe": {
|
||||
"input_cost_per_audio_token": 2e-06,
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "gemini",
|
||||
"mode": "audio_transcription",
|
||||
"output_cost_per_token": 1.2e-05,
|
||||
"source": "https://ai.google.dev/gemini-api/docs/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/audio/transcriptions"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_audio_input": true,
|
||||
"tpm": 800000,
|
||||
"rpm": 2000
|
||||
},
|
||||
"gemini/gemini-3.5-transcribe-live": {
|
||||
"input_cost_per_audio_token": 3.5e-06,
|
||||
"input_cost_per_token": 3.5e-06,
|
||||
"litellm_provider": "gemini",
|
||||
"mode": "audio_transcription",
|
||||
"output_cost_per_token": 2.1e-05,
|
||||
"source": "https://ai.google.dev/gemini-api/docs/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/realtime"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"audio"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_audio_input": true,
|
||||
"tpm": 250000,
|
||||
"rpm": 10
|
||||
},
|
||||
"perplexity/pplx-embed-context-v1-0.6b": {
|
||||
"input_cost_per_token": 8e-09,
|
||||
"litellm_provider": "perplexity",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
# - tests/e2e Python -> `make lint-e2e-basedpyright` (test-linting.yml's e2e type-check step)
|
||||
# + raw HTTP client ban (test-code-quality.yml's check_e2e_no_raw_requests)
|
||||
# - dashboard -> prettier + eslint + lint budgets (test-litellm-ui-build.yml's frontend-lint)
|
||||
# - proxy/types -> regenerate dashboard API types and fail on drift (check-ui-api-types.yml)
|
||||
# - proxy/types -> regenerate the lazy OpenAPI snapshot and dashboard API types, fail on drift (check-ui-api-types.yml)
|
||||
#
|
||||
# Each block is skipped when no matching files are in scope, so unrelated commits
|
||||
# stay fast. This is intentionally not auto-installed as a git hook (see
|
||||
|
|
@ -244,7 +244,7 @@ fi
|
|||
|
||||
genapi_checks() {
|
||||
local status=0
|
||||
echo "check: checking dashboard API types are in sync (npm run gen:api)"
|
||||
echo "check: checking the lazy OpenAPI snapshot and dashboard API types are in sync (npm run gen:api)"
|
||||
# gen-api-types.mjs imports litellm.proxy.proxy_server, which needs the proxy deps
|
||||
# and an up-to-date Prisma client; check-ui-api-types.yml installs those and runs
|
||||
# prisma generate before gen:api, so mirror that here or a stale client can mask
|
||||
|
|
@ -260,7 +260,14 @@ genapi_checks() {
|
|||
elif ! uv run --no-sync python scripts/prisma_generate_if_needed.py; then
|
||||
echo "✗ Could not regenerate Prisma client (prisma generate failed)." >&2
|
||||
status=1
|
||||
elif ! uv run --no-sync python -m litellm.proxy._lazy_openapi_snapshot; then
|
||||
echo "✗ Could not regenerate the lazy OpenAPI snapshot (python -m litellm.proxy._lazy_openapi_snapshot failed)." >&2
|
||||
status=1
|
||||
elif ( cd ui/litellm-dashboard && LITELLM_PYTHON="uv run --no-sync python" npm run gen:api ); then
|
||||
if ! git diff --quiet -- litellm/proxy/_lazy_openapi_snapshot.json; then
|
||||
echo "✗ The lazy OpenAPI snapshot is stale; regenerated litellm/proxy/_lazy_openapi_snapshot.json. Stage it and commit; re-run make check only if other checks failed too." >&2
|
||||
status=1
|
||||
fi
|
||||
if ! git diff --quiet -- ui/litellm-dashboard/src/lib/http/schema.d.ts; then
|
||||
echo "✗ Dashboard API types are stale; regenerated src/lib/http/schema.d.ts. Stage it and commit; re-run make check only if other checks failed too." >&2
|
||||
status=1
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ at call time. The provider table below is the source of truth; edit `PROVIDERS`
|
|||
| openai | `openai-realtime` | `openai/gpt-realtime-2` |
|
||||
| azure | `azure-realtime` | `azure/gpt-realtime-2` (GA protocol) |
|
||||
| gemini | `gemini-realtime` | `gemini/gemini-3.1-flash-live-preview` |
|
||||
| vertex_ai | `vertex-realtime` | `vertex_ai/gemini-live-2.5-flash-preview-native-audio-09-2025` |
|
||||
| vertex_ai | `vertex-realtime` | `vertex_ai/gemini-live-2.5-flash-native-audio` |
|
||||
|
||||
Bedrock and xai (`xai/grok-4-1-fast-non-reasoning`) are supported by the proxy but
|
||||
kept commented out in `PROVIDERS` until they pass end-to-end here; re-enable them by
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ PROVIDERS = (
|
|||
"vertex_ai",
|
||||
"vertex-realtime",
|
||||
LiteLLMParamsBody(
|
||||
model="vertex_ai/gemini-live-2.5-flash-preview-native-audio-09-2025",
|
||||
model="vertex_ai/gemini-live-2.5-flash-native-audio",
|
||||
vertex_location="us-central1",
|
||||
vertex_credentials="os.environ/VERTEXAI_CREDENTIALS",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -308,7 +308,8 @@ class TestGeminiChatCompletions:
|
|||
content=f"Reply with the single word pong. marker={tag}",
|
||||
)
|
||||
],
|
||||
max_tokens=32,
|
||||
max_tokens=64,
|
||||
reasoning_effort="none",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -50,11 +50,14 @@ CACHE_WARM_CONSECUTIVE_READS = 3
|
|||
|
||||
|
||||
def _cacheable_system_block(marker: str) -> TextBlock:
|
||||
"""A system prompt comfortably above the 4096-token minimum cacheable size
|
||||
of Haiku 4.5 (the smallest model here), unique per run so no other run's
|
||||
cache entry can satisfy the read."""
|
||||
text = " ".join(
|
||||
f"Reference paragraph {index} for run {marker}." for index in range(300)
|
||||
"""A system prompt at roughly twice the 4096-token minimum cacheable size of
|
||||
Haiku 4.5 (the smallest model here), unique per run so no other run's cache
|
||||
entry can satisfy the read. The marker appears once instead of in every
|
||||
paragraph: repeating it swung the block's size by ~1800 tokens with the
|
||||
marker's own tokenization and left it under the minimum on ~15% of runs, so
|
||||
the system breakpoint went uncached and the priming loop never saw a read."""
|
||||
text = f"Run {marker}.\n" + " ".join(
|
||||
f"Reference paragraph {index}." for index in range(1500)
|
||||
)
|
||||
return TextBlock(text=text, cache_control=CacheControl())
|
||||
|
||||
|
|
@ -101,8 +104,8 @@ def _first_turn_user_text(marker: str) -> str:
|
|||
"""A first user turn heavy enough (hundreds of tokens) that losing its cache
|
||||
entry is unambiguous in the usage numbers, unique per attempt so priming
|
||||
retries never depend on the proxy's response cache behavior."""
|
||||
notes = " ".join(f"Session note {index} for attempt {marker}." for index in range(100))
|
||||
return f"Reply with one word.\n{notes}"
|
||||
notes = " ".join(f"Session note {index}." for index in range(100))
|
||||
return f"Reply with one word. Attempt {marker}.\n{notes}"
|
||||
|
||||
|
||||
class PrimedCache(BaseModel):
|
||||
|
|
|
|||
|
|
@ -70,9 +70,15 @@ def _vertex_params(model: str, location: str) -> LiteLLMParamsBody:
|
|||
|
||||
|
||||
def _cacheable_system_block(marker: str) -> TextBlock:
|
||||
"""A system prompt comfortably above the 1024-token minimum cacheable size,
|
||||
unique per run so no other run's cache entry can satisfy the read."""
|
||||
text = " ".join(f"Reference paragraph {index} for run {marker}." for index in range(300))
|
||||
"""A system prompt at roughly twice the 4096-token minimum cacheable size of
|
||||
Haiku 4.5 (the smallest model here), unique per run so no other run's cache
|
||||
entry can satisfy the read. The marker appears once instead of in every
|
||||
paragraph: repeating it swung the block's size by ~1800 tokens with the
|
||||
marker's own tokenization and left it under the minimum on ~15% of runs, so
|
||||
the system breakpoint went uncached and the priming loop never saw a read."""
|
||||
text = f"Run {marker}.\n" + " ".join(
|
||||
f"Reference paragraph {index}." for index in range(1500)
|
||||
)
|
||||
return TextBlock(text=text, cache_control=CacheControl())
|
||||
|
||||
|
||||
|
|
@ -110,8 +116,8 @@ def _first_turn_user_text(marker: str) -> str:
|
|||
"""A first user turn heavy enough (hundreds of tokens) that losing its cache
|
||||
entry is unambiguous in the usage numbers, unique per attempt so priming
|
||||
retries never depend on the proxy's response cache behavior."""
|
||||
notes = " ".join(f"Session note {index} for attempt {marker}." for index in range(100))
|
||||
return f"Reply with one word.\n{notes}"
|
||||
notes = " ".join(f"Session note {index}." for index in range(100))
|
||||
return f"Reply with one word. Attempt {marker}.\n{notes}"
|
||||
|
||||
|
||||
class PrimedCache(BaseModel):
|
||||
|
|
|
|||
|
|
@ -163,12 +163,6 @@ class TestBudgetManagement:
|
|||
f"/budget/list never included the created budget {budget_id}",
|
||||
)
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason=(
|
||||
"stage red: product gap, /budget/update 500s on any model_max_budget "
|
||||
"(prisma Json arg + unquoted GraphQL interpolation)"
|
||||
)
|
||||
)
|
||||
@pytest.mark.covers("mgmt.budget.update.accepts_model_max_budget")
|
||||
def test_update_accepts_per_model_budgets_including_punctuated_names(
|
||||
self, client: ManagementClient, resources: ResourceManager
|
||||
|
|
|
|||
|
|
@ -256,6 +256,7 @@ class ChatBody(BaseModel):
|
|||
reasoning_effort: str | None = None
|
||||
thinking: ThinkingParam | None = None
|
||||
service_tier: str | None = None
|
||||
prompt_cache_key: str | None = None
|
||||
tools: Sequence[ChatTool | McpChatTool] | None = None
|
||||
tool_choice: str | None = None
|
||||
guardrails: list[str] | None = None
|
||||
|
|
|
|||
|
|
@ -12,11 +12,16 @@ header is exercised with a real nonzero value instead of passing vacuously. The
|
|||
backend is gpt-5.5 because it reports cached tokens on the second call; the
|
||||
gpt-5.6 line reports cache writes and never a read, which would leave the
|
||||
cache-read header at zero forever. The raw-transport send is used because the
|
||||
typed chat client validates bodies and drops headers. OpenAI caching is
|
||||
best-effort, so the prime+measure round retries with a fresh prefix before
|
||||
failing.
|
||||
typed chat client validates bodies and drops headers.
|
||||
|
||||
OpenAI publishes a primed prefix asynchronously and routes lookups by
|
||||
prompt_cache_key, so a measure fired the instant the prime returns can miss a
|
||||
prefix that is about to become readable. Each round pins a cache key and re-reads
|
||||
the prefix it already paid to prime before spending a fresh one.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from cost_rows import approx_equal, cacheable_prefix, register_priced_model
|
||||
|
|
@ -31,6 +36,8 @@ pytestmark = pytest.mark.e2e
|
|||
BACKEND = "openai/gpt-5.5"
|
||||
OPENAI_API_KEY = "os.environ/OPENAI_API_KEY"
|
||||
CACHE_ATTEMPTS = 3
|
||||
CACHE_REREADS = 3
|
||||
CACHE_SETTLE_SECONDS = 2.0
|
||||
|
||||
INPUT_RATE = 4e-05
|
||||
OUTPUT_RATE = 8e-05
|
||||
|
|
@ -70,7 +77,7 @@ class TestCostHeaders:
|
|||
),
|
||||
)
|
||||
|
||||
def priced_call(content: str) -> StreamingResponse:
|
||||
def priced_call(content: str, cache_key: str) -> StreamingResponse:
|
||||
response = client.proxy.transport.send(
|
||||
"/chat/completions",
|
||||
headers=client.proxy.transport.bearer(scoped_key),
|
||||
|
|
@ -78,21 +85,30 @@ class TestCostHeaders:
|
|||
model=model,
|
||||
messages=[ChatMessage(role="user", content=content)],
|
||||
max_completion_tokens=4000,
|
||||
prompt_cache_key=cache_key,
|
||||
),
|
||||
)
|
||||
assert response.ok, f"chat failed (status {response.status_code}): {response.body[:300]}"
|
||||
return response
|
||||
|
||||
for _ in range(CACHE_ATTEMPTS):
|
||||
prefix = cacheable_prefix(unique_marker())
|
||||
priced_call(f"{prefix}\nReply with the single word ready.")
|
||||
measured = priced_call(f"{prefix}\nReply with the single word measured.")
|
||||
if _header_cost(measured, "x-litellm-response-cost-cache-read") > 0:
|
||||
break
|
||||
else:
|
||||
def prime_then_reread() -> StreamingResponse | None:
|
||||
marker = unique_marker()
|
||||
prefix = cacheable_prefix(marker)
|
||||
priced_call(f"{prefix}\nReply with the single word ready.", marker)
|
||||
for _ in range(CACHE_REREADS):
|
||||
time.sleep(CACHE_SETTLE_SECONDS)
|
||||
response = priced_call(f"{prefix}\nReply with the single word measured.", marker)
|
||||
if _header_cost(response, "x-litellm-response-cost-cache-read") > 0:
|
||||
return response
|
||||
return None
|
||||
|
||||
rounds = (prime_then_reread() for _ in range(CACHE_ATTEMPTS))
|
||||
measured = next((response for response in rounds if response is not None), None)
|
||||
if measured is None:
|
||||
pytest.fail(
|
||||
f"no cache read landed across {CACHE_ATTEMPTS} prime+measure rounds; "
|
||||
"the cache-read cost header was never exercised with a nonzero value"
|
||||
f"no cache read landed across {CACHE_ATTEMPTS} prime rounds of "
|
||||
f"{CACHE_REREADS} re-reads each; the cache-read cost header was never "
|
||||
"exercised with a nonzero value"
|
||||
)
|
||||
|
||||
total = measured.response_cost
|
||||
|
|
|
|||
|
|
@ -117,6 +117,11 @@ const ADMIN_AUTH = {
|
|||
Authorization: `Bearer ${users[Role.ProxyAdmin].password}`,
|
||||
};
|
||||
|
||||
// Five probes 2s apart outlast the e2e stack's proxy_config_reload_interval_seconds of 7.
|
||||
const SETTLE_INTERVAL_MS = 2_000;
|
||||
const SETTLE_PROBES = 5;
|
||||
const SETTLE_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Apply a router_settings patch through the typed /config/update contract. The
|
||||
* server merges it over existing settings (request wins), so only the passed keys
|
||||
|
|
@ -133,6 +138,21 @@ async function patchRouterSettings(
|
|||
expect(res.ok(), `seed /config/update failed: ${res.status()} ${await res.text()}`).toBeTruthy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Spreads its samples across more than one reload cycle: a single reply only proves the one
|
||||
* replica that served it has reloaded, not the sibling still on the pre-update config.
|
||||
*/
|
||||
async function sampleStatuses(probe: () => Promise<number>): Promise<readonly number[]> {
|
||||
return Array.from({ length: SETTLE_PROBES }).reduce<Promise<readonly number[]>>(
|
||||
async (taken, _unused, index) => {
|
||||
const sofar = await taken;
|
||||
if (index > 0) await new Promise((resolve) => setTimeout(resolve, SETTLE_INTERVAL_MS));
|
||||
return [...sofar, await probe()];
|
||||
},
|
||||
Promise.resolve([]),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Router Settings - Loadbalancing", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
|
|
@ -252,28 +272,34 @@ test.describe("Router Settings - Fallbacks serve the request", () => {
|
|||
});
|
||||
|
||||
test("a request to an unreachable model is answered by its fallback", async ({ page, request }) => {
|
||||
const chat = async () =>
|
||||
request.post("/v1/chat/completions", {
|
||||
headers: { ...ADMIN_AUTH, "Content-Type": "application/json" },
|
||||
data: {
|
||||
model: BROKEN_PRIMARY,
|
||||
messages: [{ role: "user", content: "fallback probe" }],
|
||||
},
|
||||
});
|
||||
const chatStatus = async () =>
|
||||
(
|
||||
await request.post("/v1/chat/completions", {
|
||||
headers: { ...ADMIN_AUTH, "Content-Type": "application/json" },
|
||||
data: {
|
||||
model: BROKEN_PRIMARY,
|
||||
messages: [{ role: "user", content: "fallback probe" }],
|
||||
},
|
||||
})
|
||||
).status();
|
||||
|
||||
// The control: it proves the reply below could only have come from the fallback.
|
||||
expect((await chat()).status(), "broken primary unexpectedly succeeded on its own").toBeGreaterThanOrEqual(400);
|
||||
// The control: every replica must reject, or the reply below could have come from one
|
||||
// that was still serving a fallback left behind by an earlier attempt.
|
||||
await expect
|
||||
.poll(async () => (await sampleStatuses(chatStatus)).every((status) => status >= 400), {
|
||||
timeout: SETTLE_TIMEOUT_MS,
|
||||
message: "broken primary unexpectedly succeeded on its own",
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
await patchRouterSettings(request, {
|
||||
fallbacks: [{ [BROKEN_PRIMARY]: [PRIMARY] }],
|
||||
} as Partial<NonNullable<ConfigYAML["router_settings"]>>);
|
||||
|
||||
// Same call now succeeds, served by the fallback model.
|
||||
// One success is the whole claim here, so this waits for a first sighting rather than
|
||||
// for every replica: demanding a streak would also assert a fallback hit rate.
|
||||
await expect
|
||||
.poll(async () => (await chat()).status(), {
|
||||
timeout: 30_000,
|
||||
message: "fallback never took effect",
|
||||
})
|
||||
.poll(chatStatus, { timeout: SETTLE_TIMEOUT_MS, message: "fallback never took effect" })
|
||||
.toBe(200);
|
||||
|
||||
// And the playground renders a reply for a model whose own upstream is down.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@ sys.path.insert(
|
|||
),
|
||||
)
|
||||
|
||||
from litellm_proxy_extras.utils import ProxyExtrasDBManager
|
||||
from litellm_proxy_extras.utils import (
|
||||
PARTITIONED_SPEND_LOGS_PUSH_ERROR,
|
||||
ProxyExtrasDBManager,
|
||||
filter_partitioned_spend_logs_diff,
|
||||
)
|
||||
|
||||
# Path to the migrations directory
|
||||
_MIGRATIONS_DIR = os.path.abspath(
|
||||
|
|
@ -475,3 +479,205 @@ class TestMigrationGuardScope:
|
|||
if not self._run_rules([(TestMigrationGuardScope._NEW, by_name[name])])
|
||||
]
|
||||
assert not redundant, f"these no longer violate and should be removed: {redundant}"
|
||||
|
||||
|
||||
_PARTITIONED_DRIFT_SQL = """-- AlterTable
|
||||
ALTER TABLE "LiteLLM_BudgetTable" ADD COLUMN "updated_by" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_SpendLogs" DROP CONSTRAINT "LiteLLM_SpendLogs_pkey",
|
||||
ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD CONSTRAINT "LiteLLM_SpendLogs_pkey" PRIMARY KEY ("request_id");
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "LiteLLM_SpendLogs_legacy";
|
||||
"""
|
||||
|
||||
|
||||
class TestPartitionedSpendLogsDriftFilter:
|
||||
"""A doc-partitioned LiteLLM_SpendLogs (db_scripts/partition_spend_logs.sql) has a
|
||||
composite primary key that schema.prisma cannot express, so `prisma migrate diff`
|
||||
emits a primary-key rewrite that Postgres rejects, aborting the whole drift script
|
||||
before its legitimate statements run."""
|
||||
|
||||
def test_pk_rewrite_and_runbook_artifact_drops_are_removed(self):
|
||||
filtered = filter_partitioned_spend_logs_diff(_PARTITIONED_DRIFT_SQL)
|
||||
assert 'DROP CONSTRAINT "LiteLLM_SpendLogs_pkey"' not in filtered
|
||||
assert 'PRIMARY KEY ("request_id")' not in filtered
|
||||
assert "LiteLLM_SpendLogs_legacy" not in filtered
|
||||
|
||||
def test_legitimate_statements_in_the_same_script_are_kept(self):
|
||||
filtered = filter_partitioned_spend_logs_diff(_PARTITIONED_DRIFT_SQL)
|
||||
assert 'ALTER TABLE "LiteLLM_BudgetTable" ADD COLUMN "updated_by" TEXT;' in filtered
|
||||
assert 'ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP' in filtered
|
||||
assert 'ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP' in filtered
|
||||
assert filtered.count('ALTER TABLE "LiteLLM_SpendLogs"') == 1
|
||||
|
||||
def test_an_alter_containing_only_the_pk_rewrite_is_dropped_entirely(self):
|
||||
sql = (
|
||||
'ALTER TABLE "LiteLLM_SpendLogs" DROP CONSTRAINT "LiteLLM_SpendLogs_pkey",\n'
|
||||
'ADD CONSTRAINT "LiteLLM_SpendLogs_pkey" PRIMARY KEY ("request_id");\n'
|
||||
)
|
||||
assert filter_partitioned_spend_logs_diff(sql).strip() == ""
|
||||
|
||||
def test_other_tables_pk_changes_are_untouched(self):
|
||||
sql = (
|
||||
'ALTER TABLE "LiteLLM_TeamTable" DROP CONSTRAINT "LiteLLM_TeamTable_pkey",\n'
|
||||
'ADD CONSTRAINT "LiteLLM_TeamTable_pkey" PRIMARY KEY ("team_id");\n'
|
||||
)
|
||||
filtered = filter_partitioned_spend_logs_diff(sql)
|
||||
assert 'DROP CONSTRAINT "LiteLLM_TeamTable_pkey"' in filtered
|
||||
assert 'PRIMARY KEY ("team_id")' in filtered
|
||||
|
||||
|
||||
class _FakeCompleted:
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
|
||||
class TestResolveAllMigrationsLedger:
|
||||
def _run(self, monkeypatch, tmp_path, partitioned, execute_fails):
|
||||
import subprocess as subprocess_module
|
||||
|
||||
import litellm_proxy_extras.utils as utils_module
|
||||
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:5432/db")
|
||||
monkeypatch.delenv("DIRECT_URL", raising=False)
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "spend_logs_is_partitioned", staticmethod(lambda: partitioned)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager,
|
||||
"_get_migration_names",
|
||||
staticmethod(lambda migrations_dir: ["20250326162113_baseline"]),
|
||||
)
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
if "diff" in cmd:
|
||||
kwargs["stdout"].write(_PARTITIONED_DRIFT_SQL)
|
||||
return _FakeCompleted()
|
||||
if "execute" in cmd:
|
||||
executed_sql = open(cmd[cmd.index("--file") + 1]).read()
|
||||
calls.append(("executed_sql", executed_sql))
|
||||
if execute_fails:
|
||||
raise subprocess_module.CalledProcessError(1, cmd, stderr="boom")
|
||||
return _FakeCompleted()
|
||||
return _FakeCompleted()
|
||||
|
||||
monkeypatch.setattr(utils_module.subprocess, "run", fake_run)
|
||||
ProxyExtrasDBManager._resolve_all_migrations(str(tmp_path), "schema.prisma")
|
||||
return calls
|
||||
|
||||
def _resolved(self, calls):
|
||||
return [c for c in calls if isinstance(c, list) and "resolve" in c]
|
||||
|
||||
def _executed_sql(self, calls):
|
||||
return next(c[1] for c in calls if isinstance(c, tuple) and c[0] == "executed_sql")
|
||||
|
||||
def test_failed_drift_apply_does_not_mark_migrations_applied(self, monkeypatch, tmp_path):
|
||||
calls = self._run(monkeypatch, tmp_path, partitioned=False, execute_fails=True)
|
||||
assert self._resolved(calls) == []
|
||||
|
||||
def test_successful_drift_apply_still_marks_migrations_applied(self, monkeypatch, tmp_path):
|
||||
calls = self._run(monkeypatch, tmp_path, partitioned=False, execute_fails=False)
|
||||
assert len(self._resolved(calls)) == 1
|
||||
|
||||
def test_partitioned_spend_logs_gets_the_filtered_drift_script(self, monkeypatch, tmp_path):
|
||||
calls = self._run(monkeypatch, tmp_path, partitioned=True, execute_fails=False)
|
||||
executed_sql = self._executed_sql(calls)
|
||||
assert 'PRIMARY KEY ("request_id")' not in executed_sql
|
||||
assert "LiteLLM_SpendLogs_legacy" not in executed_sql
|
||||
assert 'ALTER TABLE "LiteLLM_BudgetTable" ADD COLUMN "updated_by" TEXT;' in executed_sql
|
||||
assert 'ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP' in executed_sql
|
||||
assert len(self._resolved(calls)) == 1
|
||||
|
||||
def test_unpartitioned_spend_logs_drift_script_is_untouched(self, monkeypatch, tmp_path):
|
||||
calls = self._run(monkeypatch, tmp_path, partitioned=False, execute_fails=False)
|
||||
assert self._executed_sql(calls) == _PARTITIONED_DRIFT_SQL
|
||||
|
||||
|
||||
class TestPartitionedSpendLogsPushGuard:
|
||||
def _forbid_subprocess(self, monkeypatch):
|
||||
import litellm_proxy_extras.utils as utils_module
|
||||
|
||||
def fail_run(cmd, **kwargs):
|
||||
raise AssertionError(f"subprocess.run should not be called, got: {cmd}")
|
||||
|
||||
monkeypatch.setattr(utils_module.subprocess, "run", fail_run)
|
||||
|
||||
def test_v1_db_push_fails_fast_with_guidance(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "spend_logs_is_partitioned", staticmethod(lambda: True)
|
||||
)
|
||||
self._forbid_subprocess(monkeypatch)
|
||||
with pytest.raises(RuntimeError) as err:
|
||||
ProxyExtrasDBManager._run_migrations(use_migrate=False, use_v2_resolver=False)
|
||||
assert str(err.value) == PARTITIONED_SPEND_LOGS_PUSH_ERROR
|
||||
|
||||
def test_v2_db_push_fails_fast_with_guidance(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "spend_logs_is_partitioned", staticmethod(lambda: True)
|
||||
)
|
||||
self._forbid_subprocess(monkeypatch)
|
||||
with pytest.raises(RuntimeError) as err:
|
||||
ProxyExtrasDBManager._setup_database_v2(use_migrate=False)
|
||||
assert str(err.value) == PARTITIONED_SPEND_LOGS_PUSH_ERROR
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
def fetchone(self):
|
||||
return (1,)
|
||||
|
||||
|
||||
class _FakePsycopgConn:
|
||||
def __init__(self, executed):
|
||||
self._executed = executed
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def execute(self, query, params):
|
||||
self._executed.append((query, params))
|
||||
return _FakeCursor()
|
||||
|
||||
|
||||
class TestSpendLogsPartitionDetectionSchemaScope:
|
||||
"""A same-named LiteLLM_SpendLogs in another schema must not trip the
|
||||
detector: the catalog lookup has to be scoped to Prisma's target schema."""
|
||||
|
||||
def _detect(self, monkeypatch, database_url):
|
||||
import sys
|
||||
import types
|
||||
|
||||
executed = []
|
||||
fake_psycopg = types.ModuleType("psycopg")
|
||||
fake_psycopg.connect = lambda url, **kwargs: _FakePsycopgConn(executed)
|
||||
fake_psycopg.OperationalError = type("OperationalError", (Exception,), {})
|
||||
fake_psycopg.DatabaseError = type("DatabaseError", (Exception,), {})
|
||||
monkeypatch.setitem(sys.modules, "psycopg", fake_psycopg)
|
||||
monkeypatch.setenv("DATABASE_URL", database_url)
|
||||
assert ProxyExtrasDBManager.spend_logs_is_partitioned() is True
|
||||
return executed[0]
|
||||
|
||||
def test_lookup_is_scoped_to_the_schema_url_param(self, monkeypatch):
|
||||
query, params = self._detect(
|
||||
monkeypatch, "postgresql://u:p@localhost:5432/db?schema=tenant_a"
|
||||
)
|
||||
assert "pg_namespace" in query
|
||||
assert "n.nspname = %s" in query
|
||||
assert params == ("tenant_a",)
|
||||
|
||||
def test_lookup_falls_back_to_public_without_a_schema_param(self, monkeypatch):
|
||||
query, params = self._detect(monkeypatch, "postgresql://u:p@localhost:5432/db")
|
||||
assert "n.nspname = %s" in query
|
||||
assert params == ("public",)
|
||||
|
||||
def test_only_partitioned_relations_match(self, monkeypatch):
|
||||
query, _ = self._detect(monkeypatch, "postgresql://u:p@localhost:5432/db")
|
||||
assert "pg_partitioned_table" in query
|
||||
|
|
|
|||
|
|
@ -753,3 +753,41 @@ class TestCheckResponsesCost:
|
|||
call_kwargs = mock_aget.call_args[1]
|
||||
assert "model" not in call_kwargs.get("litellm_metadata", {})
|
||||
assert "model_group" not in call_kwargs.get("litellm_metadata", {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_stamps_internal_call_origin_so_the_read_is_billed(
|
||||
self, check_responses_cost_instance, mock_prisma_client
|
||||
):
|
||||
"""A background create returns queued with no usage, so this poll's retrieval is the only
|
||||
place the job's spend is ever seen. Without the origin stamp it is priced at zero like a
|
||||
user-facing read (LIT-5602) and the job is never billed."""
|
||||
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY
|
||||
from litellm.litellm_core_utils.internal_call_metadata import (
|
||||
is_unbilled_non_inference_call,
|
||||
)
|
||||
|
||||
mock_job = MagicMock()
|
||||
mock_job.unified_object_id = "resp_test_billed"
|
||||
mock_job.created_by = "test-user"
|
||||
mock_job.id = "job-billed"
|
||||
mock_job.file_object = {"model": "gpt-5", "id": "resp_test_billed"}
|
||||
|
||||
mock_prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock(
|
||||
return_value=[mock_job]
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock(
|
||||
return_value=0
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = "completed"
|
||||
|
||||
with patch("litellm.aget_responses", new_callable=AsyncMock) as mock_aget:
|
||||
mock_aget.return_value = mock_response
|
||||
await check_responses_cost_instance.check_responses_cost()
|
||||
|
||||
metadata = mock_aget.call_args[1]["litellm_metadata"]
|
||||
foreground_read = {"background": False}
|
||||
assert metadata[INTERNAL_CALL_ORIGIN_METADATA_KEY] == "background_response_cost_poll"
|
||||
assert is_unbilled_non_inference_call("aget_responses", metadata, foreground_read) is False
|
||||
assert is_unbilled_non_inference_call("aget_responses", None, foreground_read) is True
|
||||
|
|
|
|||
|
|
@ -2661,7 +2661,7 @@ async def test_run_direct_health_check_drops_only_the_rejected_kwarg(monkeypatch
|
|||
rejected argument alongside working ones would probe deployments the operator opted out."""
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
|
||||
seen: list = []
|
||||
seen: list[tuple[dict[str, str] | None, bool]] = []
|
||||
|
||||
async def fake_perform_health_check(
|
||||
model_list,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,825 @@
|
|||
"""
|
||||
Batching tests for NewRelicMetricsLogger: flush-window interval computation,
|
||||
dimension-bucket aggregation, the 4xx-drop vs 5xx/network-requeue policy, the
|
||||
retry-queue cap, and the stop flag that ends the periodic flush loop.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import gzip
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError, Request, Response
|
||||
|
||||
from litellm.integrations.newrelic.newrelic_metrics import (
|
||||
NewRelicMetricsLogger,
|
||||
_bucket_metrics,
|
||||
build_metric_payload,
|
||||
)
|
||||
from litellm.types.integrations.newrelic import (
|
||||
NEWRELIC_METRIC_COMPLETION_TOKENS,
|
||||
NEWRELIC_METRIC_COST_USD,
|
||||
NEWRELIC_METRIC_ENDPOINT_BY_REGION,
|
||||
NEWRELIC_METRIC_PROMPT_TOKENS,
|
||||
NEWRELIC_METRIC_REQUEST_DURATION_MS,
|
||||
NEWRELIC_METRIC_REQUESTS,
|
||||
NEWRELIC_METRIC_TOTAL_TOKENS,
|
||||
NewRelicMetricRecord,
|
||||
)
|
||||
|
||||
|
||||
def _record(
|
||||
team_id="team-a",
|
||||
team_alias=None,
|
||||
model="gpt-4o",
|
||||
model_group=None,
|
||||
status="success",
|
||||
response_cost=0.5,
|
||||
prompt_tokens=10,
|
||||
completion_tokens=20,
|
||||
total_tokens=30,
|
||||
duration_ms=100.0,
|
||||
) -> NewRelicMetricRecord:
|
||||
return NewRelicMetricRecord(
|
||||
team_id=team_id,
|
||||
team_alias=team_alias if team_alias is not None else f"{team_id}-alias",
|
||||
model_group=model_group if model_group is not None else f"{model}-group",
|
||||
model=model,
|
||||
custom_llm_provider="openai",
|
||||
status=status,
|
||||
response_cost=response_cost,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
|
||||
def _standard_logging_object(team_id="team-a", response_cost=0.25) -> dict:
|
||||
return {
|
||||
"metadata": {"user_api_key_team_id": team_id, "user_api_key_team_alias": f"{team_id}-alias"},
|
||||
"model_group": "gpt-4o-group",
|
||||
"model": "gpt-4o",
|
||||
"custom_llm_provider": "openai",
|
||||
"status": "success",
|
||||
"response_cost": response_cost,
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
"response_time": 0.1,
|
||||
}
|
||||
|
||||
|
||||
def _make_logger(**kwargs) -> NewRelicMetricsLogger:
|
||||
with patch("asyncio.create_task"):
|
||||
return NewRelicMetricsLogger(newrelic_api_key="test-key", **kwargs)
|
||||
|
||||
|
||||
def _response(status_code: int, text: str = "") -> Response:
|
||||
return Response(status_code, request=Request("POST", "https://example.com"), text=text)
|
||||
|
||||
|
||||
def _raises(status_code: int):
|
||||
"""Mock the way AsyncHTTPHandler.post really behaves: raise_for_status() turns
|
||||
every non-2xx into an HTTPStatusError rather than returning the response."""
|
||||
resp = _response(status_code)
|
||||
return AsyncMock(side_effect=HTTPStatusError("err", request=resp.request, response=resp))
|
||||
|
||||
|
||||
def _metrics_by_name(payload, name):
|
||||
return [m for m in payload[0]["metrics"] if m["name"] == name]
|
||||
|
||||
|
||||
class TestBuildMetricPayload:
|
||||
def test_interval_and_timestamp_reflect_flush_window(self):
|
||||
payload = build_metric_payload((_record(),), window_start=1_000.0, now=1_007.5)
|
||||
|
||||
assert payload[0]["common"]["timestamp"] == 1_000_000
|
||||
assert payload[0]["common"]["interval.ms"] == 7_500
|
||||
|
||||
def test_interval_is_at_least_one_ms(self):
|
||||
payload = build_metric_payload((_record(),), window_start=1_000.0, now=1_000.0)
|
||||
|
||||
assert payload[0]["common"]["interval.ms"] == 1
|
||||
|
||||
def test_single_record_metric_values(self):
|
||||
payload = build_metric_payload(
|
||||
(_record(response_cost=0.5, prompt_tokens=10, completion_tokens=20, total_tokens=30, duration_ms=100.0),),
|
||||
window_start=1_000.0,
|
||||
now=1_005.0,
|
||||
)
|
||||
|
||||
by_name = {m["name"]: m for m in payload[0]["metrics"]}
|
||||
assert by_name[NEWRELIC_METRIC_REQUESTS]["value"] == 1.0
|
||||
assert by_name[NEWRELIC_METRIC_REQUESTS]["type"] == "count"
|
||||
assert by_name[NEWRELIC_METRIC_COST_USD]["value"] == 0.5
|
||||
assert by_name[NEWRELIC_METRIC_PROMPT_TOKENS]["value"] == 10.0
|
||||
assert by_name[NEWRELIC_METRIC_COMPLETION_TOKENS]["value"] == 20.0
|
||||
assert by_name[NEWRELIC_METRIC_TOTAL_TOKENS]["value"] == 30.0
|
||||
duration = by_name[NEWRELIC_METRIC_REQUEST_DURATION_MS]
|
||||
assert duration["type"] == "summary"
|
||||
assert duration["value"] == {"count": 1, "sum": 100.0, "min": 100.0, "max": 100.0}
|
||||
assert by_name[NEWRELIC_METRIC_REQUESTS]["attributes"] == {
|
||||
"team_id": "team-a",
|
||||
"team_alias": "team-a-alias",
|
||||
"model_group": "gpt-4o-group",
|
||||
"model": "gpt-4o",
|
||||
"custom_llm_provider": "openai",
|
||||
"status": "success",
|
||||
}
|
||||
|
||||
def test_aggregates_across_dimension_buckets(self):
|
||||
"""Two teams x two models in one queue land in the right bucket sums.
|
||||
|
||||
team_alias and model_group are held constant so bucketing provably keys on
|
||||
team_id and model themselves, not on correlated fields.
|
||||
"""
|
||||
shared = {"team_alias": "shared-alias", "model_group": "shared-group"}
|
||||
records = (
|
||||
_record(team_id="team-a", model="gpt-4o", response_cost=0.1, total_tokens=10, duration_ms=50.0, **shared),
|
||||
_record(team_id="team-a", model="gpt-4o", response_cost=0.2, total_tokens=20, duration_ms=150.0, **shared),
|
||||
_record(
|
||||
team_id="team-a", model="claude-4", response_cost=0.4, total_tokens=40, duration_ms=200.0, **shared
|
||||
),
|
||||
_record(team_id="team-b", model="gpt-4o", response_cost=0.8, total_tokens=80, duration_ms=300.0, **shared),
|
||||
)
|
||||
payload = build_metric_payload(records, window_start=1_000.0, now=1_005.0)
|
||||
|
||||
cost_by_bucket = {
|
||||
(m["attributes"]["team_id"], m["attributes"]["model"]): m["value"]
|
||||
for m in _metrics_by_name(payload, NEWRELIC_METRIC_COST_USD)
|
||||
}
|
||||
assert cost_by_bucket == {
|
||||
("team-a", "gpt-4o"): pytest.approx(0.3),
|
||||
("team-a", "claude-4"): pytest.approx(0.4),
|
||||
("team-b", "gpt-4o"): pytest.approx(0.8),
|
||||
}
|
||||
|
||||
requests_by_bucket = {
|
||||
(m["attributes"]["team_id"], m["attributes"]["model"]): m["value"]
|
||||
for m in _metrics_by_name(payload, NEWRELIC_METRIC_REQUESTS)
|
||||
}
|
||||
assert requests_by_bucket == {
|
||||
("team-a", "gpt-4o"): 2.0,
|
||||
("team-a", "claude-4"): 1.0,
|
||||
("team-b", "gpt-4o"): 1.0,
|
||||
}
|
||||
|
||||
duration_by_bucket = {
|
||||
(m["attributes"]["team_id"], m["attributes"]["model"]): m["value"]
|
||||
for m in _metrics_by_name(payload, NEWRELIC_METRIC_REQUEST_DURATION_MS)
|
||||
}
|
||||
assert duration_by_bucket[("team-a", "gpt-4o")] == {"count": 2, "sum": 200.0, "min": 50.0, "max": 150.0}
|
||||
|
||||
def test_status_is_a_bucket_dimension(self):
|
||||
records = (
|
||||
_record(status="success", response_cost=0.1),
|
||||
_record(status="failure", response_cost=0.0),
|
||||
)
|
||||
payload = build_metric_payload(records, window_start=1_000.0, now=1_005.0)
|
||||
|
||||
statuses = {m["attributes"]["status"] for m in _metrics_by_name(payload, NEWRELIC_METRIC_REQUESTS)}
|
||||
assert statuses == {"success", "failure"}
|
||||
|
||||
def test_empty_attribute_values_are_omitted(self):
|
||||
record = NewRelicMetricRecord(
|
||||
team_id="",
|
||||
team_alias="",
|
||||
model_group="",
|
||||
model="gpt-4o",
|
||||
custom_llm_provider="openai",
|
||||
status="success",
|
||||
response_cost=0.0,
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
total_tokens=0,
|
||||
duration_ms=0.0,
|
||||
)
|
||||
payload = build_metric_payload((record,), window_start=1_000.0, now=1_005.0)
|
||||
|
||||
attributes = payload[0]["metrics"][0]["attributes"]
|
||||
assert "team_id" not in attributes
|
||||
assert "team_alias" not in attributes
|
||||
assert "model_group" not in attributes
|
||||
|
||||
|
||||
class TestQueueAndFlush:
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_event_queues_record_from_standard_logging_object(self):
|
||||
logger = _make_logger()
|
||||
|
||||
await logger.async_log_success_event(
|
||||
kwargs={"standard_logging_object": _standard_logging_object()},
|
||||
response_obj={},
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
)
|
||||
|
||||
assert len(logger.log_queue) == 1
|
||||
record = logger.log_queue[0]
|
||||
assert record.team_id == "team-a"
|
||||
assert record.response_cost == 0.25
|
||||
assert record.duration_ms == pytest.approx(100.0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failure_event_queues_record(self):
|
||||
logger = _make_logger()
|
||||
|
||||
slo = _standard_logging_object()
|
||||
slo["status"] = "failure"
|
||||
await logger.async_log_failure_event(
|
||||
kwargs={"standard_logging_object": slo},
|
||||
response_obj={},
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
)
|
||||
|
||||
assert len(logger.log_queue) == 1
|
||||
assert logger.log_queue[0].status == "failure"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_threshold_flush_uses_flush_queue(self):
|
||||
logger = _make_logger()
|
||||
logger.batch_size = 1
|
||||
logger.flush_queue = AsyncMock()
|
||||
|
||||
await logger.async_log_success_event(
|
||||
kwargs={"standard_logging_object": _standard_logging_object()},
|
||||
response_obj={},
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
)
|
||||
|
||||
logger.flush_queue.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_queue_updates_last_flush_time_on_success(self):
|
||||
logger = _make_logger()
|
||||
logger.log_queue = [_record()]
|
||||
logger.last_flush_time = 0
|
||||
logger.async_client.post = AsyncMock(return_value=_response(202))
|
||||
|
||||
await logger.flush_queue()
|
||||
|
||||
assert logger.log_queue == []
|
||||
assert logger.last_flush_time > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_advances_window_even_on_requeue(self):
|
||||
# The window start advances every flush cycle so requeued records report
|
||||
# in the next window instead of freezing interval.ms under sustained
|
||||
# failure, and an idle gap never inflates the next batch's window
|
||||
logger = _make_logger()
|
||||
logger.log_queue = [_record()]
|
||||
logger.last_flush_time = 123.0
|
||||
logger.async_client.post = _raises(500)
|
||||
|
||||
await logger.flush_queue()
|
||||
|
||||
assert logger.last_flush_time > 123.0
|
||||
assert len(logger.log_queue) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sent_payload_window_starts_at_last_flush_time(self):
|
||||
logger = _make_logger()
|
||||
logger.log_queue = [_record()]
|
||||
logger.last_flush_time = 2_000.0
|
||||
logger.async_client.post = AsyncMock(return_value=_response(202))
|
||||
|
||||
with patch("litellm.integrations.newrelic.newrelic_metrics.time.time", return_value=2_010.0):
|
||||
await logger.async_send_batch()
|
||||
|
||||
sent = logger.async_client.post.await_args.kwargs
|
||||
body = json.loads(gzip.decompress(sent["data"]).decode("utf-8"))
|
||||
assert body[0]["common"]["timestamp"] == 2_000_000
|
||||
assert body[0]["common"]["interval.ms"] == 10_000
|
||||
assert sent["headers"]["Api-Key"] == "test-key"
|
||||
assert sent["headers"]["Content-Encoding"] == "gzip"
|
||||
assert sent["url"] == NEWRELIC_METRIC_ENDPOINT_BY_REGION["us"]
|
||||
|
||||
|
||||
class TestBatchSizeCap:
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_sends_at_most_batch_size_records_per_request(self):
|
||||
"""A queue grown past the batch size by requeues must go out in chunks:
|
||||
one oversized request would breach the Metric API data point cap and get
|
||||
the whole retry backlog dropped as a 4xx."""
|
||||
logger = _make_logger()
|
||||
logger.batch_size = 2
|
||||
logger.log_queue = [_record(model=f"model-{i}") for i in range(5)]
|
||||
logger.async_client.post = AsyncMock(return_value=_response(202))
|
||||
|
||||
await logger.flush_queue()
|
||||
|
||||
sent_counts = [
|
||||
sum(
|
||||
metric["value"]
|
||||
for metric in json.loads(gzip.decompress(call.kwargs["data"]).decode("utf-8"))[0]["metrics"]
|
||||
if metric["name"] == NEWRELIC_METRIC_REQUESTS
|
||||
)
|
||||
for call in logger.async_client.post.await_args_list
|
||||
]
|
||||
assert sent_counts == [2.0, 2.0, 1.0]
|
||||
assert logger.log_queue == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_chunk_stops_the_flush_and_keeps_order(self):
|
||||
"""A 5xx on the first chunk ends the flush instead of hammering the same
|
||||
failing endpoint with the rest of the backlog, and the requeue keeps the
|
||||
records in chronological order."""
|
||||
logger = _make_logger()
|
||||
logger.batch_size = 2
|
||||
records = [_record(model=f"model-{i}") for i in range(5)]
|
||||
logger.log_queue = list(records)
|
||||
logger.async_client.post = _raises(500)
|
||||
|
||||
await logger.flush_queue()
|
||||
|
||||
assert logger.async_client.post.await_count == 1
|
||||
assert logger.log_queue == records
|
||||
|
||||
|
||||
class TestFlushConcurrency:
|
||||
@pytest.mark.asyncio
|
||||
async def test_records_appended_during_flush_await_survive(self):
|
||||
"""A record appended by a concurrent request while the POST is in flight
|
||||
must survive the flush, not be clobbered by a queue replacement."""
|
||||
logger = _make_logger()
|
||||
logger.log_queue = [_record(team_id="team-a")]
|
||||
interleaved = _record(team_id="team-interleaved")
|
||||
|
||||
async def _post_appending_mid_flight(**kwargs):
|
||||
logger.log_queue.append(interleaved)
|
||||
return _response(202)
|
||||
|
||||
logger.async_client.post = AsyncMock(side_effect=_post_appending_mid_flight)
|
||||
|
||||
await logger.async_send_batch()
|
||||
|
||||
assert logger.log_queue == [interleaved]
|
||||
body = json.loads(gzip.decompress(logger.async_client.post.await_args.kwargs["data"]).decode("utf-8"))
|
||||
team_ids = {m["attributes"]["team_id"] for m in body[0]["metrics"]}
|
||||
assert team_ids == {"team-a"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_records_appended_during_failed_flush_await_survive_requeue(self):
|
||||
"""The requeue path must also preserve interleaved records: batch is
|
||||
prepended in place, never assigned over the live queue."""
|
||||
logger = _make_logger()
|
||||
original = _record(team_id="team-a")
|
||||
logger.log_queue = [original]
|
||||
interleaved = _record(team_id="team-interleaved")
|
||||
|
||||
async def _post_appending_mid_flight(**kwargs):
|
||||
logger.log_queue.append(interleaved)
|
||||
raise HTTPStatusError('e', request=_response(500).request, response=_response(500))
|
||||
|
||||
logger.async_client.post = AsyncMock(side_effect=_post_appending_mid_flight)
|
||||
|
||||
await logger.async_send_batch()
|
||||
|
||||
assert logger.log_queue == [original, interleaved]
|
||||
|
||||
|
||||
class TestErrorPolicy:
|
||||
@pytest.mark.asyncio
|
||||
async def test_4xx_drops_batch(self):
|
||||
logger = _make_logger()
|
||||
logger.log_queue = [_record(), _record(team_id="team-b")]
|
||||
logger.async_client.post = AsyncMock(return_value=_response(400, text="bad request"))
|
||||
|
||||
await logger.async_send_batch()
|
||||
|
||||
assert logger.log_queue == []
|
||||
assert logger.async_client.post.await_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_403_drops_batch_and_names_permanent_credential_failure(self):
|
||||
logger = _make_logger()
|
||||
logger.log_queue = [_record()]
|
||||
logger.async_client.post = _raises(403)
|
||||
|
||||
with patch("litellm.integrations.newrelic.newrelic_metrics.verbose_logger") as mock_logger:
|
||||
await logger.async_send_batch()
|
||||
|
||||
assert logger.log_queue == []
|
||||
warning_text = " ".join(str(arg) for call in mock_logger.warning.call_args_list for arg in call.args)
|
||||
assert "permanent credential failure" in warning_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_5xx_requeues_batch(self):
|
||||
records = [_record(), _record(team_id="team-b")]
|
||||
logger = _make_logger()
|
||||
logger.log_queue = list(records)
|
||||
logger.async_client.post = _raises(500)
|
||||
|
||||
await logger.async_send_batch()
|
||||
|
||||
assert logger.log_queue == records
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_error_requeues_batch(self):
|
||||
records = [_record()]
|
||||
logger = _make_logger()
|
||||
logger.log_queue = list(records)
|
||||
logger.async_client.post = AsyncMock(side_effect=ConnectionError("boom"))
|
||||
|
||||
await logger.async_send_batch()
|
||||
|
||||
assert logger.log_queue == records
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requeue_is_capped_dropping_oldest(self):
|
||||
logger = _make_logger()
|
||||
logger.max_queue_size = 3
|
||||
oldest = _record(team_id="oldest")
|
||||
rest = [_record(team_id=f"team-{i}") for i in range(3)]
|
||||
logger.log_queue = [oldest, *rest]
|
||||
logger.async_client.post = _raises(500)
|
||||
|
||||
await logger.async_send_batch()
|
||||
|
||||
assert logger.log_queue == rest
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requeued_records_are_resent_with_new_records(self):
|
||||
logger = _make_logger()
|
||||
logger.log_queue = [_record()]
|
||||
logger.async_client.post = _raises(500)
|
||||
|
||||
await logger.async_send_batch()
|
||||
logger.log_queue.append(_record(team_id="team-b"))
|
||||
logger.async_client.post = AsyncMock(return_value=_response(202))
|
||||
|
||||
await logger.async_send_batch()
|
||||
|
||||
sent = logger.async_client.post.await_args.kwargs
|
||||
body = json.loads(gzip.decompress(sent["data"]).decode("utf-8"))
|
||||
team_ids = {m["attributes"]["team_id"] for m in body[0]["metrics"]}
|
||||
assert team_ids == {"team-a", "team-b"}
|
||||
assert logger.log_queue == []
|
||||
|
||||
|
||||
class TestStopFlag:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_ends_periodic_flush_loop(self):
|
||||
logger = _make_logger()
|
||||
logger.flush_interval = 0.01
|
||||
logger.flush_queue = AsyncMock()
|
||||
|
||||
task = asyncio.create_task(logger.periodic_flush())
|
||||
await asyncio.sleep(0.05)
|
||||
assert not task.done()
|
||||
|
||||
logger.stop()
|
||||
await asyncio.wait_for(task, timeout=1.0)
|
||||
|
||||
assert task.done()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stopped_logger_exits_after_one_final_drain(self):
|
||||
logger = _make_logger()
|
||||
logger.flush_interval = 0.01
|
||||
logger._final_drain = AsyncMock()
|
||||
logger._stopped = True
|
||||
|
||||
await asyncio.wait_for(logger.periodic_flush(), timeout=1.0)
|
||||
|
||||
logger._final_drain.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_eviction_drains_queued_records(self):
|
||||
"""Eviction must post what is already queued, not silently discard it."""
|
||||
from litellm.litellm_core_utils.specialty_caches.dynamic_logging_cache import (
|
||||
DynamicLoggingCache,
|
||||
)
|
||||
|
||||
cache = DynamicLoggingCache()
|
||||
logger = _make_logger()
|
||||
logger.log_queue = [_record(), _record(team_id="team-b")]
|
||||
logger.async_client.post = AsyncMock(return_value=_response(202))
|
||||
credentials = {"newrelic_api_key": "test-key", "newrelic_region": None}
|
||||
cache.set_cache(credentials=credentials, service_name="newrelic", logging_obj=logger)
|
||||
|
||||
key = cache.get_cache_key(args={**credentials, "service_name": "newrelic"})
|
||||
cache.cache._remove_key(key)
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
logger.async_client.post.assert_awaited_once()
|
||||
body = json.loads(gzip.decompress(logger.async_client.post.await_args.kwargs["data"]).decode("utf-8"))
|
||||
team_ids = {m["attributes"]["team_id"] for m in body[0]["metrics"]}
|
||||
assert team_ids == {"team-a", "team-b"}
|
||||
assert logger.log_queue == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dynamic_logging_cache_eviction_calls_stop(self):
|
||||
from litellm.litellm_core_utils.specialty_caches.dynamic_logging_cache import (
|
||||
DynamicLoggingCache,
|
||||
)
|
||||
|
||||
cache = DynamicLoggingCache()
|
||||
logger = _make_logger()
|
||||
credentials = {"newrelic_api_key": "test-key", "newrelic_region": None}
|
||||
cache.set_cache(credentials=credentials, service_name="newrelic", logging_obj=logger)
|
||||
|
||||
key = cache.get_cache_key(args={**credentials, "service_name": "newrelic"})
|
||||
cache.cache._remove_key(key)
|
||||
|
||||
assert logger._stopped is True
|
||||
assert cache.get_cache(credentials=credentials, service_name="newrelic") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_after_eviction_drain_self_flushes():
|
||||
"""An in-flight callback holding an evicted (stopped) logger still delivers
|
||||
its record: with no periodic loop left, the append itself drains."""
|
||||
logger = _make_logger()
|
||||
with patch.object(
|
||||
logger.async_client, "post", new=AsyncMock(return_value=_response(202))
|
||||
) as mock_post:
|
||||
logger.stop()
|
||||
await logger.async_log_success_event(
|
||||
{"standard_logging_object": _standard_logging_object()}, None, None, None
|
||||
)
|
||||
assert mock_post.await_count >= 1, "record appended after stop() must be flushed, not stranded"
|
||||
assert logger.log_queue == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_final_drain_retries_transient_failure_then_delivers():
|
||||
"""A transient 5xx during the eviction drain must not strand the last
|
||||
batch: the final drain retries on its own (no periodic loop is left)."""
|
||||
logger = _make_logger()
|
||||
err = _response(500)
|
||||
responses = [HTTPStatusError('e', request=err.request, response=err), HTTPStatusError('e', request=err.request, response=err), _response(202)]
|
||||
post_mock = AsyncMock(side_effect=responses)
|
||||
with patch.object(logger, "async_client") as client, patch("asyncio.sleep", new=AsyncMock()):
|
||||
client.post = post_mock
|
||||
await logger._log_async_event(standard_logging_object=_standard_logging_object())
|
||||
await logger._final_drain()
|
||||
assert post_mock.await_count == 3
|
||||
assert logger.log_queue == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_final_drain_drops_after_bounded_passes_under_lock():
|
||||
"""A permanently failing destination is retried across bounded passes, then
|
||||
the remainder is dropped under flush_lock and logged, never stranded. A
|
||||
second drain over the now-empty queue is a no-op."""
|
||||
logger = _make_logger()
|
||||
post_mock = _raises(500)
|
||||
with patch.object(logger, "async_client") as client, patch("asyncio.sleep", new=AsyncMock()):
|
||||
client.post = post_mock
|
||||
await logger._log_async_event(standard_logging_object=_standard_logging_object())
|
||||
await logger._final_drain()
|
||||
after_first = post_mock.await_count
|
||||
await logger._final_drain()
|
||||
assert after_first >= 1, "the failing destination was retried before the drop"
|
||||
assert post_mock.await_count == after_first, "second drain over an empty queue is a no-op"
|
||||
assert logger.log_queue == [], "exhausted retries end in a logged drop, not a stranded queue"
|
||||
|
||||
|
||||
def test_attribute_values_bounded_against_payload_bombs():
|
||||
"""A caller-controlled high-entropy model string is truncated in metric
|
||||
attributes so one record cannot inflate the shared batch past the Metric
|
||||
API payload cap and take out other users' metrics."""
|
||||
record = _record(model="m" * 5000)
|
||||
metrics = _bucket_metrics((record,))
|
||||
for metric in metrics:
|
||||
assert len(metric["attributes"]["model"]) == 255
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_gap_does_not_inflate_next_window():
|
||||
"""Empty flush cycles advance the window start, so a burst after idling
|
||||
reports an interval close to the flush cadence, not the whole idle gap."""
|
||||
logger = _make_logger()
|
||||
logger.last_flush_time = 100.0
|
||||
with patch.object(logger, "async_client") as client:
|
||||
client.post = AsyncMock(return_value=_response(202))
|
||||
await logger.flush_queue()
|
||||
assert logger.last_flush_time > 100.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mid_drain_append_delivered_against_healthy_destination():
|
||||
"""A record a callback appends while a drain is running is picked up by a
|
||||
later pass and delivered when the destination is healthy; nothing stranded."""
|
||||
logger = _make_logger()
|
||||
logger.stop()
|
||||
late_record = _record(model="late-model")
|
||||
injected = {"done": False}
|
||||
posted = []
|
||||
|
||||
async def _capture(url, headers=None, content=None, **kw):
|
||||
posted.append(content)
|
||||
if not injected["done"]:
|
||||
injected["done"] = True
|
||||
logger.log_queue.append(late_record)
|
||||
return _response(202)
|
||||
|
||||
with patch.object(logger, "async_client") as client, patch("asyncio.sleep", new=AsyncMock()):
|
||||
client.post = _capture
|
||||
logger.log_queue.append(_record(model="first"))
|
||||
await logger._drain_with_retry()
|
||||
assert logger.log_queue == [], "the mid-drain append was drained too, nothing stranded"
|
||||
assert len(posted) >= 2, "both the original and the mid-drain record were sent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_attempts_every_chunk_not_just_the_head_under_failure():
|
||||
"""Regression: with more than batch_size records queued on a stopped logger
|
||||
and a persistently failing destination, every record must be attempted before
|
||||
the bounded terminal drop. The periodic path stops at the first failing chunk,
|
||||
so a drain that reused it would drop the un-sent tail (records past the head
|
||||
chunk) as if it had tried them, silently undercounting the team's usage."""
|
||||
logger = _make_logger()
|
||||
logger.stop()
|
||||
logger.batch_size = 2
|
||||
logger.log_queue = [_record(model=f"m{i}") for i in range(5)]
|
||||
sent_models = []
|
||||
|
||||
async def _capture_then_fail(url, data=None, headers=None, **kw):
|
||||
body = json.loads(gzip.decompress(data).decode("utf-8"))
|
||||
sent_models.extend(
|
||||
m["attributes"]["model"] for m in body[0]["metrics"] if m["name"] == NEWRELIC_METRIC_REQUESTS
|
||||
)
|
||||
resp = _response(503)
|
||||
raise HTTPStatusError("err", request=resp.request, response=resp)
|
||||
|
||||
with patch("asyncio.sleep", new=AsyncMock()):
|
||||
logger.async_client.post = _capture_then_fail
|
||||
await logger._drain_with_retry()
|
||||
|
||||
assert set(sent_models) == {"m0", "m1", "m2", "m3", "m4"}, "every chunk, including the tail, was attempted"
|
||||
assert logger.log_queue == [], "the exhausted batch is dropped after bounded passes, nothing stranded"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_delivers_the_tail_once_the_destination_recovers():
|
||||
"""The tail beyond the head chunk must be delivered, not stranded, once a
|
||||
transiently failing destination recovers within the drain's passes."""
|
||||
logger = _make_logger()
|
||||
logger.stop()
|
||||
logger.batch_size = 2
|
||||
logger.log_queue = [_record(model=f"m{i}") for i in range(5)]
|
||||
delivered_models = []
|
||||
posts = {"n": 0}
|
||||
|
||||
async def _fail_first_pass_then_recover(url, data=None, headers=None, **kw):
|
||||
posts["n"] += 1
|
||||
if posts["n"] <= 3: # the first pass's three chunks all fail
|
||||
resp = _response(503)
|
||||
raise HTTPStatusError("err", request=resp.request, response=resp)
|
||||
body = json.loads(gzip.decompress(data).decode("utf-8"))
|
||||
delivered_models.extend(
|
||||
m["attributes"]["model"] for m in body[0]["metrics"] if m["name"] == NEWRELIC_METRIC_REQUESTS
|
||||
)
|
||||
return _response(202)
|
||||
|
||||
with patch("asyncio.sleep", new=AsyncMock()):
|
||||
logger.async_client.post = _fail_first_pass_then_recover
|
||||
await logger._drain_with_retry()
|
||||
|
||||
assert set(delivered_models) == {"m0", "m1", "m2", "m3", "m4"}, "all chunks delivered after recovery"
|
||||
assert logger.log_queue == [], "nothing left stranded once the destination recovered"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_drop_leaves_untried_late_arrival_for_next_drain():
|
||||
"""Against a permanently failing destination, the terminal drop clears only
|
||||
the records this drain actually tried; a record a callback appends during the
|
||||
final pass, after that pass's snapshot, is left in the queue for its own
|
||||
serialized drain, never wiped un-tried."""
|
||||
logger = _make_logger()
|
||||
logger.stop()
|
||||
from litellm.types.integrations.newrelic import NEWRELIC_METRICS_MAX_DRAIN_PASSES
|
||||
|
||||
late_record = _record(model="late-arrival")
|
||||
posts = {"n": 0}
|
||||
|
||||
async def _fail_and_append_on_final_pass(url, data=None, headers=None, **kw):
|
||||
posts["n"] += 1
|
||||
# One record means one post per pass, so the final pass's post is the
|
||||
# Nth; append then, after the drain has already snapshotted the queue.
|
||||
if posts["n"] == NEWRELIC_METRICS_MAX_DRAIN_PASSES:
|
||||
logger.log_queue.append(late_record)
|
||||
resp = _response(503)
|
||||
raise HTTPStatusError("err", request=resp.request, response=resp)
|
||||
|
||||
with patch("asyncio.sleep", new=AsyncMock()):
|
||||
logger.async_client.post = _fail_and_append_on_final_pass
|
||||
logger.log_queue.append(_record(model="doomed"))
|
||||
await logger._drain_with_retry()
|
||||
assert logger.log_queue == [late_record], "the un-tried late arrival is left for its own drain, not dropped"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_appended_on_an_early_pass_is_not_dropped_short_of_the_retry_budget():
|
||||
"""A record a callback appends during an early drain pass entered the queue
|
||||
after this drain's snapshot, so it has not seen the full retry budget. The
|
||||
terminal drop must clear only records queued when the drain began, leaving
|
||||
the early-pass arrival for its own serialized drain instead of dropping it
|
||||
after fewer than the configured attempts."""
|
||||
logger = _make_logger()
|
||||
logger.stop()
|
||||
early_record = _record(model="early-pass-arrival")
|
||||
posts = {"n": 0}
|
||||
|
||||
async def _fail_and_append_on_first_pass(url, data=None, headers=None, **kw):
|
||||
posts["n"] += 1
|
||||
# One record queued at start means the first pass's post is the 1st;
|
||||
# append during it, before this drain's later passes.
|
||||
if posts["n"] == 1:
|
||||
logger.log_queue.append(early_record)
|
||||
resp = _response(503)
|
||||
raise HTTPStatusError("err", request=resp.request, response=resp)
|
||||
|
||||
with patch("asyncio.sleep", new=AsyncMock()):
|
||||
logger.async_client.post = _fail_and_append_on_first_pass
|
||||
logger.log_queue.append(_record(model="doomed"))
|
||||
await logger._drain_with_retry()
|
||||
assert logger.log_queue == [early_record], "the early-pass arrival is left for its own drain, not dropped short"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_stop_drains_are_serialized():
|
||||
"""A callback that appends to a stopped logger and starts its own drain must
|
||||
queue behind an already-running drain, not race it: otherwise one drain's
|
||||
terminal clear could wipe a record the other is still responsible for.
|
||||
Proven by holding the first drain inside its flush and asserting the second
|
||||
has not entered its own flush until the first releases."""
|
||||
logger = _make_logger()
|
||||
logger._stopped = True # stopped without scheduling a background drain
|
||||
logger.log_queue.append(_record(model="r1"))
|
||||
entered = []
|
||||
release = asyncio.Event()
|
||||
|
||||
async def blocking_flush():
|
||||
entered.append(len(entered) + 1)
|
||||
if len(entered) == 1:
|
||||
await release.wait()
|
||||
logger.log_queue.clear()
|
||||
|
||||
logger._drain_flush_once = blocking_flush
|
||||
t1 = asyncio.create_task(logger._drain_with_retry())
|
||||
await asyncio.sleep(0.02) # let t1 acquire the drain lock and enter flush
|
||||
assert entered == [1], f"first drain did not enter flush: {entered}"
|
||||
t2 = asyncio.create_task(logger._drain_with_retry())
|
||||
await asyncio.sleep(0.02) # t2 must block on the drain lock, not enter flush
|
||||
assert entered == [1], f"second drain raced the first: {entered}"
|
||||
release.set()
|
||||
await asyncio.gather(t1, t2)
|
||||
assert logger.log_queue == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raised_403_is_dropped_not_requeued():
|
||||
"""AsyncHTTPHandler.post raises HTTPStatusError on 4xx, so a 403 (permanent
|
||||
bad key) arrives as an exception, not a response. It must be dropped, never
|
||||
requeued, or a revoked key retries forever."""
|
||||
logger = _make_logger()
|
||||
logger.log_queue.append(_record())
|
||||
logger.async_client.post = _raises(403)
|
||||
await logger.async_send_batch()
|
||||
assert logger.log_queue == [], "a permanent 403 must drop, not requeue"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raised_500_is_requeued():
|
||||
"""A raised 5xx is transient and must be requeued for retry."""
|
||||
logger = _make_logger()
|
||||
record = _record()
|
||||
logger.log_queue.append(record)
|
||||
logger.async_client.post = _raises(503)
|
||||
await logger.async_send_batch()
|
||||
assert logger.log_queue == [record], "a transient 5xx must requeue"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", [429, 408])
|
||||
async def test_transient_4xx_is_requeued_not_dropped(status):
|
||||
"""The Metric API returns 429 when it throttles (and 408 on a request
|
||||
timeout); both are transient and expect a retry, so the batch must be
|
||||
requeued rather than permanently dropped like a 400/403."""
|
||||
logger = _make_logger()
|
||||
record = _record()
|
||||
logger.log_queue.append(record)
|
||||
logger.async_client.post = _raises(status)
|
||||
await logger.async_send_batch()
|
||||
assert logger.log_queue == [record], f"a transient {status} must requeue, not drop"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", [200, 201, 204])
|
||||
async def test_any_2xx_is_treated_as_delivered_not_requeued(status):
|
||||
"""The Metric API answers 202, but any 2xx means the destination accepted the
|
||||
batch. Treating a non-202 2xx as a failure would re-queue and re-send data
|
||||
New Relic already stored, duplicating the team's metrics until the cap drops."""
|
||||
logger = _make_logger()
|
||||
logger.log_queue.append(_record())
|
||||
logger.async_client.post = AsyncMock(return_value=_response(status))
|
||||
await logger.async_send_batch()
|
||||
assert logger.log_queue == [], f"a {status} success must drop, not requeue and duplicate"
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
"""
|
||||
Tests for team-scoped New Relic metrics callback support.
|
||||
|
||||
Verifies that NewRelicMetricsLogger is instantiated with per-team credentials
|
||||
(newrelic_api_key, newrelic_region) with no environment fallback, and that
|
||||
NewRelicHandler correctly resolves and caches per-team loggers.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.newrelic.newrelic_metrics import NewRelicMetricsLogger
|
||||
from litellm.integrations.newrelic.newrelic_team_handler import NewRelicHandler
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
||||
TRUSTED_CALLBACK_VARS_FIELD,
|
||||
)
|
||||
from litellm.litellm_core_utils.specialty_caches.dynamic_logging_cache import (
|
||||
DynamicLoggingCache,
|
||||
)
|
||||
from litellm.types.integrations.newrelic import NEWRELIC_METRIC_ENDPOINT_BY_REGION
|
||||
from litellm.types.utils import StandardCallbackDynamicParams
|
||||
|
||||
US_ENDPOINT = NEWRELIC_METRIC_ENDPOINT_BY_REGION["us"]
|
||||
EU_ENDPOINT = NEWRELIC_METRIC_ENDPOINT_BY_REGION["eu"]
|
||||
|
||||
|
||||
class TestNewRelicMetricsLoggerCredentialKwargs:
|
||||
"""The logger takes credentials by injection only; env vars never leak in."""
|
||||
|
||||
def test_init_with_explicit_credentials(self):
|
||||
with patch("asyncio.create_task"):
|
||||
logger = NewRelicMetricsLogger(newrelic_api_key="team_key", newrelic_region="eu")
|
||||
|
||||
assert logger.newrelic_api_key == "team_key"
|
||||
assert logger.metric_api_url == EU_ENDPOINT
|
||||
|
||||
def test_init_defaults_to_us_region(self):
|
||||
with patch("asyncio.create_task"):
|
||||
logger = NewRelicMetricsLogger(newrelic_api_key="team_key")
|
||||
|
||||
assert logger.metric_api_url == US_ENDPOINT
|
||||
|
||||
def test_unknown_region_falls_back_to_us(self):
|
||||
with patch("asyncio.create_task"):
|
||||
logger = NewRelicMetricsLogger(newrelic_api_key="team_key", newrelic_region="mars")
|
||||
|
||||
assert logger.metric_api_url == US_ENDPOINT
|
||||
|
||||
def test_region_is_case_insensitive(self):
|
||||
with patch("asyncio.create_task"):
|
||||
logger = NewRelicMetricsLogger(newrelic_api_key="team_key", newrelic_region="EU")
|
||||
|
||||
assert logger.metric_api_url == EU_ENDPOINT
|
||||
|
||||
def test_init_raises_without_api_key(self):
|
||||
with pytest.raises(ValueError, match="newrelic_api_key"):
|
||||
with patch("asyncio.create_task"):
|
||||
NewRelicMetricsLogger(newrelic_api_key="")
|
||||
|
||||
def test_init_never_falls_back_to_env_license_key(self, monkeypatch):
|
||||
"""A missing team key must fail, never silently reuse the operator's key."""
|
||||
monkeypatch.setenv("NEW_RELIC_LICENSE_KEY", "operator-license-key")
|
||||
|
||||
with pytest.raises(ValueError, match="newrelic_api_key"):
|
||||
with patch("asyncio.create_task"):
|
||||
NewRelicMetricsLogger(newrelic_api_key="")
|
||||
|
||||
|
||||
class TestNewRelicHandler:
|
||||
"""The handler resolves the correct logger per team."""
|
||||
|
||||
def test_creates_team_logger_with_dynamic_credentials(self):
|
||||
cache = DynamicLoggingCache()
|
||||
params = StandardCallbackDynamicParams(newrelic_api_key="team_a_key", newrelic_region="eu")
|
||||
|
||||
with patch("asyncio.create_task"):
|
||||
result = NewRelicHandler.get_newrelic_logger_for_request(
|
||||
standard_callback_dynamic_params=params,
|
||||
in_memory_dynamic_logger_cache=cache,
|
||||
)
|
||||
|
||||
assert result.newrelic_api_key == "team_a_key"
|
||||
assert result.metric_api_url == EU_ENDPOINT
|
||||
|
||||
def test_caches_team_logger(self):
|
||||
cache = DynamicLoggingCache()
|
||||
params = StandardCallbackDynamicParams(newrelic_api_key="team_b_key")
|
||||
|
||||
with patch("asyncio.create_task"):
|
||||
result1 = NewRelicHandler.get_newrelic_logger_for_request(
|
||||
standard_callback_dynamic_params=params,
|
||||
in_memory_dynamic_logger_cache=cache,
|
||||
)
|
||||
result2 = NewRelicHandler.get_newrelic_logger_for_request(
|
||||
standard_callback_dynamic_params=params,
|
||||
in_memory_dynamic_logger_cache=cache,
|
||||
)
|
||||
|
||||
assert result1 is result2
|
||||
|
||||
def test_different_teams_get_different_loggers(self):
|
||||
cache = DynamicLoggingCache()
|
||||
params_a = StandardCallbackDynamicParams(newrelic_api_key="team_a_key")
|
||||
params_b = StandardCallbackDynamicParams(newrelic_api_key="team_b_key")
|
||||
|
||||
with patch("asyncio.create_task"):
|
||||
result_a = NewRelicHandler.get_newrelic_logger_for_request(
|
||||
standard_callback_dynamic_params=params_a,
|
||||
in_memory_dynamic_logger_cache=cache,
|
||||
)
|
||||
result_b = NewRelicHandler.get_newrelic_logger_for_request(
|
||||
standard_callback_dynamic_params=params_b,
|
||||
in_memory_dynamic_logger_cache=cache,
|
||||
)
|
||||
|
||||
assert result_a is not result_b
|
||||
assert result_a.newrelic_api_key == "team_a_key"
|
||||
assert result_b.newrelic_api_key == "team_b_key"
|
||||
|
||||
def test_region_is_part_of_cache_key(self):
|
||||
"""Same key, different region must not share a logger (different endpoints)."""
|
||||
cache = DynamicLoggingCache()
|
||||
|
||||
with patch("asyncio.create_task"):
|
||||
result_us = NewRelicHandler.get_newrelic_logger_for_request(
|
||||
standard_callback_dynamic_params=StandardCallbackDynamicParams(newrelic_api_key="key"),
|
||||
in_memory_dynamic_logger_cache=cache,
|
||||
)
|
||||
result_eu = NewRelicHandler.get_newrelic_logger_for_request(
|
||||
standard_callback_dynamic_params=StandardCallbackDynamicParams(
|
||||
newrelic_api_key="key", newrelic_region="eu"
|
||||
),
|
||||
in_memory_dynamic_logger_cache=cache,
|
||||
)
|
||||
|
||||
assert result_us is not result_eu
|
||||
assert result_us.metric_api_url == US_ENDPOINT
|
||||
assert result_eu.metric_api_url == EU_ENDPOINT
|
||||
|
||||
def test_request_blocked_callback_params_includes_newrelic(self):
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
||||
_request_blocked_callback_params,
|
||||
)
|
||||
|
||||
assert "newrelic_api_key" in _request_blocked_callback_params
|
||||
assert "newrelic_region" in _request_blocked_callback_params
|
||||
|
||||
|
||||
class TestDynamicCredentialDetection:
|
||||
def test_no_credentials(self):
|
||||
params = StandardCallbackDynamicParams()
|
||||
assert NewRelicHandler._dynamic_newrelic_credentials_are_passed(params) is False
|
||||
|
||||
def test_region_only_is_not_credentials(self):
|
||||
params = StandardCallbackDynamicParams(newrelic_region="eu")
|
||||
assert NewRelicHandler._dynamic_newrelic_credentials_are_passed(params) is False
|
||||
|
||||
def test_api_key_is_credentials(self):
|
||||
params = StandardCallbackDynamicParams(newrelic_api_key="key")
|
||||
assert NewRelicHandler._dynamic_newrelic_credentials_are_passed(params) is True
|
||||
|
||||
|
||||
class TestStandardCallbackDynamicParamsIncludesNewRelic:
|
||||
def test_newrelic_params_in_annotations(self):
|
||||
annotations = StandardCallbackDynamicParams.__annotations__
|
||||
assert "newrelic_api_key" in annotations
|
||||
assert "newrelic_region" in annotations
|
||||
|
||||
|
||||
def _build_logging_obj(kwargs: dict, *, with_newrelic_callback: bool = True):
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
with patch("asyncio.create_task"):
|
||||
return Logging(
|
||||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=False,
|
||||
call_type="completion",
|
||||
start_time="2026-01-01",
|
||||
litellm_call_id="test-call-id",
|
||||
function_id="test-func",
|
||||
dynamic_success_callbacks=["newrelic"] if with_newrelic_callback else None,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _metrics_loggers(logging_obj) -> list[NewRelicMetricsLogger]:
|
||||
return [cb for cb in (logging_obj.dynamic_success_callbacks or []) if isinstance(cb, NewRelicMetricsLogger)]
|
||||
|
||||
|
||||
class TestTeamCallbackFlowPassesNewRelicCredentials:
|
||||
"""
|
||||
newrelic_* credentials reach NewRelicHandler only from the proxy-stamped trusted
|
||||
field. Anything the caller put in the request body must not, or a caller could
|
||||
pair its own newrelic_region with the team's ingest key.
|
||||
"""
|
||||
|
||||
def test_trusted_callback_vars_reach_newrelic_handler(self):
|
||||
logging_obj = _build_logging_obj(
|
||||
{
|
||||
TRUSTED_CALLBACK_VARS_FIELD: {"newrelic_api_key": "team-nr-key-123", "newrelic_region": "eu"},
|
||||
"model": "gpt-4",
|
||||
"litellm_params": {"metadata": {}},
|
||||
}
|
||||
)
|
||||
|
||||
metrics_loggers = _metrics_loggers(logging_obj)
|
||||
assert len(metrics_loggers) == 1, "NewRelicMetricsLogger should be initialized from team callback_vars"
|
||||
assert metrics_loggers[0].newrelic_api_key == "team-nr-key-123"
|
||||
assert metrics_loggers[0].metric_api_url == EU_ENDPOINT
|
||||
|
||||
def test_trace_logger_still_dispatched_alongside_metrics(self):
|
||||
"""The metrics logger must not displace the trace logger for the same name."""
|
||||
logging_obj = _build_logging_obj(
|
||||
{
|
||||
TRUSTED_CALLBACK_VARS_FIELD: {"newrelic_api_key": "team-nr-key-123"},
|
||||
"model": "gpt-4",
|
||||
"litellm_params": {"metadata": {}},
|
||||
}
|
||||
)
|
||||
|
||||
non_metrics = [
|
||||
cb for cb in (logging_obj.dynamic_success_callbacks or []) if not isinstance(cb, NewRelicMetricsLogger)
|
||||
]
|
||||
assert len(non_metrics) == 1, "trace logger (OTel v2 or legacy agent) must remain in the dynamic list"
|
||||
assert len(_metrics_loggers(logging_obj)) == 1
|
||||
async_non_metrics = [
|
||||
cb
|
||||
for cb in (logging_obj.dynamic_async_success_callbacks or [])
|
||||
if not isinstance(cb, NewRelicMetricsLogger)
|
||||
]
|
||||
assert len(async_non_metrics) == 1
|
||||
|
||||
def test_request_kwargs_newrelic_params_are_ignored(self):
|
||||
logging_obj = _build_logging_obj(
|
||||
{
|
||||
"newrelic_api_key": "caller-nr-key",
|
||||
"newrelic_region": "eu",
|
||||
"model": "gpt-4",
|
||||
"litellm_params": {"metadata": {}},
|
||||
}
|
||||
)
|
||||
|
||||
assert _metrics_loggers(logging_obj) == []
|
||||
|
||||
def test_logging_object_stays_deepcopyable(self):
|
||||
logging_obj = _build_logging_obj(
|
||||
{
|
||||
TRUSTED_CALLBACK_VARS_FIELD: {"newrelic_api_key": "team-nr-key-123"},
|
||||
"model": "gpt-4",
|
||||
"litellm_params": {"metadata": {}},
|
||||
},
|
||||
with_newrelic_callback=False,
|
||||
)
|
||||
|
||||
assert copy.deepcopy(logging_obj)._trusted_callback_vars == logging_obj._trusted_callback_vars
|
||||
|
||||
def test_caller_cannot_redirect_team_credentials(self):
|
||||
"""The exfil shape: caller's newrelic_region paired with the team's key."""
|
||||
logging_obj = _build_logging_obj(
|
||||
{
|
||||
TRUSTED_CALLBACK_VARS_FIELD: {"newrelic_api_key": "team-nr-key-123"},
|
||||
"newrelic_region": "eu",
|
||||
"model": "gpt-4",
|
||||
"litellm_params": {"metadata": {}},
|
||||
}
|
||||
)
|
||||
|
||||
metrics_loggers = _metrics_loggers(logging_obj)
|
||||
assert len(metrics_loggers) == 1
|
||||
assert metrics_loggers[0].newrelic_api_key == "team-nr-key-123"
|
||||
assert metrics_loggers[0].metric_api_url == US_ENDPOINT
|
||||
|
|
@ -357,6 +357,92 @@ def test_release_without_eviction_keeps_provider_alive(monkeypatch):
|
|||
cache.release(None) # default-route release is a no-op
|
||||
|
||||
|
||||
# --- per-request service.name routing from trusted key/team config --- #
|
||||
|
||||
|
||||
def test_tenant_service_name_precedence_and_blanks():
|
||||
from litellm.integrations.otel.plumbing.routing import tenant_service_name
|
||||
|
||||
assert tenant_service_name({"otel_service_name": "team-svc"}) == "team-svc"
|
||||
assert tenant_service_name({"otel_service_name_override": "override", "otel_service_name": "base"}) == "override"
|
||||
assert tenant_service_name({"otel_service_name": " "}) is None
|
||||
assert tenant_service_name({"logging_setting": "x"}) is None
|
||||
assert tenant_service_name(None) is None
|
||||
|
||||
|
||||
def test_key_override_survives_team_metadata_merge():
|
||||
from litellm.integrations.otel.plumbing.routing import tenant_service_name
|
||||
|
||||
# Request setup merges team metadata over key metadata (last writer wins),
|
||||
# so a key keeps its own destination via ``otel_service_name_override``,
|
||||
# which a team defining only ``otel_service_name`` never touches.
|
||||
merged = {"otel_service_name_override": "key-svc"}
|
||||
merged.update({"otel_service_name": "team-svc"})
|
||||
assert tenant_service_name(merged) == "key-svc"
|
||||
|
||||
|
||||
def test_provider_cached_per_service_name():
|
||||
cache = _cache("otel")
|
||||
default = NoOpTracer()
|
||||
routed = cache.route_for(default, None, {"otel_service_name": "payments-gateway"})
|
||||
assert routed.tracer is not default
|
||||
assert routed.detached is False # stays parented into the request trace
|
||||
assert routed.provider is not None
|
||||
assert routed.provider.resource.attributes["service.name"] == "payments-gateway"
|
||||
cache.route_for(default, None, {"otel_service_name": "payments-gateway"})
|
||||
assert len(cache._providers) == 1
|
||||
cache.route_for(default, None, {"otel_service_name": "search-gateway"})
|
||||
assert len(cache._providers) == 2
|
||||
for provider in cache._providers.values():
|
||||
provider.shutdown()
|
||||
|
||||
|
||||
def test_service_name_routed_span_carries_team_service_name(monkeypatch):
|
||||
# The artifact the exporter receives: the finished span's Resource must
|
||||
# carry the team's service.name, not the env-configured default.
|
||||
monkeypatch.setenv("OTEL_SERVICE_NAME", "proxy-default")
|
||||
cache = _cache("otel")
|
||||
default = NoOpTracer()
|
||||
route = cache.route_for(default, None, {"otel_service_name": "payments-gateway"})
|
||||
with route.tracer.start_as_current_span("chat gpt-4o-mini") as span:
|
||||
pass
|
||||
assert span.resource.attributes["service.name"] == "payments-gateway"
|
||||
cache.release(route.provider)
|
||||
|
||||
unrouted = cache.route_for(default, None, {"logging_setting": "x"})
|
||||
assert unrouted.tracer is default # env fallback: no scoped provider built
|
||||
|
||||
|
||||
def test_client_dynamic_params_cannot_choose_service_name():
|
||||
# ``StandardCallbackDynamicParams`` is populated from client-supplied
|
||||
# request metadata; the service name may only come from server-set
|
||||
# key/team config (the ``auth_metadata`` argument).
|
||||
cache = _cache("otel")
|
||||
default = NoOpTracer()
|
||||
assert cache.route_for(default, {"otel_service_name": "attacker"}).tracer is default
|
||||
assert cache.route_for(default, {"otel_service_name_override": "attacker"}).tracer is default
|
||||
assert cache._providers == {}
|
||||
|
||||
|
||||
def test_service_name_override_leaves_exporters_untouched():
|
||||
cache = _cache(
|
||||
"otel",
|
||||
exporters=[
|
||||
ExporterSpec(
|
||||
kind="otlp_http",
|
||||
endpoint="http://collector:4318",
|
||||
headers="x=base-collector",
|
||||
owner=None,
|
||||
),
|
||||
],
|
||||
)
|
||||
cfg = cache._routed_config({}, {}, None, "payments-gateway")
|
||||
assert cfg.service_name == "payments-gateway"
|
||||
(spec,) = cfg.exporters
|
||||
assert spec.headers == "x=base-collector"
|
||||
assert spec.endpoint == "http://collector:4318"
|
||||
|
||||
|
||||
# --- New Relic: per-team api-key header + fixed-table region endpoint --- #
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -201,6 +201,36 @@ def test_time_to_first_token_is_streaming_only():
|
|||
assert names == set(ALL_METRICS) - {TIME_TO_FIRST_TOKEN}
|
||||
|
||||
|
||||
def test_response_read_does_not_replay_the_generation_usage():
|
||||
"""A responses-management read returns the ORIGINAL generation's usage on the
|
||||
object it fetches. Recording it would add those tokens again on every poll, so
|
||||
the two usage-derived instruments are skipped while the duration ones, which
|
||||
describe the read itself, still fire."""
|
||||
metrics = _drive_success(InMemoryMetricReader(), call_type="aget_responses")
|
||||
|
||||
assert TOKEN_USAGE not in metrics
|
||||
assert TIME_PER_OUTPUT_TOKEN not in metrics
|
||||
assert OPERATION_DURATION in metrics
|
||||
assert RESPONSE_DURATION in metrics
|
||||
|
||||
|
||||
def test_background_response_read_still_records_usage():
|
||||
"""A background=true create returns no usage, so its completed read is the only
|
||||
place the generation's tokens are ever seen. Skipping it would lose them
|
||||
entirely rather than deduplicate them."""
|
||||
reader = InMemoryMetricReader()
|
||||
logger = _logger(reader, enable_metrics=True)
|
||||
kwargs, response_obj, start, end = _build_call(call_type="aget_responses")
|
||||
response_obj["background"] = True
|
||||
asyncio.run(logger.async_log_success_event(kwargs, response_obj, start, end))
|
||||
|
||||
metrics = _metrics_by_name(reader)
|
||||
by_type = {dp.attributes[TOKEN_TYPE]: dp for dp in metrics[TOKEN_USAGE]}
|
||||
assert by_type["input"].sum == PROMPT_TOKENS
|
||||
assert by_type["output"].sum == COMPLETION_TOKENS
|
||||
assert TIME_PER_OUTPUT_TOKEN in metrics
|
||||
|
||||
|
||||
def test_metrics_disabled_records_nothing():
|
||||
"""enable_metrics=False: the recorder is never built, so the injected reader
|
||||
sees no gen_ai.client.* series even though the success hook runs."""
|
||||
|
|
|
|||
|
|
@ -268,6 +268,27 @@ def test_vector_store_file_management_is_not_chat(call_type):
|
|||
assert resolve_operation(call_type).value == "litellm.vector_store_file_management"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"call_type",
|
||||
[
|
||||
f"{prefix}{operation}"
|
||||
for operation in ("get_responses", "delete_responses", "cancel_responses", "list_input_items")
|
||||
for prefix in ("", "a")
|
||||
],
|
||||
)
|
||||
def test_responses_management_is_not_chat(call_type):
|
||||
"""Fetching, deleting or cancelling a stored response runs no inference, so it must not
|
||||
read as a chat completion: the retrieved object replays the original call's tokens and
|
||||
would inflate the chat series on every read. Regression test for LIT-5602."""
|
||||
assert resolve_operation(call_type) is GenAIOperation.LITELLM_RESPONSES_MANAGEMENT
|
||||
assert resolve_operation(call_type).value == "litellm.responses_management"
|
||||
|
||||
|
||||
def test_creating_a_response_is_still_chat():
|
||||
"""Guards the test above: ``/v1/responses`` itself is a chat completion."""
|
||||
assert resolve_operation("aresponses") is GenAIOperation.CHAT
|
||||
|
||||
|
||||
_NON_CHAT_ROUTES: Final = (
|
||||
("image_generation", GenAIOperation.GENERATE_CONTENT, GenAIOutputType.IMAGE),
|
||||
("speech", GenAIOperation.GENERATE_CONTENT, GenAIOutputType.SPEECH),
|
||||
|
|
|
|||
|
|
@ -6345,3 +6345,95 @@ class TestOpenTelemetryDatabaseSemconvAttributes(unittest.TestCase):
|
|||
span = self._service_span(ServiceTypes.DB, "get_data", None)
|
||||
self.assertEqual(span.attributes["db.system.name"], "postgresql")
|
||||
self.assertNotIn("server.address", span.attributes)
|
||||
|
||||
|
||||
class TestOpenTelemetryNonInferenceUsage(unittest.TestCase):
|
||||
"""Reading a stored response replays the usage of the call that created it, so emitting those
|
||||
token counts again on the read's span reports the same tokens a second time. Regression tests
|
||||
for LIT-5602, covering the legacy emitter that runs by default."""
|
||||
|
||||
USAGE = {"prompt_tokens": 4000, "completion_tokens": 2000, "total_tokens": 6000}
|
||||
TOKEN_KEYS = frozenset({"gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens", "gen_ai.usage.total_tokens"})
|
||||
BACKGROUND_POLL = {"internal_call_origin": "background_response_cost_poll"}
|
||||
RESPONSE_OBJ = {"id": "resp_lit5602", "model": "gpt-4o", "usage": USAGE}
|
||||
BACKGROUND_RESPONSE_OBJ = {**RESPONSE_OBJ, "background": True}
|
||||
|
||||
def _kwargs(self, call_type, litellm_metadata=None):
|
||||
return {
|
||||
"model": "gpt-4o",
|
||||
"call_type": call_type,
|
||||
"optional_params": {},
|
||||
"litellm_params": {
|
||||
"custom_llm_provider": "openai",
|
||||
"litellm_metadata": litellm_metadata or {},
|
||||
},
|
||||
"standard_logging_object": {"id": "lit5602", "call_type": call_type, "metadata": {}},
|
||||
}
|
||||
|
||||
def _token_attributes_on_span(self, call_type, litellm_metadata=None, response_obj=None):
|
||||
otel = OpenTelemetry()
|
||||
mock_span = MagicMock()
|
||||
otel.set_attributes(
|
||||
span=mock_span,
|
||||
kwargs=self._kwargs(call_type, litellm_metadata),
|
||||
response_obj=response_obj or dict(self.RESPONSE_OBJ),
|
||||
)
|
||||
return {call[0][0] for call in mock_span.set_attribute.call_args_list if call[0][0] in self.TOKEN_KEYS}
|
||||
|
||||
def _token_histogram_calls(self, call_type, litellm_metadata=None, response_obj=None):
|
||||
otel = OpenTelemetry()
|
||||
otel._operation_duration_histogram = MagicMock()
|
||||
otel._token_usage_histogram = MagicMock()
|
||||
otel._cost_histogram = None
|
||||
now = datetime.now()
|
||||
otel._record_metrics(
|
||||
self._kwargs(call_type, litellm_metadata), response_obj or dict(self.RESPONSE_OBJ), now, now
|
||||
)
|
||||
return otel._token_usage_histogram.record.call_count
|
||||
|
||||
def _time_per_output_token_calls(self, call_type, litellm_metadata=None, response_obj=None):
|
||||
otel = OpenTelemetry()
|
||||
otel._time_per_output_token_histogram = MagicMock()
|
||||
now = datetime.now()
|
||||
otel._record_time_per_output_token_metric(
|
||||
self._kwargs(call_type, litellm_metadata), response_obj or dict(self.RESPONSE_OBJ), now, 1.0, {}
|
||||
)
|
||||
return otel._time_per_output_token_histogram.record.call_count
|
||||
|
||||
def test_inference_call_still_reports_its_tokens_on_the_span(self):
|
||||
self.assertEqual(self._token_attributes_on_span("acompletion"), set(self.TOKEN_KEYS))
|
||||
|
||||
def test_response_read_does_not_report_the_retrieved_tokens_on_the_span(self):
|
||||
self.assertEqual(self._token_attributes_on_span("aget_responses"), set())
|
||||
|
||||
def test_background_cost_poll_read_still_reports_its_tokens_on_the_span(self):
|
||||
self.assertEqual(self._token_attributes_on_span("aget_responses", self.BACKGROUND_POLL), set(self.TOKEN_KEYS))
|
||||
|
||||
def test_inference_call_still_records_the_token_usage_histogram(self):
|
||||
self.assertEqual(self._token_histogram_calls("acompletion"), 2)
|
||||
|
||||
def test_response_read_does_not_record_the_token_usage_histogram(self):
|
||||
self.assertEqual(self._token_histogram_calls("aget_responses"), 0)
|
||||
|
||||
def test_background_cost_poll_read_still_records_the_token_usage_histogram(self):
|
||||
self.assertEqual(self._token_histogram_calls("aget_responses", self.BACKGROUND_POLL), 2)
|
||||
|
||||
def test_background_response_read_still_reports_its_tokens_on_the_span(self):
|
||||
self.assertEqual(
|
||||
self._token_attributes_on_span("aget_responses", response_obj=self.BACKGROUND_RESPONSE_OBJ),
|
||||
set(self.TOKEN_KEYS),
|
||||
)
|
||||
|
||||
def test_background_response_read_still_records_the_token_usage_histogram(self):
|
||||
self.assertEqual(self._token_histogram_calls("aget_responses", response_obj=self.BACKGROUND_RESPONSE_OBJ), 2)
|
||||
|
||||
def test_inference_call_still_records_time_per_output_token(self):
|
||||
self.assertEqual(self._time_per_output_token_calls("acompletion"), 1)
|
||||
|
||||
def test_response_read_does_not_divide_its_latency_by_the_retrieved_token_count(self):
|
||||
self.assertEqual(self._time_per_output_token_calls("aget_responses"), 0)
|
||||
|
||||
def test_background_response_read_still_records_time_per_output_token(self):
|
||||
self.assertEqual(
|
||||
self._time_per_output_token_calls("aget_responses", response_obj=self.BACKGROUND_RESPONSE_OBJ), 1
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1552,6 +1552,76 @@ def test_string_cost_values():
|
|||
assert round(completion_cost, 12) == round(expected_completion_cost, 12)
|
||||
|
||||
|
||||
def test_generic_cost_per_token_overlapping_cached_and_image_tokens():
|
||||
"""Some providers report cached_tokens and image_tokens as overlapping subsets of
|
||||
prompt_tokens. Billing each in full charged the overlap twice, once at the cache rate
|
||||
and again at the input rate."""
|
||||
model = "litellm-test-overlapping-cached-image"
|
||||
litellm.register_model(
|
||||
{
|
||||
model: {
|
||||
"litellm_provider": "openai",
|
||||
"mode": "chat",
|
||||
"input_cost_per_token": 1e-6,
|
||||
"cache_read_input_token_cost": 1e-7,
|
||||
"output_cost_per_token": 2e-6,
|
||||
}
|
||||
}
|
||||
)
|
||||
usage = Usage(
|
||||
prompt_tokens=100,
|
||||
completion_tokens=10,
|
||||
total_tokens=110,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
text_tokens=None, cached_tokens=90, image_tokens=80
|
||||
),
|
||||
)
|
||||
|
||||
prompt_cost, completion_cost = generic_cost_per_token(
|
||||
model=model, usage=usage, custom_llm_provider="openai"
|
||||
)
|
||||
|
||||
# 90 cached at 1e-7, the remaining 10 uncached tokens once at 1e-6
|
||||
assert prompt_cost == pytest.approx(90 * 1e-7 + 10 * 1e-6)
|
||||
assert completion_cost == pytest.approx(10 * 2e-6)
|
||||
|
||||
|
||||
def test_generic_cost_per_token_warm_prefix_cache_spanning_text_and_image_tokens():
|
||||
"""xAI reports text_tokens + image_tokens = prompt_tokens with cached_tokens overlapping
|
||||
both, so a warm prefix cache covering the whole image exceeds the text-only count.
|
||||
Observed live on grok-4.6 (issue #37281): the image tokens were billed a second time at
|
||||
the full input rate on top of the cache-read bucket, 0.003500 in vs the provider's own
|
||||
0.001274 bill."""
|
||||
model = "litellm-test-warm-prefix-cache-overlap"
|
||||
litellm.register_model(
|
||||
{
|
||||
model: {
|
||||
"litellm_provider": "openai",
|
||||
"mode": "chat",
|
||||
"input_cost_per_token": 2e-6,
|
||||
"cache_read_input_token_cost": 5e-7,
|
||||
"output_cost_per_token": 6e-6,
|
||||
}
|
||||
}
|
||||
)
|
||||
usage = Usage(
|
||||
prompt_tokens=2461,
|
||||
completion_tokens=440,
|
||||
total_tokens=2901,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
text_tokens=1319, cached_tokens=2432, image_tokens=1142
|
||||
),
|
||||
)
|
||||
|
||||
prompt_cost, completion_cost = generic_cost_per_token(
|
||||
model=model, usage=usage, custom_llm_provider="openai"
|
||||
)
|
||||
|
||||
# 2432 cached at the cache-read rate, the 29 uncached tokens once at the input rate
|
||||
assert prompt_cost == pytest.approx(2432 * 5e-7 + 29 * 2e-6)
|
||||
assert completion_cost == pytest.approx(440 * 6e-6)
|
||||
|
||||
|
||||
def test_calculate_cost_component_with_string_values():
|
||||
"""Test the calculate_cost_component function directly with string cost values."""
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import calculate_cost_component
|
||||
|
|
|
|||
|
|
@ -8,11 +8,10 @@ See https://github.com/BerriAI/litellm/issues/26153.
|
|||
|
||||
import pytest
|
||||
|
||||
|
||||
from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import (
|
||||
StandardBuiltInToolCostTracking,
|
||||
_get_web_search_requests,
|
||||
)
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import get_web_search_requests
|
||||
from litellm.types.utils import ModelResponse, ServerToolUse, Usage
|
||||
|
||||
|
||||
|
|
@ -28,25 +27,25 @@ class _UsageWithDictServerToolUse:
|
|||
|
||||
|
||||
def test_get_web_search_requests_handles_none():
|
||||
assert _get_web_search_requests(None) is None
|
||||
assert get_web_search_requests(None) is None
|
||||
|
||||
|
||||
def test_get_web_search_requests_handles_dict():
|
||||
assert _get_web_search_requests({"web_search_requests": 5}) == 5
|
||||
assert get_web_search_requests({"web_search_requests": 5}) == 5
|
||||
|
||||
|
||||
def test_get_web_search_requests_handles_dict_missing_key():
|
||||
assert _get_web_search_requests({}) is None
|
||||
assert get_web_search_requests({}) is None
|
||||
|
||||
|
||||
def test_get_web_search_requests_handles_pydantic():
|
||||
stu = ServerToolUse(web_search_requests=7)
|
||||
assert _get_web_search_requests(stu) == 7
|
||||
assert get_web_search_requests(stu) == 7
|
||||
|
||||
|
||||
def test_get_web_search_requests_handles_pydantic_with_none_value():
|
||||
stu = ServerToolUse()
|
||||
assert _get_web_search_requests(stu) is None
|
||||
assert get_web_search_requests(stu) is None
|
||||
|
||||
|
||||
def test_response_object_includes_web_search_call_with_dict_server_tool_use():
|
||||
|
|
|
|||
|
|
@ -1002,6 +1002,17 @@ def test_an_exception_without_a_status_is_still_a_connection_error(quiet_excepti
|
|||
)
|
||||
|
||||
|
||||
def test_an_unmapped_exception_with_no_model_or_provider_is_a_connection_error(quiet_exception_mapping):
|
||||
with pytest.raises(litellm.APIConnectionError) as raised:
|
||||
exception_type(
|
||||
model=None,
|
||||
original_exception=ValueError("boom"),
|
||||
custom_llm_provider=None,
|
||||
)
|
||||
|
||||
assert "boom" in raised.value.message
|
||||
|
||||
|
||||
CONTEXT_WINDOW_MESSAGE = "This model's maximum context length is 4096 tokens."
|
||||
CONTENT_POLICY_MESSAGE = (
|
||||
'{"error": {"type": "invalid_request_error", "code": "content_policy_violation"}}'
|
||||
|
|
|
|||
|
|
@ -5225,6 +5225,197 @@ async def test_restore_correlation_context_works_across_asyncio_task_boundary():
|
|||
session_id_var.set("")
|
||||
|
||||
|
||||
class TestNonInferenceCallTypesAreNotBilled:
|
||||
"""A retrieved response replays the usage of the call that created it, so pricing a read
|
||||
of it double bills the same tokens. Regression tests for LIT-5602."""
|
||||
|
||||
RETRIEVED_RESPONSE_USAGE = {"input_tokens": 4000, "output_tokens": 2000, "total_tokens": 6000}
|
||||
|
||||
BACKGROUND_POLL_METADATA = {"internal_call_origin": "background_response_cost_poll"}
|
||||
|
||||
def _logging_obj(self, call_type: str, litellm_metadata: dict | None = None):
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
||||
obj = LiteLLMLoggingObj(
|
||||
model="gpt-4o",
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type=call_type,
|
||||
start_time=time.time(),
|
||||
litellm_call_id=f"lit5602-{call_type}",
|
||||
function_id="fn-lit5602",
|
||||
)
|
||||
obj.update_environment_variables(
|
||||
model="gpt-4o",
|
||||
user="",
|
||||
optional_params={},
|
||||
litellm_params={
|
||||
"api_base": "",
|
||||
"custom_llm_provider": "openai",
|
||||
"litellm_metadata": litellm_metadata or {},
|
||||
},
|
||||
)
|
||||
return obj
|
||||
|
||||
def _retrieved_response(self, background: bool | None = None):
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
|
||||
return ResponsesAPIResponse(
|
||||
id="resp_lit5602",
|
||||
created_at=1234567890,
|
||||
model="gpt-4o",
|
||||
output=[],
|
||||
usage=self.RETRIEVED_RESPONSE_USAGE,
|
||||
background=background,
|
||||
)
|
||||
|
||||
def test_creating_a_response_is_still_priced(self):
|
||||
"""Guards the tests below: the same response object must cost money on the create path."""
|
||||
cost = self._logging_obj("aresponses")._response_cost_calculator(result=self._retrieved_response())
|
||||
assert cost is not None and cost > 0
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"call_type",
|
||||
[
|
||||
"aget_responses",
|
||||
"adelete_responses",
|
||||
"acancel_responses",
|
||||
"alist_input_items",
|
||||
"avector_store_delete",
|
||||
"avector_store_file_content",
|
||||
"avector_store_file_delete",
|
||||
],
|
||||
)
|
||||
def test_read_and_management_calls_cost_nothing(self, call_type):
|
||||
cost = self._logging_obj(call_type)._response_cost_calculator(result=self._retrieved_response())
|
||||
assert cost == 0.0
|
||||
|
||||
def test_retrieved_usage_is_not_re_reported_in_standard_logging_payload(self):
|
||||
from litellm.litellm_core_utils.litellm_logging import (
|
||||
get_standard_logging_object_payload,
|
||||
)
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
logging_obj = self._logging_obj("aget_responses")
|
||||
now = datetime.now()
|
||||
payload = get_standard_logging_object_payload(
|
||||
kwargs={
|
||||
"litellm_call_id": "lit5602-payload",
|
||||
"model": "gpt-4o",
|
||||
"call_type": "aget_responses",
|
||||
"litellm_params": {},
|
||||
},
|
||||
init_response_obj=self._retrieved_response(),
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
logging_obj=logging_obj,
|
||||
status="success",
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["prompt_tokens"] == 0
|
||||
assert payload["completion_tokens"] == 0
|
||||
assert payload["total_tokens"] == 0
|
||||
assert payload["response_cost"] == 0.0
|
||||
|
||||
def test_background_cost_poll_read_is_still_priced(self):
|
||||
"""A background create returns queued with no usage, so the poller's read carries the job's
|
||||
only billable usage. Zeroing it there means background jobs are never billed."""
|
||||
cost = self._logging_obj(
|
||||
"aget_responses", litellm_metadata=self.BACKGROUND_POLL_METADATA
|
||||
)._response_cost_calculator(result=self._retrieved_response())
|
||||
assert cost is not None and cost > 0
|
||||
|
||||
def test_background_cost_poll_reports_usage_in_standard_logging_payload(self):
|
||||
from datetime import datetime
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import (
|
||||
get_standard_logging_object_payload,
|
||||
)
|
||||
|
||||
now = datetime.now()
|
||||
payload = get_standard_logging_object_payload(
|
||||
kwargs={
|
||||
"litellm_call_id": "lit5602-poll-payload",
|
||||
"model": "gpt-4o",
|
||||
"call_type": "aget_responses",
|
||||
"litellm_params": {"litellm_metadata": self.BACKGROUND_POLL_METADATA},
|
||||
},
|
||||
init_response_obj=self._retrieved_response(),
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
logging_obj=self._logging_obj(
|
||||
"aget_responses", litellm_metadata=self.BACKGROUND_POLL_METADATA
|
||||
),
|
||||
status="success",
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["total_tokens"] == 6000
|
||||
|
||||
def test_reading_a_background_response_is_still_priced(self):
|
||||
"""A background create answers queued with no usage at all, so whoever reads the finished
|
||||
job is the first and only caller to see its tokens. Zeroing that read bills the job nothing."""
|
||||
cost = self._logging_obj("aget_responses")._response_cost_calculator(
|
||||
result=self._retrieved_response(background=True)
|
||||
)
|
||||
assert cost is not None and cost > 0
|
||||
|
||||
def test_reading_a_background_response_reports_usage_in_standard_logging_payload(self):
|
||||
from datetime import datetime
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import (
|
||||
get_standard_logging_object_payload,
|
||||
)
|
||||
|
||||
now = datetime.now()
|
||||
payload = get_standard_logging_object_payload(
|
||||
kwargs={
|
||||
"litellm_call_id": "lit5602-background-payload",
|
||||
"model": "gpt-4o",
|
||||
"call_type": "aget_responses",
|
||||
"litellm_params": {},
|
||||
},
|
||||
init_response_obj=self._retrieved_response(background=True),
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
logging_obj=self._logging_obj("aget_responses"),
|
||||
status="success",
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["total_tokens"] == 6000
|
||||
|
||||
def test_reading_a_foreground_response_is_still_free(self):
|
||||
"""Guards the test above against a blanket exemption: an explicit background=false read was
|
||||
already billed by its create and must stay at zero."""
|
||||
cost = self._logging_obj("aget_responses")._response_cost_calculator(
|
||||
result=self._retrieved_response(background=False)
|
||||
)
|
||||
assert cost == 0.0
|
||||
|
||||
def _read_call_messages(self):
|
||||
logging_obj, _ = litellm.utils.function_setup(
|
||||
original_function="aget_responses",
|
||||
rules_obj=litellm.utils.Rules(),
|
||||
start_time=time.time(),
|
||||
**{"litellm_call_id": "lit5602-setup", "response_id": "resp_lit5602"},
|
||||
)
|
||||
return logging_obj.model_call_details["messages"]
|
||||
|
||||
def test_read_calls_do_not_log_a_placeholder_chat_message(self):
|
||||
assert self._read_call_messages() == []
|
||||
|
||||
def test_read_call_messages_survive_a_logger_that_walks_them(self):
|
||||
"""Loggers reach into this value expecting a chat history and branch on it being a list.
|
||||
An empty list reads as no messages; a tuple matches no branch and crashes the success hook,
|
||||
and None is not iterable where other loggers walk it."""
|
||||
from litellm.integrations.lunary import parse_messages
|
||||
|
||||
assert parse_messages(self._read_call_messages()) == []
|
||||
|
||||
|
||||
def _build_success_payload(logging_obj, kwargs):
|
||||
import datetime
|
||||
|
||||
|
|
|
|||
|
|
@ -2957,3 +2957,65 @@ async def test_log_messages_routes_async_logging_through_bounded_worker():
|
|||
logging_obj.success_handler.assert_not_called()
|
||||
# the bare create_task path must no longer be used for success logging
|
||||
mock_create_task.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_config_path_captures_transcription_usage():
|
||||
"""A transcription.completed event with usage from the provider transform must
|
||||
land in the logged messages so realtime cost calculation can bill it."""
|
||||
from typing import Final
|
||||
|
||||
from litellm.types.realtime import RealtimeInputAudioTranscriptionUsage, RealtimeResponseTypedDict
|
||||
|
||||
client_ws: Final = MagicMock()
|
||||
client_ws.send_text = AsyncMock()
|
||||
backend_ws: Final = MagicMock()
|
||||
backend_ws.send = AsyncMock()
|
||||
logging_obj: Final = MagicMock()
|
||||
|
||||
usage: Final[RealtimeInputAudioTranscriptionUsage] = {
|
||||
"type": "tokens",
|
||||
"input_tokens": 50,
|
||||
"output_tokens": 6,
|
||||
"total_tokens": 56,
|
||||
"input_token_details": {"text_tokens": 0, "audio_tokens": 50},
|
||||
}
|
||||
transform_output: Final[RealtimeResponseTypedDict] = {
|
||||
"response": {
|
||||
"type": "conversation.item.input_audio_transcription.completed",
|
||||
"event_id": "event_1",
|
||||
"transcript": "ahoy",
|
||||
"item_id": "item_1",
|
||||
"content_index": 0,
|
||||
"usage": usage,
|
||||
},
|
||||
"current_output_item_id": None,
|
||||
"current_response_id": None,
|
||||
"current_delta_chunks": None,
|
||||
"current_conversation_id": None,
|
||||
"current_item_chunks": None,
|
||||
"current_delta_type": None,
|
||||
"session_configuration_request": None,
|
||||
}
|
||||
provider_config: Final = MagicMock()
|
||||
provider_config.transform_realtime_request = MagicMock(return_value=())
|
||||
provider_config.transform_realtime_response = MagicMock(return_value=transform_output)
|
||||
|
||||
streaming: Final = RealTimeStreaming(
|
||||
client_ws,
|
||||
backend_ws,
|
||||
logging_obj,
|
||||
provider_config=provider_config,
|
||||
model="gemini-3.5-transcribe-live",
|
||||
)
|
||||
|
||||
await streaming._handle_provider_config_message("{}")
|
||||
|
||||
usage_events: Final = tuple(
|
||||
message
|
||||
for message in streaming.messages
|
||||
if isinstance(message, dict)
|
||||
and message.get("type") == "conversation.item.input_audio_transcription.completed"
|
||||
and message.get("usage") == usage
|
||||
)
|
||||
assert len(usage_events) == 1
|
||||
|
|
|
|||
|
|
@ -3997,3 +3997,98 @@ def test_translate_anthropic_messages_to_openai_carries_midturn_system_prompt_ca
|
|||
assert result == [
|
||||
{"role": "system", "content": [{"type": "text", "text": "fix", "prompt_cache_breakpoint": explicit}]}
|
||||
]
|
||||
|
||||
|
||||
def _openai_response_with_usage(usage: Usage) -> ModelResponse:
|
||||
return ModelResponse(
|
||||
id="resp_web_search",
|
||||
model="gemini-3-flash-preview",
|
||||
choices=[
|
||||
Choices(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
message=Message(role="assistant", content="searched"),
|
||||
)
|
||||
],
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
def test_translate_openai_response_to_anthropic_maps_gemini_web_search_usage():
|
||||
from litellm.types.utils import PromptTokensDetailsWrapper
|
||||
|
||||
usage = Usage(
|
||||
prompt_tokens=385,
|
||||
completion_tokens=566,
|
||||
total_tokens=951,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=385, web_search_requests=2),
|
||||
)
|
||||
|
||||
anthropic_response = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(
|
||||
response=_openai_response_with_usage(usage)
|
||||
)
|
||||
|
||||
assert anthropic_response["usage"]["server_tool_use"] == {"web_search_requests": 2}
|
||||
|
||||
|
||||
def test_translate_openai_response_to_anthropic_maps_server_tool_use_web_search_usage():
|
||||
from litellm.types.utils import ServerToolUse
|
||||
|
||||
usage = Usage(
|
||||
prompt_tokens=100,
|
||||
completion_tokens=40,
|
||||
total_tokens=140,
|
||||
server_tool_use=ServerToolUse(web_search_requests=3),
|
||||
)
|
||||
|
||||
anthropic_response = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(
|
||||
response=_openai_response_with_usage(usage)
|
||||
)
|
||||
|
||||
assert anthropic_response["usage"]["server_tool_use"] == {"web_search_requests": 3}
|
||||
|
||||
|
||||
def test_translate_openai_response_to_anthropic_omits_server_tool_use_without_web_search():
|
||||
usage = Usage(prompt_tokens=100, completion_tokens=40, total_tokens=140)
|
||||
|
||||
anthropic_response = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(
|
||||
response=_openai_response_with_usage(usage)
|
||||
)
|
||||
|
||||
assert "server_tool_use" not in anthropic_response["usage"]
|
||||
|
||||
|
||||
def test_completion_cost_on_translated_anthropic_response_includes_web_search():
|
||||
from litellm.types.utils import PromptTokensDetailsWrapper
|
||||
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
with_search = adapter.translate_openai_response_to_anthropic(
|
||||
response=_openai_response_with_usage(
|
||||
Usage(
|
||||
prompt_tokens=385,
|
||||
completion_tokens=566,
|
||||
total_tokens=951,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=385, web_search_requests=2),
|
||||
)
|
||||
)
|
||||
)
|
||||
without_search = adapter.translate_openai_response_to_anthropic(
|
||||
response=_openai_response_with_usage(Usage(prompt_tokens=385, completion_tokens=566, total_tokens=951))
|
||||
)
|
||||
|
||||
cost_with_search = litellm.completion_cost(
|
||||
completion_response=with_search,
|
||||
model="gemini/gemini-3-flash-preview",
|
||||
call_type="anthropic_messages",
|
||||
)
|
||||
cost_without_search = litellm.completion_cost(
|
||||
completion_response=without_search,
|
||||
model="gemini/gemini-3-flash-preview",
|
||||
call_type="anthropic_messages",
|
||||
)
|
||||
|
||||
per_query_cost = litellm.model_cost["gemini/gemini-3-flash-preview"]["search_context_cost_per_query"][
|
||||
"search_context_size_medium"
|
||||
]
|
||||
assert per_query_cost > 0
|
||||
assert cost_with_search - cost_without_search == pytest.approx(2 * per_query_cost)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@ from litellm.anthropic_interface import messages
|
|||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
from litellm.types.utils import Delta, ModelResponse, StreamingChoices
|
||||
from litellm.types.utils import (
|
||||
Delta,
|
||||
ModelResponse,
|
||||
StandardLoggingPayloadErrorInformation,
|
||||
StreamingChoices,
|
||||
)
|
||||
|
||||
|
||||
def test_anthropic_experimental_pass_through_messages_handler():
|
||||
|
|
@ -1292,7 +1297,7 @@ class TestMessagesStreamingSuccessLogging:
|
|||
class _FailureCapture(CustomLogger):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.error_information: List[Dict[str, Any]] = []
|
||||
self.error_information: list[StandardLoggingPayloadErrorInformation] = []
|
||||
|
||||
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
||||
payload = kwargs.get("standard_logging_object") or {}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,8 @@ See https://github.com/BerriAI/litellm/issues/26153.
|
|||
|
||||
import pytest
|
||||
|
||||
|
||||
from litellm.llms.anthropic.cost_calculation import (
|
||||
_get_web_search_requests,
|
||||
get_cost_for_anthropic_web_search,
|
||||
)
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import get_web_search_requests
|
||||
from litellm.llms.anthropic.cost_calculation import get_cost_for_anthropic_web_search
|
||||
from litellm.types.utils import ModelInfo, ServerToolUse
|
||||
|
||||
|
||||
|
|
@ -33,19 +30,19 @@ def _make_model_info(cost_per_query: float = 0.01) -> ModelInfo:
|
|||
|
||||
|
||||
def test_get_web_search_requests_handles_none():
|
||||
assert _get_web_search_requests(None) is None
|
||||
assert get_web_search_requests(None) is None
|
||||
|
||||
|
||||
def test_get_web_search_requests_handles_dict():
|
||||
assert _get_web_search_requests({"web_search_requests": 4}) == 4
|
||||
assert get_web_search_requests({"web_search_requests": 4}) == 4
|
||||
|
||||
|
||||
def test_get_web_search_requests_handles_dict_missing_key():
|
||||
assert _get_web_search_requests({}) is None
|
||||
assert get_web_search_requests({}) is None
|
||||
|
||||
|
||||
def test_get_web_search_requests_handles_pydantic():
|
||||
assert _get_web_search_requests(ServerToolUse(web_search_requests=2)) == 2
|
||||
assert get_web_search_requests(ServerToolUse(web_search_requests=2)) == 2
|
||||
|
||||
|
||||
def test_get_cost_for_anthropic_web_search_with_dict_server_tool_use():
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import pytest
|
|||
|
||||
import litellm
|
||||
from litellm.llms.bedrock.base_aws_llm import Boto3CredentialsInfo
|
||||
from litellm.llms.bedrock.rerank.handler import BedrockRerankHandler
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
|
||||
# Mock response for Bedrock rerank
|
||||
|
|
@ -402,6 +403,66 @@ def test_bedrock_rerank_extra_headers_and_headers_merge():
|
|||
pytest.fail(f"Failed to merge and forward headers: {str(e)}")
|
||||
|
||||
|
||||
def test_bedrock_rerank_forwarded_headers_excluded_from_sigv4_signature():
|
||||
"""
|
||||
A forwarded header like x-forwarded-for can be rewritten between LiteLLM
|
||||
signing the request and AWS receiving it (e.g. by an intermediate load
|
||||
balancer), which invalidates the signature if that header was part of
|
||||
the signed set. It must still reach Bedrock, just unsigned.
|
||||
"""
|
||||
handler = BedrockRerankHandler()
|
||||
|
||||
prepared_request = handler._prepare_request(
|
||||
model="cohere.rerank-v3-5:0",
|
||||
api_base=None,
|
||||
extra_headers={"x-forwarded-for": "203.0.113.5"},
|
||||
data={"query": test_query, "documents": test_documents},
|
||||
optional_params={
|
||||
"aws_access_key_id": "test-access-key",
|
||||
"aws_secret_access_key": "test-secret-key",
|
||||
"aws_region_name": "us-east-1",
|
||||
},
|
||||
)
|
||||
|
||||
headers = prepared_request["prepped"].headers
|
||||
signed_headers = headers["Authorization"].split("SignedHeaders=")[1].split(",")[0].split(";")
|
||||
|
||||
assert "x-forwarded-for" not in signed_headers, (
|
||||
f"x-forwarded-for must not be part of the SigV4 signature, got SignedHeaders={signed_headers}"
|
||||
)
|
||||
assert headers["x-forwarded-for"] == "203.0.113.5", "forwarded header must still reach Bedrock, unsigned"
|
||||
|
||||
|
||||
def test_bedrock_rerank_signs_with_sigv4_even_when_bedrock_api_key_is_set(monkeypatch):
|
||||
"""
|
||||
Bedrock API keys are only valid for Bedrock and Bedrock Runtime actions, not for
|
||||
Agents for Amazon Bedrock Runtime ones. Rerank is served by bedrock-agent-runtime,
|
||||
so it has to keep signing with SigV4 even when AWS_BEARER_TOKEN_BEDROCK is set.
|
||||
"""
|
||||
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-api-key")
|
||||
|
||||
handler = BedrockRerankHandler()
|
||||
|
||||
prepared_request = handler._prepare_request(
|
||||
model="cohere.rerank-v3-5:0",
|
||||
api_base=None,
|
||||
extra_headers=None,
|
||||
data={"query": test_query, "documents": test_documents},
|
||||
optional_params={
|
||||
"aws_access_key_id": "test-access-key",
|
||||
"aws_secret_access_key": "test-secret-key",
|
||||
"aws_region_name": "us-east-1",
|
||||
},
|
||||
)
|
||||
|
||||
assert prepared_request["endpoint_url"].startswith("https://bedrock-agent-runtime.")
|
||||
|
||||
authorization = prepared_request["prepped"].headers["Authorization"]
|
||||
assert authorization.startswith("AWS4-HMAC-SHA256"), (
|
||||
f"rerank must sign with SigV4, got Authorization={authorization[:30]}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bedrock_rerank_records_llm_api_duration():
|
||||
"""The bedrock rerank handler must feed httpx timing into the logging obj, so the
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue