Merge pull request #20931 from BerriAI/litellm_oss_staging_02_10_2026

Litellm oss staging 02 10 2026
This commit is contained in:
Sameer Kankute
2026-02-11 16:28:21 +05:30
committed by GitHub
20 changed files with 1729 additions and 91 deletions
+26 -6
View File
@@ -237,17 +237,37 @@ def batch_completion_models_all_responses(*args, **kwargs):
if "model" in kwargs:
kwargs.pop("model")
if "models" in kwargs:
models = kwargs["models"]
kwargs.pop("models")
models = kwargs.pop("models")
else:
raise Exception("'models' param not in kwargs")
if isinstance(models, str):
models = [models]
elif isinstance(models, (list, tuple)):
models = list(models)
else:
raise TypeError("'models' must be a string or list of strings")
if len(models) == 0:
return []
responses = []
with concurrent.futures.ThreadPoolExecutor(max_workers=len(models)) as executor:
for idx, model in enumerate(models):
future = executor.submit(litellm.completion, *args, model=model, **kwargs)
if future.result() is not None:
responses.append(future.result())
futures = [
executor.submit(litellm.completion, *args, model=model, **kwargs)
for model in models
]
for future in futures:
try:
result = future.result()
if result is not None:
responses.append(result)
except Exception as e:
print_verbose(
f"batch_completion_models_all_responses: model request failed: {str(e)}"
)
continue
return responses
+35
View File
@@ -28,6 +28,41 @@ else:
class ArizeLogger(OpenTelemetry):
"""
Arize logger that sends traces to an Arize endpoint.
Creates its own dedicated TracerProvider so it can coexist with the
generic ``otel`` callback (or any other OTEL-based integration) without
fighting over the global ``opentelemetry.trace`` TracerProvider singleton.
"""
def _init_tracing(self, tracer_provider):
"""
Override to always create a *private* TracerProvider for Arize.
See ArizePhoenixLogger._init_tracing for full rationale.
"""
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.trace import SpanKind
if tracer_provider is not None:
self.tracer = tracer_provider.get_tracer("litellm")
self.span_kind = SpanKind
return
provider = TracerProvider(resource=self._get_litellm_resource(self.config))
provider.add_span_processor(self._get_span_processor())
self.tracer = provider.get_tracer("litellm")
self.span_kind = SpanKind
def _init_otel_logger_on_litellm_proxy(self):
"""
Override: Arize should NOT overwrite the proxy's
``open_telemetry_logger``. That attribute is reserved for the
primary ``otel`` callback which handles proxy-level parent spans.
"""
pass
def set_attributes(self, span: Span, kwargs, response_obj: Optional[Any]):
ArizeLogger.set_arize_attributes(span, kwargs, response_obj)
return
+177 -9
View File
@@ -5,43 +5,211 @@ from litellm._logging import verbose_logger
from litellm.integrations.arize import _utils
from litellm.integrations.arize._utils import ArizeOTELAttributes
from litellm.types.integrations.arize_phoenix import ArizePhoenixConfig
from litellm.integrations.opentelemetry import OpenTelemetry
if TYPE_CHECKING:
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.trace import Span as _Span
from opentelemetry.trace import SpanKind
from litellm.integrations.opentelemetry import OpenTelemetry as _OpenTelemetry
from litellm.integrations.opentelemetry import OpenTelemetryConfig as _OpenTelemetryConfig
from litellm.types.integrations.arize import Protocol as _Protocol
Protocol = _Protocol
OpenTelemetryConfig = _OpenTelemetryConfig
Span = Union[_Span, Any]
OpenTelemetry = _OpenTelemetry
else:
Protocol = Any
OpenTelemetryConfig = Any
Span = Any
TracerProvider = Any
SpanKind = Any
# Import OpenTelemetry at runtime
try:
from litellm.integrations.opentelemetry import OpenTelemetry
except ImportError:
OpenTelemetry = None # type: ignore
ARIZE_HOSTED_PHOENIX_ENDPOINT = "https://otlp.arize.com/v1/traces"
class ArizePhoenixLogger(OpenTelemetry):
class ArizePhoenixLogger(OpenTelemetry): # type: ignore
"""
Arize Phoenix logger that sends traces to a Phoenix endpoint.
Creates its own dedicated TracerProvider so it can coexist with the
generic ``otel`` callback (or any other OTEL-based integration) without
fighting over the global ``opentelemetry.trace`` TracerProvider singleton.
"""
def _init_tracing(self, tracer_provider):
"""
Override to always create a *private* TracerProvider for Arize Phoenix.
The base ``OpenTelemetry._init_tracing`` falls back to the global
TracerProvider when one already exists. That causes whichever
integration initialises second to silently reuse the first one's
exporter, so spans only reach one destination.
By creating our own provider we guarantee Arize Phoenix always gets
its own exporter pipeline, regardless of initialisation order.
"""
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.trace import SpanKind
if tracer_provider is not None:
# Explicitly supplied (e.g. in tests) — honour it.
self.tracer = tracer_provider.get_tracer("litellm")
self.span_kind = SpanKind
return
# Always create a dedicated provider — never touch the global one.
provider = TracerProvider(resource=self._get_litellm_resource(self.config))
provider.add_span_processor(self._get_span_processor())
self.tracer = provider.get_tracer("litellm")
self.span_kind = SpanKind
verbose_logger.debug(
"ArizePhoenixLogger: Created dedicated TracerProvider "
"(endpoint=%s, exporter=%s)",
self.config.endpoint,
self.config.exporter,
)
def _init_otel_logger_on_litellm_proxy(self):
"""
Override: Arize Phoenix should NOT overwrite the proxy's
``open_telemetry_logger``. That attribute is reserved for the
primary ``otel`` callback which handles proxy-level parent spans.
"""
pass
def set_attributes(self, span: Span, kwargs, response_obj: Optional[Any]):
ArizePhoenixLogger.set_arize_phoenix_attributes(span, kwargs, response_obj)
return
@staticmethod
def set_arize_phoenix_attributes(span: Span, kwargs, response_obj):
from litellm.integrations.opentelemetry_utils.base_otel_llm_obs_attributes import safe_set_attribute
_utils.set_attributes(span, kwargs, response_obj, ArizeOTELAttributes)
# Set project name on the span for all traces to go to custom Phoenix projects
config = ArizePhoenixLogger.get_arize_phoenix_config()
if config.project_name:
from litellm.integrations.opentelemetry_utils.base_otel_llm_obs_attributes import safe_set_attribute
safe_set_attribute(span, "openinference.project.name", config.project_name)
# Dynamic project name: check metadata first, then fall back to env var config
dynamic_project_name = ArizePhoenixLogger._get_dynamic_project_name(kwargs)
if dynamic_project_name:
safe_set_attribute(span, "openinference.project.name", dynamic_project_name)
else:
# Fall back to static config from env var
config = ArizePhoenixLogger.get_arize_phoenix_config()
if config.project_name:
safe_set_attribute(span, "openinference.project.name", config.project_name)
return
@staticmethod
def _get_dynamic_project_name(kwargs) -> Optional[str]:
"""
Retrieve dynamic Phoenix project name from request metadata.
Users can set `metadata.phoenix_project_name` in their request to route
traces to different Phoenix projects dynamically.
"""
standard_logging_payload = kwargs.get("standard_logging_object")
if isinstance(standard_logging_payload, dict):
metadata = standard_logging_payload.get("metadata")
if isinstance(metadata, dict):
project_name = metadata.get("phoenix_project_name")
if project_name:
return str(project_name)
# Also check litellm_params.metadata for SDK usage
litellm_params = kwargs.get("litellm_params")
if isinstance(litellm_params, dict):
metadata = litellm_params.get("metadata") or {}
else:
metadata = {}
if isinstance(metadata, dict):
project_name = metadata.get("phoenix_project_name")
if project_name:
return str(project_name)
return None
def _handle_success(self, kwargs, response_obj, start_time, end_time):
"""
Override to prevent creating duplicate litellm_request spans when a proxy parent span exists.
ArizePhoenixLogger should reuse the proxy parent span instead of creating a new litellm_request span,
to maintain a shallow span hierarchy as expected by Arize Phoenix.
"""
from opentelemetry.trace import Status, StatusCode
from litellm.secret_managers.main import get_secret_bool
from litellm.integrations.opentelemetry import LITELLM_PROXY_REQUEST_SPAN_NAME
verbose_logger.debug(
"ArizePhoenixLogger: Logging kwargs: %s, OTEL config settings=%s",
kwargs,
self.config,
)
ctx, parent_span = self._get_span_context(kwargs)
# ArizePhoenixLogger NEVER creates a litellm_request span when a proxy parent span exists
# This is different from the base OpenTelemetry behavior which respects USE_OTEL_LITELLM_REQUEST_SPAN
should_create_primary_span = parent_span is None or (
parent_span.name != LITELLM_PROXY_REQUEST_SPAN_NAME
and get_secret_bool("USE_OTEL_LITELLM_REQUEST_SPAN")
)
if should_create_primary_span:
# Create a new litellm_request span
span = self._start_primary_span(
kwargs, response_obj, start_time, end_time, ctx
)
# Raw-request sub-span (if enabled) - child of litellm_request span
self._maybe_log_raw_request(
kwargs, response_obj, start_time, end_time, span
)
# Ensure proxy-request parent span is annotated with the actual operation kind
if (
parent_span is not None
and parent_span.name == LITELLM_PROXY_REQUEST_SPAN_NAME
):
self.set_attributes(parent_span, kwargs, response_obj)
else:
# Do not create primary span (keep hierarchy shallow when parent exists)
span = None
# Only set attributes if the span is still recording (not closed)
# Note: parent_span is guaranteed to be not None here
if parent_span.is_recording():
parent_span.set_status(Status(StatusCode.OK))
self.set_attributes(parent_span, kwargs, response_obj)
# Raw-request as direct child of parent_span
self._maybe_log_raw_request(
kwargs, response_obj, start_time, end_time, parent_span
)
# 3. Guardrail span
self._create_guardrail_span(kwargs=kwargs, context=ctx)
# 4. Metrics & cost recording
self._record_metrics(kwargs, response_obj, start_time, end_time)
# 5. Semantic logs.
if self.config.enable_events:
log_span = span if span is not None else parent_span
if log_span is not None:
self._emit_semantic_logs(kwargs, response_obj, log_span)
# 6. Do NOT end parent span - it should be managed by its creator
# External spans (from Langfuse, user code, HTTP headers, global context) must not be closed by LiteLLM
# However, proxy-created spans should be closed here
if (
parent_span is not None
and parent_span.name == LITELLM_PROXY_REQUEST_SPAN_NAME
):
parent_span.end(end_time=self._to_ns(end_time))
@staticmethod
def get_arize_phoenix_config() -> ArizePhoenixConfig:
"""
@@ -3764,7 +3764,7 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
from litellm.integrations.opentelemetry import OpenTelemetry
for callback in _in_memory_loggers:
if isinstance(callback, OpenTelemetry):
if type(callback) is OpenTelemetry:
return callback # type: ignore
otel_logger = OpenTelemetry(
**_get_custom_logger_settings_from_proxy_server(
@@ -140,9 +140,14 @@ def should_redact_message_logging(model_call_details: dict) -> bool:
metadata_field = get_metadata_variable_name_from_kwargs(litellm_params)
metadata = litellm_params.get(metadata_field, {})
if not isinstance(metadata, dict):
# Fall back: litellm_metadata was None, try metadata
metadata = litellm_params.get("metadata", {})
if not isinstance(metadata, dict):
metadata = {}
# Get headers from the metadata
request_headers = metadata.get("headers", {}) if isinstance(metadata, dict) else {}
request_headers = metadata.get("headers", {})
# Check for headers that explicitly control redaction
if request_headers and bool(
+135 -18
View File
@@ -211,25 +211,13 @@ class BaseAWSLLM:
aws_external_id=aws_external_id,
)
elif aws_role_name is not None:
# Check if we're in IRSA and trying to assume the same role we already have
current_role_arn = os.getenv("AWS_ROLE_ARN")
web_identity_token_file = os.getenv("AWS_WEB_IDENTITY_TOKEN_FILE")
# In IRSA environments, we should skip role assumption if we're already running as the target role
# This is true when:
# 1. We have AWS_ROLE_ARN set (current role)
# 2. We have AWS_WEB_IDENTITY_TOKEN_FILE set (IRSA environment)
# 3. The current role matches the requested role
if (
current_role_arn
and web_identity_token_file
and current_role_arn == aws_role_name
):
# Check if we're already running as the target role and can skip assumption
# This handles IRSA (EKS), ECS task roles, and EC2 instance profiles
if self._is_already_running_as_role(aws_role_name, ssl_verify=ssl_verify):
verbose_logger.debug(
"Using IRSA same-role optimization: calling _auth_with_env_vars"
"Already running as target role %s, using ambient credentials",
aws_role_name,
)
# We're already running as this role via IRSA, no need to assume it again
# Use the default boto3 credentials (which will use the IRSA credentials)
credentials, _cache_ttl = self._auth_with_env_vars()
else:
verbose_logger.debug(
@@ -553,6 +541,107 @@ class BaseAWSLLM:
aws_region_name = "us-west-2"
return aws_region_name
@staticmethod
def _parse_arn_account_and_role_name(
arn: str,
) -> Optional[Tuple[str, str, str]]:
"""
Parse an ARN and return (partition, account_id, role_name).
Handles:
- arn:aws:iam::123456789012:role/MyRole
- arn:aws:iam::123456789012:role/path/to/MyRole
- arn:aws:sts::123456789012:assumed-role/MyRole/session-name
Returns None if the ARN cannot be parsed.
"""
# ARN format: arn:PARTITION:SERVICE:REGION:ACCOUNT:RESOURCE
parts = arn.split(":")
if len(parts) < 6 or parts[0] != "arn":
return None
partition = parts[1] # e.g. "aws", "aws-cn", "aws-us-gov"
account_id = parts[4]
resource = ":".join(parts[5:]) # rejoin in case resource contains colons
if resource.startswith("role/"):
# arn:aws:iam::ACCOUNT:role/[path/]ROLE_NAME
role_name = resource.split("/")[-1]
elif resource.startswith("assumed-role/"):
# arn:aws:sts::ACCOUNT:assumed-role/ROLE_NAME/SESSION
role_parts = resource.split("/")
if len(role_parts) >= 2:
role_name = role_parts[1]
else:
return None
else:
return None
return partition, account_id, role_name
def _is_already_running_as_role(
self,
aws_role_name: str,
ssl_verify: Optional[Union[bool, str]] = None,
) -> bool:
"""
Check if the current environment is already running as the target IAM role.
This handles multiple AWS environments:
- IRSA (EKS): AWS_ROLE_ARN + AWS_WEB_IDENTITY_TOKEN_FILE are set
- ECS task roles: Uses sts:GetCallerIdentity to check current role ARN
- EC2 instance profiles: Uses sts:GetCallerIdentity to check current role ARN
Compares partition, account ID, and role name to avoid cross-account
false matches.
Returns True if the current identity matches the target role, meaning
we can skip sts:AssumeRole and use ambient credentials directly.
"""
target_parsed = self._parse_arn_account_and_role_name(aws_role_name)
if target_parsed is None:
return False
target_partition, target_account, target_role = target_parsed
# Fast path: IRSA environment check (no API call needed)
current_role_arn = os.getenv("AWS_ROLE_ARN")
web_identity_token_file = os.getenv("AWS_WEB_IDENTITY_TOKEN_FILE")
if current_role_arn and web_identity_token_file:
return current_role_arn == aws_role_name
# For ECS/EC2: call sts:GetCallerIdentity to check if already running as the role
try:
import boto3
with tracer.trace("boto3.client(sts).get_caller_identity"):
sts_client = boto3.client(
"sts", verify=self._get_ssl_verify(ssl_verify)
)
identity = sts_client.get_caller_identity()
caller_arn = identity.get("Arn", "")
caller_parsed = self._parse_arn_account_and_role_name(caller_arn)
if caller_parsed is not None:
caller_partition, caller_account, caller_role = caller_parsed
if (
caller_partition == target_partition
and caller_account == target_account
and caller_role == target_role
):
verbose_logger.debug(
"Current identity already matches target role: %s",
aws_role_name,
)
return True
except Exception as e:
verbose_logger.debug(
"Could not determine current role identity: %s", str(e)
)
return False
@tracer.wrap()
def _auth_with_web_identity_token(
self,
@@ -867,7 +956,35 @@ class BaseAWSLLM:
if aws_external_id is not None:
assume_role_params["ExternalId"] = aws_external_id
sts_response = sts_client.assume_role(**assume_role_params)
try:
sts_response = sts_client.assume_role(**assume_role_params)
except Exception as e:
error_str = str(e)
if "AccessDenied" in error_str:
# Only fall back to ambient credentials if we can positively
# confirm the caller is already the target role (same account,
# partition, and role name). This avoids silently using the
# wrong identity when there is a genuine trust-policy or
# permission misconfiguration.
if self._is_already_running_as_role(
aws_role_name, ssl_verify=ssl_verify
):
verbose_logger.warning(
"AssumeRole failed for %s (%s). "
"Caller is already running as this role; "
"falling back to ambient credentials.",
aws_role_name,
error_str,
)
return self._auth_with_env_vars()
# Genuine permission error — re-raise
verbose_logger.error(
"AssumeRole AccessDenied for %s and caller is NOT "
"the same role. Re-raising. Error: %s",
aws_role_name,
error_str,
)
raise
# Extract the credentials from the response and convert to Session Credentials
sts_credentials = sts_response["Credentials"]
@@ -153,7 +153,10 @@ class _PROXY_VirtualKeyModelMaxBudgetLimiter(RouterBudgetLimiting):
"standard_logging_object", None
)
if standard_logging_payload is None:
raise ValueError("standard_logging_payload is required")
verbose_proxy_logger.debug(
"Skipping _PROXY_VirtualKeyModelMaxBudgetLimiter.async_log_success_event: standard_logging_payload is None"
)
return
_litellm_params: dict = kwargs.get("litellm_params", {}) or {}
_metadata: dict = _litellm_params.get("metadata", {}) or {}
@@ -144,6 +144,12 @@ async def image_generation(
litellm_call_id=data.get("litellm_call_id", ""), status="success"
)
)
### CALL HOOKS ### - modify outgoing data (guardrails, otel, etc.)
response = await proxy_logging_obj.post_call_success_hook(
data=data, user_api_key_dict=user_api_key_dict, response=response
)
### RESPONSE HEADERS ###
hidden_params = getattr(response, "_hidden_params", {}) or {}
model_id = hidden_params.get("model_id", None) or ""
+19 -15
View File
@@ -58,7 +58,6 @@ from litellm.litellm_core_utils.core_helpers import (
_get_parent_otel_span_from_kwargs,
get_metadata_variable_name_from_kwargs,
)
from litellm.litellm_core_utils.thread_pool_executor import executor
from litellm.litellm_core_utils.coroutine_checker import coroutine_checker
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.litellm_core_utils.dd_tracing import tracer
@@ -619,11 +618,12 @@ class Router:
self.retry_policy = RetryPolicy(**retry_policy)
elif isinstance(retry_policy, RetryPolicy):
self.retry_policy = retry_policy
verbose_router_logger.info(
"\033[32mRouter Custom Retry Policy Set:\n{}\033[0m".format(
self.retry_policy.model_dump(exclude_none=True)
if self.retry_policy is not None:
verbose_router_logger.info(
"\033[32mRouter Custom Retry Policy Set:\n{}\033[0m".format(
self.retry_policy.model_dump(exclude_none=True)
)
)
)
self.model_group_retry_policy: Optional[
Dict[str, RetryPolicy]
@@ -636,11 +636,12 @@ class Router:
elif isinstance(allowed_fails_policy, AllowedFailsPolicy):
self.allowed_fails_policy = allowed_fails_policy
verbose_router_logger.info(
"\033[32mRouter Custom Allowed Fails Policy Set:\n{}\033[0m".format(
self.allowed_fails_policy.model_dump(exclude_none=True)
if self.allowed_fails_policy is not None:
verbose_router_logger.info(
"\033[32mRouter Custom Allowed Fails Policy Set:\n{}\033[0m".format(
self.allowed_fails_policy.model_dump(exclude_none=True)
)
)
)
self.alerting_config: Optional[AlertingConfig] = alerting_config
@@ -1269,13 +1270,16 @@ class Router:
if silent_model is not None:
# Mirroring traffic to a secondary model
# Use shared thread pool for background calls
executor.submit(
self._silent_experiment_completion,
silent_model,
messages,
**kwargs,
# Use threading.Thread (not ThreadPoolExecutor) - executor.submit()
# requires pickling args, which fails when kwargs contain unpicklable
# objects (e.g. _thread.RLock from OTEL spans, loggers) in deployment.
thread = threading.Thread(
target=self._silent_experiment_completion,
args=(silent_model, messages),
kwargs=kwargs,
daemon=True,
)
thread.start()
self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
kwargs.pop("silent_model", None) # Ensure it's not in kwargs either
+7
View File
@@ -6140,6 +6140,13 @@ def validate_environment( # noqa: PLR0915
if (
"AWS_ACCESS_KEY_ID" in os.environ
and "AWS_SECRET_ACCESS_KEY" in os.environ
) or (
# IAM role, profile, or web identity auth don't require access keys
"AWS_ROLE_ARN" in os.environ
or "AWS_PROFILE" in os.environ
or "AWS_WEB_IDENTITY_TOKEN_FILE" in os.environ
or "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" in os.environ # ECS task role
or "AWS_CONTAINER_CREDENTIALS_FULL_URI" in os.environ # ECS/Fargate full URI credential delivery
):
keys_in_environment = True
else:
@@ -0,0 +1,118 @@
import concurrent.futures
import litellm
from litellm.batch_completion.main import batch_completion_models_all_responses
def test_batch_completion_models_all_responses_submits_before_waiting(monkeypatch):
"""
Regression test for issue #20704.
Ensures all model calls are submitted to the thread pool before waiting on results.
"""
models = ["model-a", "model-b", "model-c"]
called_models = []
class _AssertingFuture:
def __init__(self, result, executor, expected_submissions):
self._result = result
self._executor = executor
self._expected_submissions = expected_submissions
def result(self):
if self._executor.submit_count != self._expected_submissions:
raise AssertionError("Not all model calls were submitted before waiting")
return self._result
class _RecordingThreadPoolExecutor:
def __init__(self, max_workers, *args, **kwargs):
self.max_workers = max_workers
self.submit_count = 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def submit(self, fn, *args, **kwargs):
self.submit_count += 1
result = fn(*args, **kwargs)
return _AssertingFuture(
result=result,
executor=self,
expected_submissions=len(models),
)
def _mock_completion(*args, model, **kwargs):
called_models.append(model)
return {"model": model}
monkeypatch.setattr(litellm, "completion", _mock_completion)
monkeypatch.setattr(
concurrent.futures, "ThreadPoolExecutor", _RecordingThreadPoolExecutor
)
responses = batch_completion_models_all_responses(
models=models,
messages=[{"role": "user", "content": "hello"}],
)
assert sorted(called_models) == sorted(models)
assert len(responses) == len(models)
assert sorted(response["model"] for response in responses) == sorted(models)
def test_batch_completion_models_all_responses_continues_on_model_error(monkeypatch):
models = ["model-a", "model-error", "model-b"]
def _mock_completion(*args, model, **kwargs):
if model == "model-error":
raise RuntimeError("simulated model failure")
return {"model": model}
monkeypatch.setattr(litellm, "completion", _mock_completion)
responses = batch_completion_models_all_responses(
models=models,
messages=[{"role": "user", "content": "hello"}],
)
assert len(responses) == 2
assert sorted(response["model"] for response in responses) == ["model-a", "model-b"]
def test_batch_completion_models_all_responses_returns_empty_for_empty_models(monkeypatch):
called = False
def _mock_completion(*args, model, **kwargs):
nonlocal called
called = True
return {"model": model}
monkeypatch.setattr(litellm, "completion", _mock_completion)
responses = batch_completion_models_all_responses(
models=[],
messages=[{"role": "user", "content": "hello"}],
)
assert responses == []
assert called is False
def test_batch_completion_models_all_responses_accepts_single_model_string(monkeypatch):
called_models = []
def _mock_completion(*args, model, **kwargs):
called_models.append(model)
return {"model": model}
monkeypatch.setattr(litellm, "completion", _mock_completion)
responses = batch_completion_models_all_responses(
models="model-a",
messages=[{"role": "user", "content": "hello"}],
)
assert called_models == ["model-a"]
assert responses == [{"model": "model-a"}]
@@ -452,16 +452,13 @@ async def test_redaction_with_metadata_completion_api():
litellm.callbacks = [test_custom_logger]
# When metadata is passed, the system uses get_metadata_variable_name_from_kwargs
# to determine which field to check
# to determine which field to check. No headers means redaction should happen
# based on the global setting (litellm.turn_off_message_logging = True)
response = await litellm.acompletion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "hi"}],
mock_response="hello",
metadata={
"headers": {
"litellm-disable-message-redaction": "true"
}
}
metadata={}
)
await asyncio.sleep(1)
@@ -263,10 +263,18 @@ async def test_arize_phoenix_adds_openinference_kind_and_avoids_duplicate_litell
Ensure Arize Phoenix spans include OpenInference span kind and do not create
a duplicate litellm_request span when a proxy parent span is already active.
"""
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
exporter.clear()
litellm.logging_callback_manager._reset_all_callbacks()
# Set up a global TracerProvider so we can create valid spans
# This simulates the proxy server's TracerProvider
global_provider = TracerProvider()
global_provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(global_provider)
otel_logger = ArizePhoenixLogger(config=OpenTelemetryConfig(exporter=exporter))
litellm.callbacks = [otel_logger]
litellm.success_callback = []
@@ -0,0 +1,169 @@
"""
Tests that Arize Phoenix / Arize and the generic ``otel`` callback can
coexist, each sending spans to their own independent exporter.
Covers the three root-cause fixes:
1. ArizePhoenixLogger / ArizeLogger create *dedicated* TracerProviders.
2. The ``otel`` dedup check does NOT match Arize subclasses.
3. Arize loggers do NOT overwrite ``proxy_server.open_telemetry_logger``.
"""
import unittest
from unittest.mock import patch
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_otel_logger(exporter: InMemorySpanExporter) -> OpenTelemetry:
"""Create a generic ``otel`` callback backed by an in-memory exporter.
We build a dedicated TracerProvider explicitly so the test is isolated
from whatever global provider state may exist.
"""
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
config = OpenTelemetryConfig(exporter=exporter)
return OpenTelemetry(config=config, callback_name="otel", tracer_provider=provider)
def _make_arize_phoenix_logger(exporter: InMemorySpanExporter):
"""Create an ``arize_phoenix`` callback backed by an in-memory exporter.
ArizePhoenixLogger._init_tracing creates its own TracerProvider, so we
pass the exporter via config and let it build the provider internally.
"""
from litellm.integrations.arize.arize_phoenix import ArizePhoenixLogger
config = OpenTelemetryConfig(exporter=exporter)
return ArizePhoenixLogger(config=config, callback_name="arize_phoenix")
def _make_arize_logger(exporter: InMemorySpanExporter):
"""Create an ``arize`` callback backed by an in-memory exporter.
ArizeLogger._init_tracing creates its own TracerProvider, so we pass
the exporter via config and let it build the provider internally.
"""
from litellm.integrations.arize.arize import ArizeLogger
config = OpenTelemetryConfig(exporter=exporter)
return ArizeLogger(config=config, callback_name="arize")
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestIndependentTracerProviders(unittest.TestCase):
"""Each integration must get its own TracerProvider so spans go to the right exporter."""
def test_otel_and_arize_phoenix_have_different_tracer_providers(self):
otel_exporter = InMemorySpanExporter()
phoenix_exporter = InMemorySpanExporter()
otel_logger = _make_otel_logger(otel_exporter)
phoenix_logger = _make_arize_phoenix_logger(phoenix_exporter)
# The tracers must come from different providers
assert otel_logger.tracer is not phoenix_logger.tracer
def test_otel_and_arize_have_different_tracer_providers(self):
otel_exporter = InMemorySpanExporter()
arize_exporter = InMemorySpanExporter()
otel_logger = _make_otel_logger(otel_exporter)
arize_logger = _make_arize_logger(arize_exporter)
assert otel_logger.tracer is not arize_logger.tracer
def test_arize_phoenix_and_arize_have_different_tracer_providers(self):
phoenix_exporter = InMemorySpanExporter()
arize_exporter = InMemorySpanExporter()
phoenix_logger = _make_arize_phoenix_logger(phoenix_exporter)
arize_logger = _make_arize_logger(arize_exporter)
assert phoenix_logger.tracer is not arize_logger.tracer
class TestSpansRoutedToCorrectExporter(unittest.TestCase):
"""Spans created by each logger must land in its own exporter, not the other's."""
def test_spans_go_to_respective_exporters(self):
otel_exporter = InMemorySpanExporter()
phoenix_exporter = InMemorySpanExporter()
otel_logger = _make_otel_logger(otel_exporter)
phoenix_logger = _make_arize_phoenix_logger(phoenix_exporter)
# Create a span on each — SimpleSpanProcessor exports synchronously on end()
otel_span = otel_logger.tracer.start_span("otel_test_span")
otel_span.end()
phoenix_span = phoenix_logger.tracer.start_span("phoenix_test_span")
phoenix_span.end()
# Read spans *before* shutdown (shutdown clears the in-memory store)
otel_span_names = [s.name for s in otel_exporter.get_finished_spans()]
phoenix_span_names = [s.name for s in phoenix_exporter.get_finished_spans()]
assert "otel_test_span" in otel_span_names
assert "phoenix_test_span" not in otel_span_names
assert "phoenix_test_span" in phoenix_span_names
assert "otel_test_span" not in phoenix_span_names
class TestOtelDedupCheck(unittest.TestCase):
"""The ``otel`` callback dedup must use exact type check, not isinstance."""
def test_arize_phoenix_logger_is_not_matched_by_otel_dedup(self):
from litellm.integrations.arize.arize_phoenix import ArizePhoenixLogger
phoenix_logger = _make_arize_phoenix_logger(InMemorySpanExporter())
# isinstance would match — but type() must not
assert isinstance(phoenix_logger, OpenTelemetry)
assert type(phoenix_logger) is not OpenTelemetry
def test_arize_logger_is_not_matched_by_otel_dedup(self):
from litellm.integrations.arize.arize import ArizeLogger
arize_logger = _make_arize_logger(InMemorySpanExporter())
assert isinstance(arize_logger, OpenTelemetry)
assert type(arize_logger) is not OpenTelemetry
def test_otel_logger_matches_own_dedup(self):
otel_logger = _make_otel_logger(InMemorySpanExporter())
assert type(otel_logger) is OpenTelemetry
class TestProxyLoggerNotOverwritten(unittest.TestCase):
"""Arize / Phoenix must not overwrite ``proxy_server.open_telemetry_logger``."""
@patch("litellm.proxy.proxy_server.open_telemetry_logger", None)
def test_arize_phoenix_does_not_set_proxy_otel_logger(self):
from litellm.proxy import proxy_server
_make_arize_phoenix_logger(InMemorySpanExporter())
assert proxy_server.open_telemetry_logger is None
@patch("litellm.proxy.proxy_server.open_telemetry_logger", None)
def test_arize_does_not_set_proxy_otel_logger(self):
from litellm.proxy import proxy_server
_make_arize_logger(InMemorySpanExporter())
assert proxy_server.open_telemetry_logger is None
if __name__ == "__main__":
unittest.main()
@@ -1,5 +1,5 @@
import unittest
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
@@ -7,6 +7,7 @@ from litellm.integrations.arize.arize_phoenix import (
ArizePhoenixConfig,
ArizePhoenixLogger,
)
from litellm.integrations.arize._utils import ArizeOTELAttributes
class TestArizePhoenixConfig(unittest.TestCase):
@@ -195,5 +196,63 @@ def test_get_arize_phoenix_config_expection_on_missing_api_key(monkeypatch, env_
# ---------------------------------------------------------------------------
# Dynamic project naming from metadata
# ---------------------------------------------------------------------------
class TestGetDynamicProjectName:
"""Tests for _get_dynamic_project_name extraction logic."""
def test_extracts_from_standard_logging_object_metadata(self):
kwargs = {
"standard_logging_object": {
"metadata": {"phoenix_project_name": "my-project"},
}
}
assert ArizePhoenixLogger._get_dynamic_project_name(kwargs) == "my-project"
def test_extracts_from_litellm_params_metadata(self):
kwargs = {
"litellm_params": {
"metadata": {"phoenix_project_name": "sdk-project"},
}
}
assert ArizePhoenixLogger._get_dynamic_project_name(kwargs) == "sdk-project"
def test_returns_none_when_no_metadata(self):
assert ArizePhoenixLogger._get_dynamic_project_name({}) is None
def test_non_dict_standard_logging_object_does_not_raise(self):
"""isinstance(dict) guard prevents AttributeError on non-dict payloads."""
kwargs = {"standard_logging_object": "not-a-dict"}
assert ArizePhoenixLogger._get_dynamic_project_name(kwargs) is None
class TestDynamicProjectNameOnSpan:
"""set_arize_phoenix_attributes sets openinference.project.name on the span."""
@patch.dict("os.environ", {"PHOENIX_PROJECT_NAME": "env-fallback"}, clear=False)
@patch("litellm.integrations.arize._utils.set_attributes")
def test_dynamic_name_sets_span_attribute(self, _mock_set_attrs):
span = MagicMock()
kwargs = {
"standard_logging_object": {
"metadata": {"phoenix_project_name": "dynamic-proj"},
}
}
ArizePhoenixLogger.set_arize_phoenix_attributes(span, kwargs, response_obj=None)
span.set_attribute.assert_called_once_with("openinference.project.name", "dynamic-proj")
@patch.dict("os.environ", {"PHOENIX_PROJECT_NAME": "env-project"}, clear=False)
@patch("litellm.integrations.arize._utils.set_attributes")
def test_falls_back_to_env_var_when_no_dynamic_name(self, _mock_set_attrs):
span = MagicMock()
ArizePhoenixLogger.set_arize_phoenix_attributes(span, {}, response_obj=None)
span.set_attribute.assert_called_once_with("openinference.project.name", "env-project")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,145 @@
"""
Tests for litellm.litellm_core_utils.redact_messages.should_redact_message_logging
Covers the proxy flow where headers arrive in litellm_params["metadata"]["headers"]
but litellm_params["litellm_metadata"] is None.
"""
import pytest
import litellm
from litellm.litellm_core_utils.redact_messages import should_redact_message_logging
@pytest.fixture(autouse=True)
def _reset_global_redaction():
"""Ensure the global setting is off for every test."""
original = litellm.turn_off_message_logging
litellm.turn_off_message_logging = False
yield
litellm.turn_off_message_logging = original
def _make_model_call_details(
metadata_headers=None,
litellm_metadata=None,
metadata=None,
standard_callback_dynamic_params=None,
):
"""Build a model_call_details dict that mimics real proxy/SDK flows."""
litellm_params = {}
if metadata is not None:
litellm_params["metadata"] = metadata
elif metadata_headers is not None:
litellm_params["metadata"] = {"headers": metadata_headers}
else:
litellm_params["metadata"] = {}
# get_litellm_params always sets this key (even when value is None)
litellm_params["litellm_metadata"] = litellm_metadata
details = {"litellm_params": litellm_params}
if standard_callback_dynamic_params is not None:
details["standard_callback_dynamic_params"] = standard_callback_dynamic_params
return details
class TestShouldRedactMessageLogging:
"""Unit tests for should_redact_message_logging()."""
# ---- proxy flow: headers in metadata, litellm_metadata is None ----
def test_enable_redaction_via_x_header_proxy_flow(self):
"""x-litellm-enable-message-redaction header should enable redaction
even when litellm_metadata is None (proxy path)."""
details = _make_model_call_details(
metadata_headers={"x-litellm-enable-message-redaction": "true"},
litellm_metadata=None,
)
assert should_redact_message_logging(details) is True
def test_enable_redaction_via_old_header_proxy_flow(self):
"""litellm-enable-message-redaction header should enable redaction
even when litellm_metadata is None (proxy path)."""
details = _make_model_call_details(
metadata_headers={"litellm-enable-message-redaction": "true"},
litellm_metadata=None,
)
assert should_redact_message_logging(details) is True
def test_disable_redaction_via_header_proxy_flow(self):
"""litellm-disable-message-redaction should suppress redaction
even when global setting is on, and litellm_metadata is None."""
litellm.turn_off_message_logging = True
details = _make_model_call_details(
metadata_headers={"litellm-disable-message-redaction": "true"},
litellm_metadata=None,
)
assert should_redact_message_logging(details) is False
# ---- SDK direct-call flow: headers in litellm_metadata ----
def test_enable_redaction_via_header_in_litellm_metadata(self):
"""Headers inside litellm_metadata (SDK direct call) should work."""
details = _make_model_call_details(
litellm_metadata={"headers": {"x-litellm-enable-message-redaction": "true"}},
)
assert should_redact_message_logging(details) is True
# ---- no headers at all ----
def test_no_headers_defaults_to_global_off(self):
"""Without headers, falls back to global setting (False)."""
details = _make_model_call_details(
metadata_headers=None,
litellm_metadata=None,
)
assert should_redact_message_logging(details) is False
def test_no_headers_global_on(self):
"""Without headers, respects global turn_off_message_logging=True."""
litellm.turn_off_message_logging = True
details = _make_model_call_details(
metadata_headers=None,
litellm_metadata=None,
)
assert should_redact_message_logging(details) is True
# ---- dynamic params take precedence ----
def test_dynamic_param_enables_redaction(self):
"""Dynamic turn_off_message_logging=True should enable redaction."""
details = _make_model_call_details(
metadata_headers={},
litellm_metadata=None,
standard_callback_dynamic_params={"turn_off_message_logging": True},
)
assert should_redact_message_logging(details) is True
def test_dynamic_param_false_overrides_header(self):
"""Dynamic turn_off_message_logging=False should take precedence over enable header."""
details = _make_model_call_details(
metadata_headers={"x-litellm-enable-message-redaction": "true"},
litellm_metadata=None,
standard_callback_dynamic_params={"turn_off_message_logging": False},
)
assert should_redact_message_logging(details) is False
# ---- non-dict metadata safety ----
def test_both_metadata_fields_none(self):
"""When both litellm_metadata and metadata are None, should not raise."""
details = _make_model_call_details(
metadata=None,
litellm_metadata=None,
)
assert should_redact_message_logging(details) is False
def test_both_metadata_fields_none_global_on(self):
"""When both metadata fields are None but global is on, should still return True."""
litellm.turn_off_message_logging = True
details = _make_model_call_details(
metadata=None,
litellm_metadata=None,
)
assert should_redact_message_logging(details) is True
@@ -853,29 +853,99 @@ def test_role_assumption_ttl_calculation():
assert 3500 <= ttl <= 3600 # Allow some variance for test execution time
def test_role_assumption_error_handling():
def test_role_assumption_access_denied_falls_back_when_same_role():
"""
Test that role assumption errors are properly propagated.
Test that when AssumeRole fails with AccessDenied AND the caller is confirmed
to already be running as the target role, we fall back to ambient credentials.
"""
base_aws_llm = BaseAWSLLM()
# Mock the boto3 STS client to raise an exception
# Mock the boto3 STS client to raise AccessDenied
mock_sts_client = MagicMock()
mock_sts_client.assume_role.side_effect = Exception("AccessDenied: User is not authorized to perform sts:AssumeRole")
mock_sts_client.assume_role.side_effect = Exception(
"An error occurred (AccessDenied) when calling the AssumeRole operation: "
"Roles may not be assumed by root accounts."
)
# Mock _auth_with_env_vars to return fallback credentials
mock_creds = MagicMock()
mock_creds.access_key = "fallback-access-key"
mock_creds.secret_key = "fallback-secret-key"
with patch("boto3.client", return_value=mock_sts_client):
with patch.object(
base_aws_llm, "_auth_with_env_vars", return_value=(mock_creds, None)
) as mock_env_auth:
# _is_already_running_as_role returns True => fallback allowed
with patch.object(
base_aws_llm, "_is_already_running_as_role", return_value=True
):
credentials, ttl = base_aws_llm._auth_with_aws_role(
aws_access_key_id=None,
aws_secret_access_key=None,
aws_session_token=None,
aws_role_name="arn:aws:iam::1111111111111:role/UnauthorizedRole",
aws_session_name="error-test-session",
)
# Should have fallen back to env vars
mock_env_auth.assert_called_once()
assert credentials.access_key == "fallback-access-key"
def test_role_assumption_access_denied_raises_when_different_role():
"""
Test that when AssumeRole fails with AccessDenied but the caller is NOT
the same role, the error is re-raised (genuine permission failure).
"""
base_aws_llm = BaseAWSLLM()
mock_sts_client = MagicMock()
mock_sts_client.assume_role.side_effect = Exception(
"An error occurred (AccessDenied) when calling the AssumeRole operation: "
"User is not authorized to perform sts:AssumeRole"
)
with patch("boto3.client", return_value=mock_sts_client):
# _is_already_running_as_role returns False => do NOT fallback
with patch.object(
base_aws_llm, "_is_already_running_as_role", return_value=False
):
with pytest.raises(Exception) as exc_info:
base_aws_llm._auth_with_aws_role(
aws_access_key_id=None,
aws_secret_access_key=None,
aws_session_token=None,
aws_role_name="arn:aws:iam::999999999999:role/CrossAccountRole",
aws_session_name="error-test-session",
)
assert "AccessDenied" in str(exc_info.value)
def test_role_assumption_non_access_denied_error_propagated():
"""
Test that non-AccessDenied errors from AssumeRole are still propagated.
"""
base_aws_llm = BaseAWSLLM()
# Mock the boto3 STS client to raise a non-AccessDenied exception
mock_sts_client = MagicMock()
mock_sts_client.assume_role.side_effect = Exception(
"An error occurred (MalformedPolicyDocument) when calling the AssumeRole operation"
)
with patch("boto3.client", return_value=mock_sts_client):
# Should raise the exception
with pytest.raises(Exception) as exc_info:
base_aws_llm._auth_with_aws_role(
aws_access_key_id=None,
aws_secret_access_key=None,
aws_session_token=None,
aws_role_name="arn:aws:iam::1111111111111:role/UnauthorizedRole",
aws_session_name="error-test-session"
aws_role_name="arn:aws:iam::1111111111111:role/BadPolicyRole",
aws_session_name="error-test-session",
)
assert "AccessDenied" in str(exc_info.value)
assert "MalformedPolicyDocument" in str(exc_info.value)
def test_multiple_role_assumptions_in_sequence():
@@ -1195,3 +1265,251 @@ def test_converse_handler_external_id_extraction():
assert hasattr(mock_get_credentials, 'called_kwargs')
assert "aws_external_id" in mock_get_credentials.called_kwargs
assert mock_get_credentials.called_kwargs["aws_external_id"] == "TestExternalID123"
def test_is_already_running_as_role_irsa_same_role():
"""Test IRSA fast path: when AWS_ROLE_ARN matches target role."""
base_aws_llm = BaseAWSLLM()
with patch.dict(os.environ, {
"AWS_ROLE_ARN": "arn:aws:iam::123456789012:role/MyRole",
"AWS_WEB_IDENTITY_TOKEN_FILE": "/var/run/secrets/token",
}):
assert base_aws_llm._is_already_running_as_role(
"arn:aws:iam::123456789012:role/MyRole"
) is True
def test_is_already_running_as_role_irsa_different_role():
"""Test IRSA fast path: when AWS_ROLE_ARN does NOT match target role."""
base_aws_llm = BaseAWSLLM()
with patch.dict(os.environ, {
"AWS_ROLE_ARN": "arn:aws:iam::123456789012:role/MyRole",
"AWS_WEB_IDENTITY_TOKEN_FILE": "/var/run/secrets/token",
}):
assert base_aws_llm._is_already_running_as_role(
"arn:aws:iam::999999999999:role/OtherRole"
) is False
def test_is_already_running_as_role_ecs_task_role():
"""Test ECS/EC2 path: GetCallerIdentity shows assumed-role matching target."""
base_aws_llm = BaseAWSLLM()
mock_sts_client = MagicMock()
mock_sts_client.get_caller_identity.return_value = {
"Arn": "arn:aws:sts::123456789012:assumed-role/MyEcsTaskRole/ecs-task-id"
}
with patch.dict(os.environ, {}, clear=False):
# Ensure no IRSA env vars
env = {k: v for k, v in os.environ.items() if k not in ("AWS_ROLE_ARN", "AWS_WEB_IDENTITY_TOKEN_FILE")}
with patch.dict(os.environ, env, clear=True):
with patch("boto3.client", return_value=mock_sts_client):
assert base_aws_llm._is_already_running_as_role(
"arn:aws:iam::123456789012:role/MyEcsTaskRole"
) is True
def test_is_already_running_as_role_ecs_different_role():
"""Test ECS/EC2 path: GetCallerIdentity shows a different role."""
base_aws_llm = BaseAWSLLM()
mock_sts_client = MagicMock()
mock_sts_client.get_caller_identity.return_value = {
"Arn": "arn:aws:sts::123456789012:assumed-role/MyEcsTaskRole/ecs-task-id"
}
with patch.dict(os.environ, {}, clear=False):
env = {k: v for k, v in os.environ.items() if k not in ("AWS_ROLE_ARN", "AWS_WEB_IDENTITY_TOKEN_FILE")}
with patch.dict(os.environ, env, clear=True):
with patch("boto3.client", return_value=mock_sts_client):
assert base_aws_llm._is_already_running_as_role(
"arn:aws:iam::999999999999:role/DifferentRole"
) is False
def test_is_already_running_as_role_ecs_role_with_path():
"""Test ECS path with role that has a path prefix (e.g., /service-role/MyRole)."""
base_aws_llm = BaseAWSLLM()
mock_sts_client = MagicMock()
mock_sts_client.get_caller_identity.return_value = {
"Arn": "arn:aws:sts::123456789012:assumed-role/MyEcsTaskRole/ecs-task-id"
}
with patch.dict(os.environ, {}, clear=False):
env = {k: v for k, v in os.environ.items() if k not in ("AWS_ROLE_ARN", "AWS_WEB_IDENTITY_TOKEN_FILE")}
with patch.dict(os.environ, env, clear=True):
with patch("boto3.client", return_value=mock_sts_client):
# Role ARN with path
assert base_aws_llm._is_already_running_as_role(
"arn:aws:iam::123456789012:role/service-role/MyEcsTaskRole"
) is True
def test_is_already_running_as_role_get_caller_identity_fails():
"""Test that when GetCallerIdentity fails, we return False (don't crash)."""
base_aws_llm = BaseAWSLLM()
mock_sts_client = MagicMock()
mock_sts_client.get_caller_identity.side_effect = Exception("No credentials found")
with patch.dict(os.environ, {}, clear=False):
env = {k: v for k, v in os.environ.items() if k not in ("AWS_ROLE_ARN", "AWS_WEB_IDENTITY_TOKEN_FILE")}
with patch.dict(os.environ, env, clear=True):
with patch("boto3.client", return_value=mock_sts_client):
assert base_aws_llm._is_already_running_as_role(
"arn:aws:iam::123456789012:role/SomeRole"
) is False
def test_get_credentials_ecs_same_role_skips_assume_role():
"""
End-to-end test: when running on ECS with the same role as aws_role_name,
get_credentials should use ambient credentials and NOT call AssumeRole.
"""
base_aws_llm = BaseAWSLLM()
mock_creds = MagicMock()
mock_creds.access_key = "ecs-access-key"
mock_creds.secret_key = "ecs-secret-key"
mock_creds.token = "ecs-session-token"
with patch.object(
base_aws_llm,
"_is_already_running_as_role",
return_value=True,
):
with patch.object(
base_aws_llm,
"_auth_with_env_vars",
return_value=(mock_creds, None),
) as mock_env_auth:
with patch.object(
base_aws_llm,
"_auth_with_aws_role",
) as mock_role_auth:
credentials = base_aws_llm.get_credentials(
aws_role_name="arn:aws:iam::123456789012:role/MyEcsTaskRole",
aws_region_name="us-east-1",
)
# Should use env vars, NOT role assumption
mock_env_auth.assert_called_once()
mock_role_auth.assert_not_called()
assert credentials.access_key == "ecs-access-key"
def test_parse_arn_account_and_role_name():
"""Test the ARN parser helper for various ARN formats."""
parse = BaseAWSLLM._parse_arn_account_and_role_name
# Standard IAM role ARN
assert parse("arn:aws:iam::123456789012:role/MyRole") == (
"aws", "123456789012", "MyRole"
)
# IAM role ARN with path
assert parse("arn:aws:iam::123456789012:role/service-role/MyRole") == (
"aws", "123456789012", "MyRole"
)
# Assumed-role ARN (from GetCallerIdentity)
assert parse("arn:aws:sts::123456789012:assumed-role/MyRole/session-id") == (
"aws", "123456789012", "MyRole"
)
# China partition
assert parse("arn:aws-cn:iam::123456789012:role/MyRole") == (
"aws-cn", "123456789012", "MyRole"
)
# GovCloud partition
assert parse("arn:aws-us-gov:iam::123456789012:role/MyRole") == (
"aws-us-gov", "123456789012", "MyRole"
)
# Invalid ARNs
assert parse("not-an-arn") is None
assert parse("arn:aws:iam::123456789012:user/MyUser") is None
assert parse("") is None
def test_is_already_running_as_role_cross_account_same_name():
"""
Test that same role NAME in different accounts does NOT match.
This is the cross-account false-match prevention.
"""
base_aws_llm = BaseAWSLLM()
mock_sts_client = MagicMock()
# Caller is in account 111111111111
mock_sts_client.get_caller_identity.return_value = {
"Arn": "arn:aws:sts::111111111111:assumed-role/MyRole/session-id"
}
with patch.dict(os.environ, {}, clear=False):
env = {k: v for k, v in os.environ.items() if k not in ("AWS_ROLE_ARN", "AWS_WEB_IDENTITY_TOKEN_FILE")}
with patch.dict(os.environ, env, clear=True):
with patch("boto3.client", return_value=mock_sts_client):
# Target is same role name but in account 222222222222
assert base_aws_llm._is_already_running_as_role(
"arn:aws:iam::222222222222:role/MyRole"
) is False
def test_is_already_running_as_role_cross_partition():
"""
Test that same role name + account but different partition does NOT match.
"""
base_aws_llm = BaseAWSLLM()
mock_sts_client = MagicMock()
mock_sts_client.get_caller_identity.return_value = {
"Arn": "arn:aws:sts::123456789012:assumed-role/MyRole/session-id"
}
with patch.dict(os.environ, {}, clear=False):
env = {k: v for k, v in os.environ.items() if k not in ("AWS_ROLE_ARN", "AWS_WEB_IDENTITY_TOKEN_FILE")}
with patch.dict(os.environ, env, clear=True):
with patch("boto3.client", return_value=mock_sts_client):
# Same account and role but aws-cn partition
assert base_aws_llm._is_already_running_as_role(
"arn:aws-cn:iam::123456789012:role/MyRole"
) is False
def test_is_already_running_as_role_invalid_target_arn():
"""
Test that an unparseable target ARN returns False immediately.
"""
base_aws_llm = BaseAWSLLM()
# Should return False without making any API calls
assert base_aws_llm._is_already_running_as_role("not-a-valid-arn") is False
def test_is_already_running_as_role_ssl_verify_passed():
"""
Test that ssl_verify parameter is correctly passed to the STS client.
"""
base_aws_llm = BaseAWSLLM()
mock_sts_client = MagicMock()
mock_sts_client.get_caller_identity.return_value = {
"Arn": "arn:aws:sts::123456789012:assumed-role/MyRole/session-id"
}
with patch.dict(os.environ, {}, clear=False):
env = {k: v for k, v in os.environ.items() if k not in ("AWS_ROLE_ARN", "AWS_WEB_IDENTITY_TOKEN_FILE")}
with patch.dict(os.environ, env, clear=True):
with patch("boto3.client", return_value=mock_sts_client) as mock_boto3_client:
base_aws_llm._is_already_running_as_role(
"arn:aws:iam::123456789012:role/MyRole",
ssl_verify="/path/to/ca-bundle.crt",
)
mock_boto3_client.assert_called_once_with(
"sts", verify="/path/to/ca-bundle.crt"
)
@@ -0,0 +1,293 @@
"""
Tests that guardrails (post_call_success_hook) fire for image generation requests.
The /images/generations endpoint in proxy/image_endpoints/endpoints.py calls
proxy_logging_obj.post_call_success_hook after a successful image generation.
These tests verify:
1. CustomGuardrail.async_post_call_success_hook is invoked for image generation.
2. A guardrail can inspect and transform the image response.
3. A guardrail that raises blocks the response (exception propagates).
"""
import os
import sys
from typing import Any, Optional
from unittest.mock import patch
import pytest
sys.path.insert(0, os.path.abspath("../../../.."))
import litellm
from litellm.caching.caching import DualCache
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.integrations.custom_logger import CustomLogger
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.utils import ProxyLogging
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import ImageObject, ImageResponse
def _make_image_response(**kwargs) -> ImageResponse:
"""Helper to build a minimal ImageResponse for tests."""
return ImageResponse(
data=[ImageObject(url="https://example.com/img.png")],
**kwargs,
)
# ---------------------------------------------------------------------------
# 1. Hook is invoked for image generation responses
# ---------------------------------------------------------------------------
class TrackingGuardrail(CustomGuardrail):
"""Guardrail that records whether it was called and with what args."""
def __init__(self):
super().__init__(
guardrail_name="tracking_guardrail",
default_on=True,
event_hook=GuardrailEventHooks.post_call,
)
self.called = False
self.received_data: Optional[dict] = None
self.received_response: Optional[Any] = None
async def async_post_call_success_hook(
self,
data: dict,
user_api_key_dict: UserAPIKeyAuth,
response: Any,
) -> Any:
self.called = True
self.received_data = data
self.received_response = response
return response
@pytest.mark.asyncio
async def test_post_call_success_hook_invoked_for_image_generation():
"""
Verify that a default-on guardrail's async_post_call_success_hook is
called when ProxyLogging.post_call_success_hook is invoked with an
ImageResponse (the same path used by the /images/generations endpoint).
"""
guardrail = TrackingGuardrail()
image_response = _make_image_response()
with patch("litellm.callbacks", [guardrail]):
proxy_logging = ProxyLogging(user_api_key_cache=DualCache())
data = {"model": "dall-e-3", "prompt": "A sunset over mountains"}
user_api_key_dict = UserAPIKeyAuth(api_key="test-key")
result = await proxy_logging.post_call_success_hook(
data=data,
response=image_response,
user_api_key_dict=user_api_key_dict,
)
assert guardrail.called is True, "Guardrail hook was not invoked for image generation"
assert guardrail.received_data is not None
assert guardrail.received_data["model"] == "dall-e-3"
assert isinstance(guardrail.received_response, ImageResponse)
# The response should be passed through unchanged
assert result is image_response
# ---------------------------------------------------------------------------
# 2. Guardrail can transform image generation response
# ---------------------------------------------------------------------------
class TransformingGuardrail(CustomGuardrail):
"""Guardrail that replaces the image URL in the response."""
def __init__(self):
super().__init__(
guardrail_name="transforming_guardrail",
default_on=True,
event_hook=GuardrailEventHooks.post_call,
)
async def async_post_call_success_hook(
self,
data: dict,
user_api_key_dict: UserAPIKeyAuth,
response: Any,
) -> Any:
# Return a modified image response (e.g., watermarked URL)
return ImageResponse(
data=[ImageObject(url="https://example.com/watermarked.png")],
)
@pytest.mark.asyncio
async def test_guardrail_can_transform_image_response():
"""
Verify that a guardrail can replace the ImageResponse returned to the client.
"""
guardrail = TransformingGuardrail()
original_response = _make_image_response()
with patch("litellm.callbacks", [guardrail]):
proxy_logging = ProxyLogging(user_api_key_cache=DualCache())
data = {"model": "dall-e-3", "prompt": "A sunset"}
user_api_key_dict = UserAPIKeyAuth(api_key="test-key")
result = await proxy_logging.post_call_success_hook(
data=data,
response=original_response,
user_api_key_dict=user_api_key_dict,
)
assert result is not original_response
assert isinstance(result, ImageResponse)
assert result.data[0].url == "https://example.com/watermarked.png"
# ---------------------------------------------------------------------------
# 3. Guardrail that raises blocks the image response
# ---------------------------------------------------------------------------
class BlockingGuardrail(CustomGuardrail):
"""Guardrail that raises on unsafe image prompts."""
def __init__(self):
super().__init__(
guardrail_name="blocking_guardrail",
default_on=True,
event_hook=GuardrailEventHooks.post_call,
)
async def async_post_call_success_hook(
self,
data: dict,
user_api_key_dict: UserAPIKeyAuth,
response: Any,
) -> Any:
raise ValueError("Image content blocked by guardrail")
@pytest.mark.asyncio
async def test_guardrail_exception_propagates_for_image_generation():
"""
Verify that an exception raised in a guardrail's post_call_success_hook
propagates up (the proxy endpoint wraps this in an error response).
"""
guardrail = BlockingGuardrail()
with patch("litellm.callbacks", [guardrail]):
proxy_logging = ProxyLogging(user_api_key_cache=DualCache())
data = {"model": "dall-e-3", "prompt": "Something unsafe"}
user_api_key_dict = UserAPIKeyAuth(api_key="test-key")
with pytest.raises(ValueError, match="Image content blocked by guardrail"):
await proxy_logging.post_call_success_hook(
data=data,
response=_make_image_response(),
user_api_key_dict=user_api_key_dict,
)
# ---------------------------------------------------------------------------
# 4. Non-guardrail CustomLogger also fires for image generation
# ---------------------------------------------------------------------------
class TrackingLogger(CustomLogger):
"""Plain CustomLogger (not a guardrail) that tracks invocations."""
def __init__(self):
self.called = False
self.received_response = None
async def async_post_call_success_hook(
self,
data: dict,
user_api_key_dict: UserAPIKeyAuth,
response: Any,
) -> Any:
self.called = True
self.received_response = response
return response
@pytest.mark.asyncio
async def test_custom_logger_post_call_success_hook_fires_for_image_generation():
"""
Verify that a plain CustomLogger (non-guardrail) callback also has its
async_post_call_success_hook invoked for image generation responses.
"""
logger = TrackingLogger()
image_response = _make_image_response()
with patch("litellm.callbacks", [logger]):
proxy_logging = ProxyLogging(user_api_key_cache=DualCache())
data = {"model": "dall-e-3", "prompt": "A cat"}
user_api_key_dict = UserAPIKeyAuth(api_key="test-key")
result = await proxy_logging.post_call_success_hook(
data=data,
response=image_response,
user_api_key_dict=user_api_key_dict,
)
assert logger.called is True
assert isinstance(logger.received_response, ImageResponse)
assert result is image_response
# ---------------------------------------------------------------------------
# 5. Guardrail with should_run_guardrail=False is skipped
# ---------------------------------------------------------------------------
class OptInGuardrail(CustomGuardrail):
"""Guardrail that is NOT default_on, so it only runs if explicitly requested."""
def __init__(self):
super().__init__(
guardrail_name="opt_in_guardrail",
default_on=False,
event_hook=GuardrailEventHooks.post_call,
)
self.called = False
async def async_post_call_success_hook(
self,
data: dict,
user_api_key_dict: UserAPIKeyAuth,
response: Any,
) -> Any:
self.called = True
return response
@pytest.mark.asyncio
async def test_non_default_guardrail_skipped_for_image_generation():
"""
Verify that a guardrail with default_on=False is NOT invoked for image
generation unless the request explicitly enables it.
"""
guardrail = OptInGuardrail()
with patch("litellm.callbacks", [guardrail]):
proxy_logging = ProxyLogging(user_api_key_cache=DualCache())
# No guardrails key in data -> should_run_guardrail returns False
data = {"model": "dall-e-3", "prompt": "A sunset"}
user_api_key_dict = UserAPIKeyAuth(api_key="test-key")
await proxy_logging.post_call_success_hook(
data=data,
response=_make_image_response(),
user_api_key_dict=user_api_key_dict,
)
assert guardrail.called is False, "Opt-in guardrail should not fire without explicit request"
@@ -1,8 +1,8 @@
import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator";
import { Badge } from "@tremor/react";
import { AlertTriangle, ChevronDown, ChevronUp, Loader2, Minus, TrendingUp, UserCheck, Users } from "lucide-react";
import { AlertTriangle, Calendar, ChevronDown, ChevronUp, Loader2, Minus, TrendingUp, UserCheck, Users } from "lucide-react";
import { useEffect, useState } from "react";
import { getRemainingUsers } from "./networking";
import { getRemainingUsers, getLicenseInfo, LicenseInfo } from "./networking";
// Simple utility function to combine class names
const cn = (...classes: (string | boolean | undefined)[]) => {
@@ -23,11 +23,35 @@ interface UsageData {
total_teams_remaining: number | null;
}
// Calculate days until expiration
const getDaysUntilExpiration = (expirationDate: string | null): number | null => {
if (!expirationDate) return null;
const expDate = new Date(expirationDate + 'T00:00:00Z'); // Force UTC midnight
const now = new Date();
now.setHours(0, 0, 0, 0); // Normalize to local midnight
const diffTime = expDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
};
// Format expiration for display
const formatExpirationDisplay = (daysRemaining: number | null): string => {
if (daysRemaining === null) return "No expiration";
if (daysRemaining < 0) return "Expired";
if (daysRemaining === 0) return "Expires today";
if (daysRemaining === 1) return "1 day remaining";
if (daysRemaining < 30) return `${daysRemaining} days remaining`;
if (daysRemaining < 60) return "1 month remaining";
const months = Math.floor(daysRemaining / 30);
return `${months} months remaining`;
};
export default function UsageIndicator({ accessToken, width = 220 }: UsageIndicatorProps) {
const disableUsageIndicator = useDisableUsageIndicator();
const [isExpanded, setIsExpanded] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [data, setData] = useState<UsageData | null>(null);
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -39,8 +63,12 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
setError(null);
try {
const result = await getRemainingUsers(accessToken);
setData(result);
const [usageResult, licenseResult] = await Promise.all([
getRemainingUsers(accessToken),
getLicenseInfo(accessToken).catch(() => null), // Don't fail if license endpoint unavailable
]);
setData(usageResult);
setLicenseInfo(licenseResult);
} catch (err) {
console.error("Failed to fetch usage data:", err);
setError("Failed to load usage data");
@@ -52,6 +80,13 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
fetchData();
}, [accessToken]);
// Calculate license expiration metrics
const daysUntilExpiration = licenseInfo?.expiration_date
? getDaysUntilExpiration(licenseInfo.expiration_date)
: null;
const isLicenseExpired = daysUntilExpiration !== null && daysUntilExpiration < 0;
const isLicenseExpiringSoon = daysUntilExpiration !== null && daysUntilExpiration >= 0 && daysUntilExpiration < 30;
// Calculate derived values from data
const getUsageMetrics = (data: UsageData | null) => {
if (!data) {
@@ -106,35 +141,38 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
const { isOverLimit, isNearLimit, usagePercentage, userMetrics, teamMetrics } = getUsageMetrics(data);
// Include license status in overall status
const hasAnyIssue = isOverLimit || isNearLimit || isLicenseExpired || isLicenseExpiringSoon;
const hasError = isOverLimit || isLicenseExpired;
const hasWarning = (isNearLimit || isLicenseExpiringSoon) && !hasError;
const getStatusColor = () => {
if (isOverLimit) return "red";
if (isNearLimit) return "yellow";
if (hasError) return "red";
if (hasWarning) return "yellow";
return "green";
};
const getStatusIcon = () => {
if (isOverLimit) return <AlertTriangle className="h-3 w-3" />;
if (isNearLimit) return <TrendingUp className="h-3 w-3" />;
if (hasError) return <AlertTriangle className="h-3 w-3" />;
if (hasWarning) return <TrendingUp className="h-3 w-3" />;
return null;
};
// Minimized view - just a small restore button
const MinimizedView = () => {
const hasIssues = isOverLimit || isNearLimit;
return (
<div className="px-3 py-1" style={{ maxWidth: `${width}px` }}>
<button
onClick={() => setIsMinimized(false)}
className={cn(
"flex items-center gap-2 text-xs text-gray-400 hover:text-gray-600 transition-colors p-1 rounded w-full",
hasIssues && isOverLimit && "text-red-400 hover:text-red-600",
hasIssues && isNearLimit && "text-yellow-500 hover:text-yellow-700",
hasError && "text-red-400 hover:text-red-600",
hasWarning && "text-yellow-500 hover:text-yellow-700",
)}
title="Show usage details"
>
<Users className="h-3 w-3 flex-shrink-0" />
{hasIssues && <span className="flex-shrink-0">{getStatusIcon()}</span>}
{hasAnyIssue && <span className="flex-shrink-0">{getStatusIcon()}</span>}
<div className="flex items-center gap-1 truncate">
{data && data.total_users !== null && (
<span className="flex-shrink-0">
@@ -146,8 +184,17 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
T:{data.total_teams_used}/{data.total_teams}
</span>
)}
{licenseInfo?.expiration_date && daysUntilExpiration !== null && (
<span className={cn(
"flex-shrink-0",
isLicenseExpired && "text-red-500",
isLicenseExpiringSoon && "text-yellow-500",
)}>
{daysUntilExpiration < 0 ? "Exp!" : `${daysUntilExpiration}d`}
</span>
)}
{!data ||
(data.total_users === null && data.total_teams === null && <span className="truncate">Usage</span>)}
(data.total_users === null && data.total_teams === null && !licenseInfo && <span className="truncate">Usage</span>)}
</div>
</button>
</div>
@@ -198,13 +245,13 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
"flex items-center gap-3 text-left hover:bg-gray-50 rounded-md px-0 py-1 transition-colors flex-1 min-w-0",
isOverLimit && "text-red-600",
isNearLimit && "text-yellow-600",
hasError && "text-red-600",
hasWarning && "text-yellow-600",
)}
>
<Users className="h-4 w-4 flex-shrink-0" />
<span className="text-sm font-medium truncate">Usage Status</span>
{(isOverLimit || isNearLimit) && (
{hasAnyIssue && (
<Badge color={getStatusColor()} className="text-xs px-1.5 py-0.5 flex-shrink-0">
{getStatusIcon()}
</Badge>
@@ -229,6 +276,28 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
{/* Expanded details - simple and compact */}
{isExpanded && (
<div className="mt-2 pl-7 text-xs text-gray-600 space-y-3">
{/* License expiration section */}
{licenseInfo?.has_license && licenseInfo.expiration_date && (
<div>
<div className="mb-1 flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span className="font-medium">License</span>
</div>
<div className={cn(
"flex items-center gap-1 text-xs",
isLicenseExpired && "text-red-600",
isLicenseExpiringSoon && "text-yellow-600",
)}>
{isLicenseExpired ? (
<AlertTriangle className="h-3 w-3" />
) : isLicenseExpiringSoon ? (
<TrendingUp className="h-3 w-3" />
) : null}
<span className="truncate">{formatExpirationDisplay(daysUntilExpiration)}</span>
</div>
</div>
)}
{/* Users section */}
{data.total_users !== null && (
<div>
@@ -323,7 +392,6 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
// Optimized CardStyleView for 220px width
const CardStyleView = () => {
if (isMinimized) {
const hasIssues = isOverLimit || isNearLimit;
return (
<button
onClick={() => setIsMinimized(false)}
@@ -334,7 +402,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
>
<div className="flex items-center gap-2">
<Users className="h-4 w-4 flex-shrink-0" />
{hasIssues && <span className="flex-shrink-0">{getStatusIcon()}</span>}
{hasAnyIssue && <span className="flex-shrink-0">{getStatusIcon()}</span>}
<div className="flex items-center gap-2 text-sm font-medium truncate">
{data && data.total_users !== null && (
<span
@@ -360,8 +428,20 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
T: {data.total_teams_used}/{data.total_teams}
</span>
)}
{licenseInfo?.expiration_date && daysUntilExpiration !== null && (
<span
className={cn(
"flex-shrink-0 px-1.5 py-0.5 rounded text-xs border",
isLicenseExpired && "bg-red-50 text-red-700 border-red-200",
isLicenseExpiringSoon && "bg-yellow-50 text-yellow-700 border-yellow-200",
!isLicenseExpired && !isLicenseExpiringSoon && "bg-gray-50 text-gray-700 border-gray-200",
)}
>
{daysUntilExpiration < 0 ? "Exp!" : `${daysUntilExpiration}d`}
</span>
)}
{!data ||
(data.total_users === null && data.total_teams === null && <span className="truncate">Usage</span>)}
(data.total_users === null && data.total_teams === null && !licenseInfo && <span className="truncate">Usage</span>)}
</div>
</div>
</button>
@@ -416,6 +496,50 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
{/* Compact stats optimized for 220px */}
<div className="space-y-3 text-sm">
{/* License expiration section */}
{licenseInfo?.has_license && licenseInfo.expiration_date && (
<div
className={cn(
"space-y-1 border rounded-md p-2",
isLicenseExpired && "border-red-200 bg-red-50",
isLicenseExpiringSoon && "border-yellow-200 bg-yellow-50",
)}
>
<div className="flex items-center gap-2 text-xs text-gray-600 mb-1">
<Calendar className="h-3 w-3" />
<span className="font-medium">License</span>
<span
className={cn(
"ml-1 px-1.5 py-0.5 rounded border",
isLicenseExpired && "bg-red-50 text-red-700 border-red-200",
isLicenseExpiringSoon && "bg-yellow-50 text-yellow-700 border-yellow-200",
!isLicenseExpired && !isLicenseExpiringSoon && "bg-gray-50 text-gray-600 border-gray-200",
)}
>
{isLicenseExpired ? "Expired" : isLicenseExpiringSoon ? "Expiring soon" : "OK"}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 text-xs">Status:</span>
<span
className={cn(
"font-medium text-right",
isLicenseExpired && "text-red-600",
isLicenseExpiringSoon && "text-yellow-600",
)}
>
{formatExpirationDisplay(daysUntilExpiration)}
</span>
</div>
{licenseInfo.license_type && (
<div className="flex justify-between items-center">
<span className="text-gray-600 text-xs">Type:</span>
<span className="font-medium text-right capitalize">{licenseInfo.license_type}</span>
</div>
)}
</div>
)}
{/* Users section */}
{data.total_users !== null && (
<div
@@ -7937,6 +7937,48 @@ export const getRemainingUsers = async (
}
};
export interface LicenseInfo {
has_license: boolean;
license_type: string | null;
expiration_date: string | null;
allowed_features: string[];
limits: {
max_users: number | null;
max_teams: number | null;
};
}
export const getLicenseInfo = async (
accessToken: string,
): Promise<LicenseInfo | null> => {
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/health/license` : `/health/license`;
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
// if 404 - return null (endpoint not available)
if (response.status === 404) {
return null;
}
const errorData = await response.text();
handleError(errorData);
throw new Error("Network response was not ok");
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to fetch license info:", error);
throw error;
}
};
export const updatePassThroughEndpoint = async (
accessToken: string,
endpointPath: string,