mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
Merge pull request #27815 from BerriAI/litellm_internal_staging
Some checks are pending
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Helm unit test / unit-test (push) Waiting to run
Read Version from pyproject.toml / read-version (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Unit Tests: Caching (Redis) / caching-redis (push) Waiting to run
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Waiting to run
Unit Tests: Proxy DB Operations / auth-checks (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / budgets (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / custom-logging (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / db-and-spend (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / key-generation (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / logging-misc (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-runtime (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-server-core (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / schema-migration (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-utils (push) Blocked by required conditions
Unit Tests: Security / security (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run
Some checks are pending
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Helm unit test / unit-test (push) Waiting to run
Read Version from pyproject.toml / read-version (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Unit Tests: Caching (Redis) / caching-redis (push) Waiting to run
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Waiting to run
Unit Tests: Proxy DB Operations / auth-checks (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / budgets (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / custom-logging (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / db-and-spend (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / key-generation (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / logging-misc (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-runtime (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-server-core (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / schema-migration (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-utils (push) Blocked by required conditions
Unit Tests: Security / security (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run
[Infra] Promote internal staging to main
This commit is contained in:
commit
7af0f05b71
591 changed files with 14753 additions and 3670 deletions
131
.github/workflows/mutation-test.yml
vendored
Normal file
131
.github/workflows/mutation-test.yml
vendored
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
name: "Mutation Test (manual)"
|
||||
|
||||
# Manually-triggered mutation testing. Runs mutmut against the scope
|
||||
# configured in [tool.mutmut] in pyproject.toml (currently the
|
||||
# litellm/proxy/management_endpoints/ folder). Intended cadence is roughly
|
||||
# weekly — clicked from the Actions tab when someone wants a fresh report.
|
||||
#
|
||||
# Uploads a structured `mutation-report.md` (Meta ACH-style: original +
|
||||
# mutated function with `# MUTANT START`/`# MUTANT END` delimiters + the
|
||||
# existing tests + a task instruction) as a workflow artifact. Failures
|
||||
# do not block anything because nothing depends on this workflow.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: mutation-test-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
mutation:
|
||||
name: Run mutmut
|
||||
runs-on: ubuntu-latest
|
||||
# Whole-folder mutation against ~15 files / ~7.5k LOC can take hours.
|
||||
# 350 minutes is just under the GitHub-hosted job cap of 360 minutes.
|
||||
timeout-minutes: 350
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
version: "0.10.9"
|
||||
|
||||
- name: Cache uv dependencies
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
~/.cache/uv
|
||||
.venv
|
||||
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-uv-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router
|
||||
|
||||
- name: Generate Prisma client
|
||||
env:
|
||||
PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache
|
||||
run: |
|
||||
uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
|
||||
|
||||
# mutmut 3.x runs tests inside a `mutants/` sandbox where it injects
|
||||
# mutation trampolines. uv installs the project as editable by default,
|
||||
# which puts the original source dir on sys.path via a .pth file and
|
||||
# shadows the sandbox copy — so tests would never exercise the mutated
|
||||
# code. Reinstalling non-editable removes the .pth shadow.
|
||||
- name: Reinstall litellm non-editable (so mutants/ is not shadowed)
|
||||
run: |
|
||||
uv pip uninstall litellm
|
||||
uv pip install . --no-deps
|
||||
|
||||
# pytest-retry's pytest_configure hook crashes with
|
||||
# `INTERNALERROR: no option named 'filtered_exceptions'` when invoked
|
||||
# via mutmut's in-process pytest.main() call. The entry-point name
|
||||
# doesn't normalize cleanly with `-p no:<name>`, so just remove the
|
||||
# package outright. Reruns are wrong for mutation testing anyway —
|
||||
# rerunning a "failed" mutant test would mask which mutants are killed.
|
||||
- name: Remove pytest plugins that conflict with mutmut
|
||||
run: |
|
||||
uv pip uninstall pytest-retry || true
|
||||
|
||||
- name: Run mutmut
|
||||
env:
|
||||
# Make the mutants/ sandbox win over site-packages on sys.path so the
|
||||
# trampolined files are imported instead of the installed copy.
|
||||
PYTHONPATH: ${{ github.workspace }}/mutants
|
||||
run: |
|
||||
set -o pipefail
|
||||
mkdir -p mutants
|
||||
uv run --no-sync --with mutmut==3.5.0 mutmut run 2>&1 | tee mutmut-run.log
|
||||
|
||||
# Generate the structured report. The script embeds the enclosing
|
||||
# function source for each survivor (via Python AST) and includes the
|
||||
# existing test files, so an LLM agent has enough context to write
|
||||
# killing tests without further file lookups. Modeled on Meta's ACH
|
||||
# prompt template (arXiv 2501.12862).
|
||||
- name: Generate detailed mutation report
|
||||
if: always()
|
||||
run: |
|
||||
set +e
|
||||
uv run --no-sync --with mutmut==3.5.0 mutmut export-cicd-stats > /dev/null 2>&1
|
||||
uv run --no-sync --with mutmut==3.5.0 mutmut results > mutmut-results.txt 2>&1
|
||||
uv run --no-sync python scripts/mutation_report.py
|
||||
# The full report can be very long for big test files; the run-page
|
||||
# summary cuts off at 1 MB. Append the head of the report (summary
|
||||
# + survivor list) and link out to the artifact for the full body.
|
||||
{
|
||||
head -c 900000 mutation-report.md
|
||||
echo ""
|
||||
echo ""
|
||||
echo "_Full report (with embedded function bodies and test files) is in the workflow artifact._"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload mutmut artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
||||
with:
|
||||
name: mutmut-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
mutation-report.md
|
||||
mutmut-results.txt
|
||||
mutmut-run.log
|
||||
mutants/mutmut-stats.json
|
||||
mutants/mutmut-cicd-stats.json
|
||||
mutants/litellm/proxy/management_endpoints/**/*.py
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
1
.github/workflows/test-unit-proxy-db.yml
vendored
1
.github/workflows/test-unit-proxy-db.yml
vendored
|
|
@ -100,6 +100,7 @@ jobs:
|
|||
test-path: >-
|
||||
tests/proxy_unit_tests/test_auth_checks.py
|
||||
tests/proxy_unit_tests/test_user_api_key_auth.py
|
||||
tests/proxy_unit_tests/test_deprecated_key_grace_period.py
|
||||
workers: 4
|
||||
dist: loadscope
|
||||
timeout: 15
|
||||
|
|
|
|||
|
|
@ -1426,6 +1426,12 @@ if TYPE_CHECKING:
|
|||
)
|
||||
from .llms.datarobot.chat.transformation import DataRobotConfig as DataRobotConfig
|
||||
from .llms.anthropic.chat.transformation import AnthropicConfig as AnthropicConfig
|
||||
from .llms.bedrock.claude_platform.transformation import (
|
||||
BedrockClaudePlatformConfig as BedrockClaudePlatformConfig,
|
||||
)
|
||||
from .llms.bedrock.claude_platform.messages_transformation import (
|
||||
BedrockClaudePlatformMessagesConfig as BedrockClaudePlatformMessagesConfig,
|
||||
)
|
||||
from .llms.anthropic.completion.transformation import (
|
||||
AnthropicTextConfig as AnthropicTextConfig,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ LLM_CONFIG_NAMES = (
|
|||
"OpenrouterConfig",
|
||||
"DataRobotConfig",
|
||||
"AnthropicConfig",
|
||||
"BedrockClaudePlatformConfig",
|
||||
"AnthropicTextConfig",
|
||||
"GroqSTTConfig",
|
||||
"TritonConfig",
|
||||
|
|
@ -170,6 +171,7 @@ LLM_CONFIG_NAMES = (
|
|||
"SagemakerNovaConfig",
|
||||
"CohereChatConfig",
|
||||
"AnthropicMessagesConfig",
|
||||
"BedrockClaudePlatformMessagesConfig",
|
||||
"AmazonAnthropicClaudeMessagesConfig",
|
||||
"AmazonMantleMessagesConfig",
|
||||
"TogetherAIConfig",
|
||||
|
|
@ -610,6 +612,10 @@ _LLM_CONFIGS_IMPORT_MAP = {
|
|||
"OpenrouterConfig": (".llms.openrouter.chat.transformation", "OpenrouterConfig"),
|
||||
"DataRobotConfig": (".llms.datarobot.chat.transformation", "DataRobotConfig"),
|
||||
"AnthropicConfig": (".llms.anthropic.chat.transformation", "AnthropicConfig"),
|
||||
"BedrockClaudePlatformConfig": (
|
||||
".llms.bedrock.claude_platform.transformation",
|
||||
"BedrockClaudePlatformConfig",
|
||||
),
|
||||
"AnthropicTextConfig": (
|
||||
".llms.anthropic.completion.transformation",
|
||||
"AnthropicTextConfig",
|
||||
|
|
@ -712,6 +718,10 @@ _LLM_CONFIGS_IMPORT_MAP = {
|
|||
".llms.anthropic.experimental_pass_through.messages.transformation",
|
||||
"AnthropicMessagesConfig",
|
||||
),
|
||||
"BedrockClaudePlatformMessagesConfig": (
|
||||
".llms.bedrock.claude_platform.messages_transformation",
|
||||
"BedrockClaudePlatformMessagesConfig",
|
||||
),
|
||||
"AmazonAnthropicClaudeMessagesConfig": (
|
||||
".llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation",
|
||||
"AmazonAnthropicClaudeMessagesConfig",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import redis.asyncio as async_redis # type: ignore
|
|||
|
||||
from litellm import get_secret, get_secret_str
|
||||
from litellm._redis_credential_provider import (
|
||||
AzureADCredentialProvider,
|
||||
GCPIAMCredentialProvider,
|
||||
_generate_gcp_iam_access_token,
|
||||
)
|
||||
|
|
@ -27,6 +28,8 @@ from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
|
|||
|
||||
from ._logging import verbose_logger
|
||||
|
||||
AZURE_REDIS_SCOPE = "https://redis.azure.com/.default"
|
||||
|
||||
|
||||
def _get_redis_kwargs():
|
||||
arg_spec = inspect.getfullargspec(redis.Redis)
|
||||
|
|
@ -43,6 +46,10 @@ def _get_redis_kwargs():
|
|||
"redis_connect_func",
|
||||
"gcp_service_account",
|
||||
"gcp_ssl_ca_certs",
|
||||
"azure_redis_ad_token",
|
||||
"azure_client_id",
|
||||
"azure_tenant_id",
|
||||
"azure_client_secret",
|
||||
]
|
||||
|
||||
available_args = [x for x in arg_spec.args if x not in exclude_args] + include_args
|
||||
|
|
@ -89,6 +96,10 @@ def _get_redis_cluster_kwargs(client=None):
|
|||
) # Needed for sync clusters and IAM detection
|
||||
available_args.append("gcp_service_account")
|
||||
available_args.append("gcp_ssl_ca_certs")
|
||||
available_args.append("azure_redis_ad_token")
|
||||
available_args.append("azure_client_id")
|
||||
available_args.append("azure_tenant_id")
|
||||
available_args.append("azure_client_secret")
|
||||
available_args.append("max_connections")
|
||||
|
||||
return available_args
|
||||
|
|
@ -155,6 +166,125 @@ def create_gcp_iam_redis_connect_func(
|
|||
return iam_connect
|
||||
|
||||
|
||||
def _build_azure_credential(
|
||||
azure_client_id: Optional[str] = None,
|
||||
azure_tenant_id: Optional[str] = None,
|
||||
azure_client_secret: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Build a long-lived Azure credential object.
|
||||
|
||||
Azure SDK credentials cache tokens internally and handle expiry/refresh
|
||||
transparently, so this should be called once and the result reused.
|
||||
"""
|
||||
try:
|
||||
from azure.identity import (
|
||||
ClientSecretCredential,
|
||||
DefaultAzureCredential,
|
||||
ManagedIdentityCredential,
|
||||
)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"azure-identity is required for Azure AD Redis authentication. "
|
||||
"Install it with: pip install azure-identity"
|
||||
)
|
||||
|
||||
_client_id = azure_client_id or os.environ.get("AZURE_CLIENT_ID")
|
||||
_tenant_id = azure_tenant_id or os.environ.get("AZURE_TENANT_ID")
|
||||
_client_secret = azure_client_secret or os.environ.get("AZURE_CLIENT_SECRET")
|
||||
|
||||
if _client_id and _tenant_id and _client_secret:
|
||||
return ClientSecretCredential(
|
||||
client_id=_client_id,
|
||||
tenant_id=_tenant_id,
|
||||
client_secret=_client_secret,
|
||||
)
|
||||
elif _client_id:
|
||||
return ManagedIdentityCredential(client_id=_client_id)
|
||||
else:
|
||||
return DefaultAzureCredential()
|
||||
|
||||
|
||||
def _generate_azure_ad_redis_token(
|
||||
azure_client_id: Optional[str] = None,
|
||||
azure_tenant_id: Optional[str] = None,
|
||||
azure_client_secret: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
One-shot helper that builds a credential and fetches a single Azure AD
|
||||
access token for Redis. Each call rebuilds the credential and performs a
|
||||
network round-trip, so it should not be used in steady-state Redis flows
|
||||
— the sync (``create_azure_ad_redis_connect_func``) and async paths
|
||||
(``AzureADCredentialProvider``) keep the credential alive across
|
||||
connections so the Azure SDK's internal cache + silent refresh apply.
|
||||
"""
|
||||
credential = _build_azure_credential(
|
||||
azure_client_id=azure_client_id,
|
||||
azure_tenant_id=azure_tenant_id,
|
||||
azure_client_secret=azure_client_secret,
|
||||
)
|
||||
token = credential.get_token(AZURE_REDIS_SCOPE)
|
||||
return token.token
|
||||
|
||||
|
||||
def create_azure_ad_redis_connect_func(
|
||||
azure_client_id: Optional[str] = None,
|
||||
azure_tenant_id: Optional[str] = None,
|
||||
azure_client_secret: Optional[str] = None,
|
||||
) -> Callable:
|
||||
"""
|
||||
Creates a custom Redis connection function for Azure AD authentication.
|
||||
|
||||
Used for sync Redis clients. The credential is created once (captured by the
|
||||
closure) and reused across connections — the Azure SDK handles token caching
|
||||
and silent renewal internally. Only ``get_token`` is called per connection.
|
||||
"""
|
||||
credential = _build_azure_credential(
|
||||
azure_client_id=azure_client_id,
|
||||
azure_tenant_id=azure_tenant_id,
|
||||
azure_client_secret=azure_client_secret,
|
||||
)
|
||||
|
||||
def ad_connect(self):
|
||||
"""Initialize the connection and authenticate using Azure AD"""
|
||||
from redis.exceptions import (
|
||||
AuthenticationError,
|
||||
AuthenticationWrongNumberOfArgsError,
|
||||
)
|
||||
from redis.utils import str_if_bytes
|
||||
|
||||
self._parser.on_connect(self)
|
||||
|
||||
access_token = credential.get_token(AZURE_REDIS_SCOPE).token
|
||||
|
||||
# Only include username when explicitly set — sending AUTH "" <token>
|
||||
# is invalid for most ACL-configured Azure Redis instances.
|
||||
username = os.environ.get("REDIS_USERNAME", "")
|
||||
if username:
|
||||
auth_args = (username, access_token)
|
||||
else:
|
||||
auth_args = (access_token,)
|
||||
|
||||
self.send_command("AUTH", *auth_args, check_health=False)
|
||||
|
||||
try:
|
||||
auth_response = self.read_response()
|
||||
except AuthenticationWrongNumberOfArgsError:
|
||||
# Fallback: try with just the token (Redis < 6 / no ACL)
|
||||
self.send_command("AUTH", access_token, check_health=False)
|
||||
auth_response = self.read_response()
|
||||
|
||||
if str_if_bytes(auth_response) != "OK":
|
||||
raise AuthenticationError("Azure AD authentication failed for Redis")
|
||||
|
||||
# Attach the live credential object so async paths can wrap it in
|
||||
# AzureADCredentialProvider for refresh-aware token retrieval. The raw
|
||||
# client_id/tenant_id/secret are intentionally NOT exposed here — the
|
||||
# credential closure already holds them.
|
||||
ad_connect._azure_credential = credential # type: ignore[attr-defined]
|
||||
return ad_connect
|
||||
|
||||
|
||||
def get_redis_url_from_environment():
|
||||
if "REDIS_URL" in os.environ:
|
||||
return os.environ["REDIS_URL"]
|
||||
|
|
@ -179,7 +309,7 @@ def get_redis_url_from_environment():
|
|||
return f"{redis_protocol}://{auth_part}{os.environ['REDIS_HOST']}:{os.environ['REDIS_PORT']}"
|
||||
|
||||
|
||||
def _get_redis_client_logic(**env_overrides):
|
||||
def _get_redis_client_logic(**env_overrides): # noqa: PLR0915
|
||||
"""
|
||||
Common functionality across sync + async redis client implementations
|
||||
"""
|
||||
|
|
@ -253,6 +383,52 @@ def _get_redis_client_logic(**env_overrides):
|
|||
if _gcp_ssl_ca_certs and redis_kwargs.get("ssl", False):
|
||||
redis_kwargs["ssl_ca_certs"] = _gcp_ssl_ca_certs
|
||||
|
||||
# Handle Azure AD authentication (after GCP IAM block)
|
||||
_azure_redis_ad_token = redis_kwargs.get("azure_redis_ad_token") or get_secret(
|
||||
"REDIS_AZURE_AD_TOKEN"
|
||||
)
|
||||
|
||||
_azure_ad_enabled = (
|
||||
_azure_redis_ad_token is not None
|
||||
and str(_azure_redis_ad_token).lower() == "true"
|
||||
)
|
||||
|
||||
if _azure_ad_enabled and _gcp_service_account is not None:
|
||||
verbose_logger.warning(
|
||||
"Both GCP IAM (gcp_service_account) and Azure AD (azure_redis_ad_token) are configured for Redis. "
|
||||
"Using GCP IAM. Remove one to avoid misconfiguration."
|
||||
)
|
||||
|
||||
if _azure_ad_enabled and _gcp_service_account is None:
|
||||
_azure_client_id = redis_kwargs.get("azure_client_id") or get_secret_str(
|
||||
"AZURE_CLIENT_ID"
|
||||
)
|
||||
_azure_tenant_id = redis_kwargs.get("azure_tenant_id") or get_secret_str(
|
||||
"AZURE_TENANT_ID"
|
||||
)
|
||||
_azure_client_secret = redis_kwargs.get(
|
||||
"azure_client_secret"
|
||||
) or get_secret_str("AZURE_CLIENT_SECRET")
|
||||
|
||||
verbose_logger.debug("Setting up Azure AD authentication for Redis.")
|
||||
redis_kwargs["redis_connect_func"] = create_azure_ad_redis_connect_func(
|
||||
azure_client_id=_azure_client_id,
|
||||
azure_tenant_id=_azure_tenant_id,
|
||||
azure_client_secret=_azure_client_secret,
|
||||
)
|
||||
# Marker for async paths to detect Azure AD auth. The live credential
|
||||
# object is attached separately as `_azure_credential` by
|
||||
# `create_azure_ad_redis_connect_func`; the raw client_id/tenant_id/secret
|
||||
# are intentionally NOT exposed on the function to avoid leaking
|
||||
# credentials via inspection or logging.
|
||||
redis_kwargs["redis_connect_func"]._azure_redis_ad_token = True # type: ignore[attr-defined]
|
||||
|
||||
# Always remove Azure-specific kwargs that shouldn't be passed to Redis client
|
||||
redis_kwargs.pop("azure_redis_ad_token", None)
|
||||
redis_kwargs.pop("azure_client_id", None)
|
||||
redis_kwargs.pop("azure_tenant_id", None)
|
||||
redis_kwargs.pop("azure_client_secret", None)
|
||||
|
||||
if "url" in redis_kwargs and redis_kwargs["url"] is not None:
|
||||
# Only strip host/port/db/password when not routing to a cluster.
|
||||
# When startup_nodes is also present the cluster path takes priority and
|
||||
|
|
@ -373,7 +549,7 @@ def get_redis_client(**env_overrides):
|
|||
return redis.Redis(**redis_kwargs)
|
||||
|
||||
|
||||
def get_redis_async_client(
|
||||
def get_redis_async_client( # noqa: PLR0915
|
||||
connection_pool: Optional[async_redis.BlockingConnectionPool] = None,
|
||||
**env_overrides,
|
||||
) -> Union[async_redis.Redis, async_redis.RedisCluster]:
|
||||
|
|
@ -398,6 +574,14 @@ def get_redis_async_client(
|
|||
cluster_kwargs["credential_provider"] = GCPIAMCredentialProvider(
|
||||
redis_connect_func._gcp_service_account
|
||||
)
|
||||
# Handle Azure AD authentication for async clusters via CredentialProvider
|
||||
# so the credential's internal cache + silent refresh runs per connection
|
||||
# (mirrors GCP IAM above; avoids static-token-baked-in-pool expiry).
|
||||
elif redis_connect_func and hasattr(redis_connect_func, "_azure_credential"):
|
||||
cluster_kwargs["credential_provider"] = AzureADCredentialProvider(
|
||||
redis_connect_func._azure_credential,
|
||||
username=os.environ.get("REDIS_USERNAME") or None,
|
||||
)
|
||||
|
||||
new_startup_nodes: List[ClusterNode] = []
|
||||
|
||||
|
|
@ -431,6 +615,22 @@ def get_redis_async_client(
|
|||
# Check for Redis Sentinel
|
||||
if "sentinel_nodes" in redis_kwargs and "service_name" in redis_kwargs:
|
||||
return _init_async_redis_sentinel(redis_kwargs)
|
||||
|
||||
# Wrap GCP / Azure AD auth in a CredentialProvider for the standard async
|
||||
# Redis client. The async client doesn't support redis_connect_func, but it
|
||||
# does honour credential_provider — which is called per connection, so the
|
||||
# underlying SDK can refresh tokens silently before they expire.
|
||||
redis_connect_func = redis_kwargs.pop("redis_connect_func", None)
|
||||
if redis_connect_func and hasattr(redis_connect_func, "_azure_credential"):
|
||||
redis_kwargs["credential_provider"] = AzureADCredentialProvider(
|
||||
redis_connect_func._azure_credential,
|
||||
username=os.environ.get("REDIS_USERNAME") or None,
|
||||
)
|
||||
elif redis_connect_func and hasattr(redis_connect_func, "_gcp_service_account"):
|
||||
redis_kwargs["credential_provider"] = GCPIAMCredentialProvider(
|
||||
redis_connect_func._gcp_service_account
|
||||
)
|
||||
|
||||
_pretty_print_redis_config(redis_kwargs=redis_kwargs)
|
||||
|
||||
if connection_pool is not None:
|
||||
|
|
@ -464,6 +664,21 @@ def get_redis_connection_pool(
|
|||
redis_kwargs["max_connections"],
|
||||
)
|
||||
return async_redis.BlockingConnectionPool.from_url(**pool_kwargs)
|
||||
|
||||
# Wrap GCP / Azure AD auth in a CredentialProvider so pool-managed
|
||||
# connections re-fetch tokens via the SDK's internal cache + silent refresh
|
||||
# rather than reusing a single token captured at pool creation.
|
||||
redis_connect_func = redis_kwargs.pop("redis_connect_func", None)
|
||||
if redis_connect_func and hasattr(redis_connect_func, "_azure_credential"):
|
||||
redis_kwargs["credential_provider"] = AzureADCredentialProvider(
|
||||
redis_connect_func._azure_credential,
|
||||
username=os.environ.get("REDIS_USERNAME") or None,
|
||||
)
|
||||
elif redis_connect_func and hasattr(redis_connect_func, "_gcp_service_account"):
|
||||
redis_kwargs["credential_provider"] = GCPIAMCredentialProvider(
|
||||
redis_connect_func._gcp_service_account
|
||||
)
|
||||
|
||||
connection_class = async_redis.Connection
|
||||
if "ssl" in redis_kwargs:
|
||||
connection_class = async_redis.SSLConnection
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Tuple
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
from redis.credentials import CredentialProvider # type: ignore[attr-defined]
|
||||
|
||||
# Azure AD scope for Redis Cache for Azure.
|
||||
AZURE_REDIS_SCOPE = "https://redis.azure.com/.default"
|
||||
|
||||
# GCP IAM tokens are valid for 1 hour. Cache for 55 minutes to refresh before expiry.
|
||||
_GCP_IAM_TOKEN_TTL_SECONDS = 3300
|
||||
|
||||
|
|
@ -101,3 +104,33 @@ class GCPIAMCredentialProvider(CredentialProvider):
|
|||
_get_cached_gcp_iam_token, self._gcp_service_account
|
||||
)
|
||||
return (token,)
|
||||
|
||||
|
||||
class AzureADCredentialProvider(CredentialProvider):
|
||||
"""
|
||||
redis.credentials.CredentialProvider implementation that supplies Azure AD
|
||||
tokens for Redis authentication.
|
||||
|
||||
Wraps an azure-identity credential object so the Azure SDK's internal token
|
||||
cache and silent refresh are honoured on every Redis connection. This avoids
|
||||
the static-token-baked-in-pool issue where pool-managed connections would
|
||||
fail authentication after the initial token expired (~1 hour TTL).
|
||||
"""
|
||||
|
||||
def __init__(self, credential: Any, username: Optional[str] = None) -> None:
|
||||
self._credential = credential
|
||||
self._username = username
|
||||
|
||||
def get_credentials(self) -> Union[Tuple[str], Tuple[str, str]]:
|
||||
token = self._credential.get_token(AZURE_REDIS_SCOPE).token
|
||||
if self._username:
|
||||
return (self._username, token)
|
||||
return (token,)
|
||||
|
||||
async def get_credentials_async(self) -> Union[Tuple[str], Tuple[str, str]]:
|
||||
token_obj = await asyncio.to_thread(
|
||||
self._credential.get_token, AZURE_REDIS_SCOPE
|
||||
)
|
||||
if self._username:
|
||||
return (self._username, token_obj.token)
|
||||
return (token_obj.token,)
|
||||
|
|
|
|||
|
|
@ -617,24 +617,35 @@ def retrieve_batch(
|
|||
_is_async = kwargs.pop("aretrieve_batch", False) is True
|
||||
client = kwargs.get("client", None)
|
||||
|
||||
# Check if this is an async invoke ARN (different from regular batch ARN)
|
||||
# Async invoke ARNs have format: arn:aws(-[^:]+)?:bedrock:[a-z0-9-]{1,20}:[0-9]{12}:async-invoke/[a-z0-9]{12}
|
||||
if (
|
||||
batch_id.startswith("arn:aws")
|
||||
and ":bedrock:" in batch_id
|
||||
and ":async-invoke/" in batch_id
|
||||
):
|
||||
# Handle async invoke status check
|
||||
# Remove aws_region_name from kwargs to avoid duplicate parameter
|
||||
async_kwargs = kwargs.copy()
|
||||
async_kwargs.pop("aws_region_name", None)
|
||||
# Bedrock has two distinct ARN families that need different APIs:
|
||||
# * async-invoke ARNs (Twelve Labs Marengo embeddings) -> bedrock-runtime data plane
|
||||
# * model-invocation-job ARNs (CreateModelInvocationJob batch) -> bedrock control plane
|
||||
# They live on different AWS service endpoints and can't share a handler.
|
||||
# ARN shapes:
|
||||
# arn:aws(-[^:]+)?:bedrock:<region>:<account>:async-invoke/<id>
|
||||
# arn:aws(-[^:]+)?:bedrock:<region>:<account>:model-invocation-job/<id>
|
||||
if batch_id.startswith("arn:aws") and ":bedrock:" in batch_id:
|
||||
if ":async-invoke/" in batch_id:
|
||||
# Remove aws_region_name from kwargs to avoid duplicate parameter
|
||||
async_kwargs = kwargs.copy()
|
||||
async_kwargs.pop("aws_region_name", None)
|
||||
|
||||
return BedrockBatchesHandler._handle_async_invoke_status(
|
||||
batch_id=batch_id,
|
||||
aws_region_name=kwargs.get("aws_region_name", "us-east-1"),
|
||||
logging_obj=litellm_logging_obj,
|
||||
**async_kwargs,
|
||||
)
|
||||
return BedrockBatchesHandler._handle_async_invoke_status(
|
||||
batch_id=batch_id,
|
||||
aws_region_name=kwargs.get("aws_region_name", "us-east-1"),
|
||||
logging_obj=litellm_logging_obj,
|
||||
**async_kwargs,
|
||||
)
|
||||
if ":model-invocation-job/" in batch_id:
|
||||
mij_kwargs = kwargs.copy()
|
||||
mij_kwargs.pop("aws_region_name", None)
|
||||
|
||||
return BedrockBatchesHandler._handle_model_invocation_job_status(
|
||||
batch_id=batch_id,
|
||||
aws_region_name=kwargs.get("aws_region_name"),
|
||||
logging_obj=litellm_logging_obj,
|
||||
**mij_kwargs,
|
||||
)
|
||||
|
||||
# Try to use provider config first (for providers like bedrock)
|
||||
model: Optional[str] = kwargs.get("model", None)
|
||||
|
|
|
|||
|
|
@ -119,6 +119,20 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
def __init__(self):
|
||||
pass
|
||||
|
||||
def _normalize_tool_choice_for_responses_api(self, tool_choice: Any) -> Any:
|
||||
"""Chat tool_choice uses function.name; Responses API expects top-level name."""
|
||||
if not isinstance(tool_choice, dict) or tool_choice.get("type") != "function":
|
||||
return tool_choice
|
||||
if isinstance(tool_choice.get("name"), str) and tool_choice.get("name"):
|
||||
# Return only Responses shape so stray chat ``function`` key is not sent upstream.
|
||||
return {"type": "function", "name": tool_choice["name"]}
|
||||
fn = tool_choice.get("function")
|
||||
if isinstance(fn, dict):
|
||||
fn_name = fn.get("name")
|
||||
if isinstance(fn_name, str) and fn_name:
|
||||
return {"type": "function", "name": fn_name}
|
||||
return tool_choice
|
||||
|
||||
def _handle_raw_dict_response_item(
|
||||
self, item: Dict[str, Any], index: int
|
||||
) -> Tuple[Optional[Any], int]:
|
||||
|
|
@ -309,6 +323,10 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
text_format = self._transform_response_format_to_text_format(value)
|
||||
if text_format:
|
||||
responses_api_request["text"] = text_format # type: ignore
|
||||
elif key == "tool_choice":
|
||||
responses_api_request["tool_choice"] = ( # type: ignore[assignment]
|
||||
self._normalize_tool_choice_for_responses_api(value)
|
||||
)
|
||||
elif key in ResponsesAPIOptionalRequestParams.__annotations__.keys():
|
||||
responses_api_request[key] = value # type: ignore
|
||||
elif key == "previous_response_id":
|
||||
|
|
|
|||
|
|
@ -888,6 +888,15 @@ def log_guardrail_information(func):
|
|||
- pre_call
|
||||
- during_call
|
||||
- post_call
|
||||
|
||||
Some guardrails (e.g. ``block_code_execution``) call
|
||||
``add_standard_logging_guardrail_information_to_request_data`` directly
|
||||
from inside the wrapped function so they can record a richer payload
|
||||
(structured detections, tracing detail) than this decorator's
|
||||
"allow"/"mask"/raw-response default. To avoid double-recording in that
|
||||
case (which would emit two spans, two Datadog records, two spend-log
|
||||
entries, etc.), snapshot the entry count before invocation: if the
|
||||
wrapped function already appended its own entry, skip the auto-record.
|
||||
"""
|
||||
import functools
|
||||
import inspect
|
||||
|
|
@ -907,6 +916,16 @@ def log_guardrail_information(func):
|
|||
return GuardrailEventHooks.post_call
|
||||
return None
|
||||
|
||||
def _count_recorded_guardrail_entries(request_data: dict) -> int:
|
||||
total = 0
|
||||
for container_key in ("metadata", "litellm_metadata"):
|
||||
container = request_data.get(container_key)
|
||||
if isinstance(container, dict):
|
||||
entries = container.get("standard_logging_guardrail_information")
|
||||
if isinstance(entries, list):
|
||||
total += len(entries)
|
||||
return total
|
||||
|
||||
@functools.wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
start_time = datetime.now() # Move start_time inside the wrapper
|
||||
|
|
@ -919,8 +938,11 @@ def log_guardrail_information(func):
|
|||
if func.__name__ == "apply_guardrail" and "inputs" in kwargs:
|
||||
original_inputs = kwargs.get("inputs")
|
||||
|
||||
entries_before = _count_recorded_guardrail_entries(request_data)
|
||||
try:
|
||||
response = await func(*args, **kwargs)
|
||||
if _count_recorded_guardrail_entries(request_data) > entries_before:
|
||||
return response
|
||||
return self._process_response(
|
||||
response=response,
|
||||
request_data=request_data,
|
||||
|
|
@ -931,6 +953,8 @@ def log_guardrail_information(func):
|
|||
original_inputs=original_inputs,
|
||||
)
|
||||
except Exception as e:
|
||||
if _count_recorded_guardrail_entries(request_data) > entries_before:
|
||||
raise
|
||||
return self._process_error(
|
||||
e=e,
|
||||
request_data=request_data,
|
||||
|
|
@ -952,8 +976,11 @@ def log_guardrail_information(func):
|
|||
if func.__name__ == "apply_guardrail" and "inputs" in kwargs:
|
||||
original_inputs = kwargs.get("inputs")
|
||||
|
||||
entries_before = _count_recorded_guardrail_entries(request_data)
|
||||
try:
|
||||
response = func(*args, **kwargs)
|
||||
if _count_recorded_guardrail_entries(request_data) > entries_before:
|
||||
return response
|
||||
return self._process_response(
|
||||
response=response,
|
||||
request_data=request_data,
|
||||
|
|
@ -962,6 +989,8 @@ def log_guardrail_information(func):
|
|||
original_inputs=original_inputs,
|
||||
)
|
||||
except Exception as e:
|
||||
if _count_recorded_guardrail_entries(request_data) > entries_before:
|
||||
raise
|
||||
return self._process_error(
|
||||
e=e,
|
||||
request_data=request_data,
|
||||
|
|
|
|||
|
|
@ -237,7 +237,14 @@ class OpenTelemetry(CustomLogger):
|
|||
not isinstance(cb, OpenTelemetry) for cb in litellm.service_callback
|
||||
):
|
||||
litellm.service_callback.append(self)
|
||||
setattr(proxy_server, "open_telemetry_logger", self)
|
||||
# avoid proxy logger ownership being overwritten by later
|
||||
# handlers. Multiple integrations (default OTEL, Langfuse OTEL,
|
||||
# Arize OTEL, etc.) may initialize in sequence; without this guard,
|
||||
# the last one silently replaces the first and breaks expected
|
||||
# routing for proxy_server.open_telemetry_logger consumers.
|
||||
# Behavior: first-registered wins.
|
||||
if getattr(proxy_server, "open_telemetry_logger", None) is None:
|
||||
setattr(proxy_server, "open_telemetry_logger", self)
|
||||
|
||||
def _get_or_create_provider(
|
||||
self,
|
||||
|
|
@ -794,12 +801,100 @@ class OpenTelemetry(CustomLogger):
|
|||
# End of Team/Key Based Logging Control Flow
|
||||
#########################################################
|
||||
|
||||
def _emit_once(self, kwargs: dict, *scope: object) -> bool:
|
||||
"""Return True the first time this handler is asked to emit a span
|
||||
for the given (handler, scope) on this kwargs; False on repeats.
|
||||
|
||||
Used to suppress duplicate span emission for two distinct patterns:
|
||||
|
||||
1. **Handler-level dual-fire**: streaming code paths trigger both
|
||||
the sync and async callback for one request, so ``_handle_success``
|
||||
/ ``_handle_failure`` would otherwise produce two
|
||||
``litellm_request`` spans. Scope: ``("success",)`` / ``("failure",)``.
|
||||
2. **Payload-driven multi-entrypoint emission**: a span loop that
|
||||
reads entries from ``standard_logging_payload`` (currently only
|
||||
guardrails) is invoked from multiple lifecycle points
|
||||
(post-call hooks, success callback, failure callback). The list
|
||||
can be re-read with mutated entries between calls, so dedupe
|
||||
must be at entry granularity. Scope: the entry's stable identity.
|
||||
|
||||
``scope`` parts can be any hashable identity. The marker is stored
|
||||
in ``kwargs["litellm_params"]["metadata"]["_otel_internal"]`` so it
|
||||
is request-local (kwargs is shared across the sync/async callbacks
|
||||
and lifecycle hooks for one request).
|
||||
"""
|
||||
litellm_params = kwargs.get("litellm_params")
|
||||
if not isinstance(litellm_params, dict):
|
||||
litellm_params = {}
|
||||
kwargs["litellm_params"] = litellm_params
|
||||
|
||||
_metadata = litellm_params.get("metadata")
|
||||
if not isinstance(_metadata, dict):
|
||||
_metadata = {}
|
||||
litellm_params["metadata"] = _metadata
|
||||
|
||||
_otel_internal = _metadata.get("_otel_internal")
|
||||
if not isinstance(_otel_internal, dict):
|
||||
_otel_internal = {}
|
||||
_metadata["_otel_internal"] = _otel_internal
|
||||
|
||||
spans_logged = _otel_internal.get("spans_logged")
|
||||
if not isinstance(spans_logged, dict):
|
||||
spans_logged = {}
|
||||
_otel_internal["spans_logged"] = spans_logged
|
||||
|
||||
dedupe_key = (self.__class__.__name__, id(self), *scope)
|
||||
if spans_logged.get(dedupe_key) is True:
|
||||
return False
|
||||
|
||||
spans_logged[dedupe_key] = True
|
||||
return True
|
||||
|
||||
def _end_proxy_span_from_kwargs(self, kwargs: dict, end_time) -> None:
|
||||
"""Close the proxy-level parent span if it is still recording.
|
||||
|
||||
This helper retrieves the proxy span directly from kwargs metadata
|
||||
and closes it after all child spans have been recorded.
|
||||
|
||||
Only called from the success path. The failure path deliberately
|
||||
leaves the proxy span open so ``async_post_call_failure_hook`` can
|
||||
append the ``"Failed Proxy Server Request"`` child span before
|
||||
closing it.
|
||||
|
||||
Only spans named ``LITELLM_PROXY_REQUEST_SPAN_NAME`` are closed —
|
||||
externally provided spans must not be closed by LiteLLM.
|
||||
"""
|
||||
litellm_params = kwargs.get("litellm_params", {}) or {}
|
||||
_metadata = litellm_params.get("metadata", {}) or {}
|
||||
proxy_span = _metadata.get("litellm_parent_otel_span", None)
|
||||
if (
|
||||
proxy_span is not None
|
||||
and getattr(proxy_span, "name", None) == LITELLM_PROXY_REQUEST_SPAN_NAME
|
||||
and hasattr(proxy_span, "is_recording")
|
||||
and proxy_span.is_recording()
|
||||
):
|
||||
proxy_span.end(end_time=self._to_ns(end_time))
|
||||
|
||||
def _handle_success(self, kwargs, response_obj, start_time, end_time):
|
||||
"""Create the litellm_request span then close the proxy span."""
|
||||
verbose_logger.debug(
|
||||
"OpenTelemetry Logger: Logging kwargs: %s, OTEL config settings=%s",
|
||||
kwargs,
|
||||
self.config,
|
||||
)
|
||||
|
||||
# sync + async success handlers can both fire for one
|
||||
# request (notably in streaming code paths). Guard against duplicate
|
||||
# span writes — but still close the proxy span on the skip path so
|
||||
# the trace doesn't leak an open root span.
|
||||
if not self._emit_once(kwargs, "success"):
|
||||
verbose_logger.debug(
|
||||
"OpenTelemetry: skipping duplicate success span for handler=%s",
|
||||
self.__class__.__name__,
|
||||
)
|
||||
self._end_proxy_span_from_kwargs(kwargs, end_time)
|
||||
return
|
||||
|
||||
ctx, parent_span = self._get_span_context(kwargs)
|
||||
|
||||
if self.config.ignore_context_propagation:
|
||||
|
|
@ -859,7 +954,7 @@ class OpenTelemetry(CustomLogger):
|
|||
|
||||
# 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
|
||||
# However, proxy-created spans should be closed here.
|
||||
if (
|
||||
parent_span is not None
|
||||
and hasattr(parent_span, "name")
|
||||
|
|
@ -867,6 +962,11 @@ class OpenTelemetry(CustomLogger):
|
|||
):
|
||||
parent_span.end(end_time=self._to_ns(end_time))
|
||||
|
||||
# close the proxy span explicitly from kwargs metadata
|
||||
# after all child spans (litellm_request, guardrail, raw_request)
|
||||
# have been fully recorded and exported.
|
||||
self._end_proxy_span_from_kwargs(kwargs, end_time)
|
||||
|
||||
def _start_primary_span(
|
||||
self,
|
||||
kwargs,
|
||||
|
|
@ -1296,6 +1396,21 @@ class OpenTelemetry(CustomLogger):
|
|||
for guardrail_information in guardrail_information_list:
|
||||
start_time_float = guardrail_information.get("start_time")
|
||||
end_time_float = guardrail_information.get("end_time")
|
||||
|
||||
# ``_create_guardrail_span`` is called from three lifecycle
|
||||
# points (``async_post_call_success_hook``, ``_handle_success``,
|
||||
# ``_handle_failure``) and re-reads the (mutating) entry list
|
||||
# each time. Dedupe at entry granularity so a single real
|
||||
# guardrail invocation produces exactly one span per handler.
|
||||
if not self._emit_once(
|
||||
kwargs,
|
||||
"guardrail",
|
||||
guardrail_information.get("guardrail_name"),
|
||||
start_time_float,
|
||||
guardrail_information.get("guardrail_mode"),
|
||||
):
|
||||
continue
|
||||
|
||||
start_time_datetime = datetime.now()
|
||||
if start_time_float is not None:
|
||||
start_time_datetime = datetime.fromtimestamp(start_time_float)
|
||||
|
|
@ -1349,6 +1464,21 @@ class OpenTelemetry(CustomLogger):
|
|||
kwargs,
|
||||
self.config,
|
||||
)
|
||||
|
||||
# sync + async failure handlers can both fire for one
|
||||
# request (notably in streaming code paths), producing two
|
||||
# semantically identical ERROR spans. Unlike the success path, the
|
||||
# proxy span is intentionally left open here so that
|
||||
# ``async_post_call_failure_hook`` can append the
|
||||
# "Failed Proxy Server Request" child span before closing it —
|
||||
# there is no proxy-span side-effect to preserve on the skip path.
|
||||
if not self._emit_once(kwargs, "failure"):
|
||||
verbose_logger.debug(
|
||||
"OpenTelemetry: skipping duplicate failure span for handler=%s",
|
||||
self.__class__.__name__,
|
||||
)
|
||||
return
|
||||
|
||||
_parent_context, parent_otel_span = self._get_span_context(kwargs)
|
||||
|
||||
if self.config.ignore_context_propagation:
|
||||
|
|
@ -1771,17 +1901,41 @@ class OpenTelemetry(CustomLogger):
|
|||
value=safe_dumps(transformed_messages),
|
||||
)
|
||||
|
||||
if kwargs.get("system_instructions"):
|
||||
transformed_system_instructions = (
|
||||
self._transform_messages_to_otel_semantic_conventions(
|
||||
kwargs.get("system_instructions")
|
||||
# Coalesce the different kwarg names that carry the system
|
||||
# prompt depending on the call path:
|
||||
# - "system_instructions" — Vertex AI Gemini chat-completion
|
||||
# - "instructions" — OpenAI Responses API
|
||||
# - "system" — Anthropic Messages API
|
||||
# Use `is not None` rather than truthiness to avoid falsy
|
||||
# values (e.g. []) falling through to the wrong kwarg.
|
||||
system_instructions = (
|
||||
kwargs.get("system_instructions")
|
||||
if kwargs.get("system_instructions") is not None
|
||||
else (
|
||||
kwargs.get("instructions")
|
||||
if kwargs.get("instructions") is not None
|
||||
else kwargs.get("system")
|
||||
)
|
||||
)
|
||||
if system_instructions:
|
||||
if isinstance(system_instructions, str):
|
||||
# Plain text system prompt — no transformation needed
|
||||
self.safe_set_attribute(
|
||||
span=span,
|
||||
key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value,
|
||||
value=system_instructions,
|
||||
)
|
||||
else:
|
||||
transformed_system_instructions = (
|
||||
self._transform_messages_to_otel_semantic_conventions(
|
||||
system_instructions
|
||||
)
|
||||
)
|
||||
self.safe_set_attribute(
|
||||
span=span,
|
||||
key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value,
|
||||
value=safe_dumps(transformed_system_instructions),
|
||||
)
|
||||
)
|
||||
self.safe_set_attribute(
|
||||
span=span,
|
||||
key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value,
|
||||
value=safe_dumps(transformed_system_instructions),
|
||||
)
|
||||
|
||||
self.safe_set_attribute(
|
||||
span=span,
|
||||
|
|
@ -1840,6 +1994,57 @@ class OpenTelemetry(CustomLogger):
|
|||
value=value,
|
||||
)
|
||||
|
||||
elif response_obj.get("output"):
|
||||
# Responses API: ResponsesAPIResponse has an "output"
|
||||
# list instead of "choices". Each item with
|
||||
# type="message" contains a "content" list of
|
||||
# OutputText objects (type="output_text").
|
||||
output_items = response_obj.get("output")
|
||||
output_messages = self._transform_responses_api_output_to_otel(
|
||||
output_items
|
||||
)
|
||||
if output_messages:
|
||||
self.safe_set_attribute(
|
||||
span=span,
|
||||
key=SpanAttributes.GEN_AI_OUTPUT_MESSAGES.value,
|
||||
value=safe_dumps(output_messages),
|
||||
)
|
||||
|
||||
# Emit per-tool-call span attributes (parity with
|
||||
# the choices branch that calls _tool_calls_kv_pair).
|
||||
# Convert Responses API function_call items to the
|
||||
# ChatCompletionMessageToolCall format expected by
|
||||
# _tool_calls_kv_pair.
|
||||
tool_calls = []
|
||||
for out_item in output_items:
|
||||
item_d = self._to_dict(out_item)
|
||||
if item_d and item_d.get("type") == "function_call":
|
||||
tool_calls.append(
|
||||
{
|
||||
"function": {
|
||||
"name": item_d.get("name", ""),
|
||||
"arguments": item_d.get("arguments", ""),
|
||||
}
|
||||
}
|
||||
)
|
||||
if tool_calls:
|
||||
kv_pairs = OpenTelemetry._tool_calls_kv_pair(tool_calls) # type: ignore
|
||||
for key, value in kv_pairs.items():
|
||||
self.safe_set_attribute(
|
||||
span=span,
|
||||
key=key,
|
||||
value=value,
|
||||
)
|
||||
|
||||
# Extract finish reason from ResponsesAPIResponse.status
|
||||
status = response_obj.get("status")
|
||||
if status:
|
||||
self.safe_set_attribute(
|
||||
span=span,
|
||||
key=SpanAttributes.GEN_AI_RESPONSE_FINISH_REASONS.value,
|
||||
value=safe_dumps([status]),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.handle_callback_failure(
|
||||
callback_name=self.callback_name or "opentelemetry"
|
||||
|
|
@ -1935,6 +2140,78 @@ class OpenTelemetry(CustomLogger):
|
|||
transformed.append(transformed_msg)
|
||||
return transformed
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(obj) -> Optional[dict]:
|
||||
"""Normalize an object to a plain dict.
|
||||
|
||||
Handles three forms that appear in practice:
|
||||
|
||||
1. Plain ``dict`` — returned as-is.
|
||||
2. LiteLLM's ``BaseLiteLLMOpenAIResponseObject`` — exposes a
|
||||
``.get()`` method that delegates to ``__dict__``.
|
||||
3. Raw Pydantic v2 models from the ``openai`` SDK (e.g.
|
||||
``ResponseOutputMessage``, ``ResponseOutputText``) — these do
|
||||
**not** have ``.get()`` but do have ``.model_dump()``.
|
||||
|
||||
Returns ``None`` for anything else so callers can skip it.
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
return obj
|
||||
if hasattr(obj, "get"):
|
||||
# BaseLiteLLMOpenAIResponseObject duck-type
|
||||
return obj # type: ignore[return-value]
|
||||
if hasattr(obj, "model_dump"):
|
||||
# Raw Pydantic v2 model (e.g. openai SDK types)
|
||||
return obj.model_dump() # type: ignore[union-attr]
|
||||
return None
|
||||
|
||||
def _transform_responses_api_output_to_otel(self, output: List) -> List[dict]:
|
||||
"""
|
||||
Transform Responses API output items into OTEL GenAI 1.38 format.
|
||||
|
||||
The Responses API returns output as a list of items, each with a
|
||||
``type`` field. Message items (``type="message"``) contain a
|
||||
``content`` list of ``OutputText`` objects with ``type="output_text"``
|
||||
and ``text`` fields.
|
||||
|
||||
Items may be plain dicts, LiteLLM wrapper objects (with ``.get()``),
|
||||
or raw Pydantic v2 models from the ``openai`` SDK (with
|
||||
``.model_dump()``). We normalize each item to a dict via
|
||||
``_to_dict`` before processing.
|
||||
|
||||
This method converts them to the same ``{"role": ..., "parts": [...]}``
|
||||
format used by ``_transform_choices_to_otel_semantic_conventions``.
|
||||
"""
|
||||
transformed = []
|
||||
for raw_item in output:
|
||||
item = self._to_dict(raw_item)
|
||||
if item is None:
|
||||
continue
|
||||
if item.get("type") == "message":
|
||||
role = item.get("role", "assistant")
|
||||
parts = []
|
||||
for raw_content in item.get("content", []):
|
||||
content = self._to_dict(raw_content)
|
||||
if content is None:
|
||||
continue
|
||||
if content.get("type") == "output_text":
|
||||
text = content.get("text", "")
|
||||
if text:
|
||||
parts.append({"type": "text", "content": text})
|
||||
if parts:
|
||||
transformed.append({"role": role, "parts": parts})
|
||||
elif item.get("type") == "function_call":
|
||||
# Surface tool calls from Responses API output
|
||||
part: dict = {
|
||||
"type": "tool_call",
|
||||
"name": item.get("name", ""),
|
||||
"arguments": item.get("arguments", ""),
|
||||
}
|
||||
if item.get("call_id"):
|
||||
part["id"] = item["call_id"]
|
||||
transformed.append({"role": "assistant", "parts": [part]})
|
||||
return transformed
|
||||
|
||||
def set_raw_request_attributes(self, span: Span, kwargs, response_obj):
|
||||
try:
|
||||
# Only set provider-specific raw payload attributes on this span.
|
||||
|
|
@ -2041,7 +2318,7 @@ class OpenTelemetry(CustomLogger):
|
|||
verbose_logger.debug(
|
||||
"OpenTelemetry: Using explicit parent span from metadata"
|
||||
)
|
||||
return trace.set_span_in_context(parent_otel_span), parent_otel_span
|
||||
return trace.set_span_in_context(parent_otel_span), None
|
||||
|
||||
# Priority 2: HTTP traceparent header
|
||||
if traceparent is not None:
|
||||
|
|
|
|||
|
|
@ -53,8 +53,19 @@ def process_audio_file(audio_file: FileTypes) -> ProcessedAudioFile:
|
|||
# Raw bytes
|
||||
filename = "audio.wav"
|
||||
file_content = bytes(audio_file)
|
||||
elif isinstance(audio_file, (str, os.PathLike)):
|
||||
# File path or PathLike
|
||||
elif isinstance(audio_file, str):
|
||||
# Bare strings are rejected — see extract_file_data for the same
|
||||
# rationale: in a proxy request handler the string is
|
||||
# attacker-controlled, and opening it as a path is an arbitrary
|
||||
# file read.
|
||||
raise ValueError(
|
||||
"process_audio_file does not accept bare str inputs. Pass bytes, "
|
||||
"an open file handle, a (filename, content) tuple, or a "
|
||||
"pathlib.Path."
|
||||
)
|
||||
elif isinstance(audio_file, os.PathLike):
|
||||
# File path or PathLike — PathLike is a Python-level type that
|
||||
# HTTP form values can't fabricate.
|
||||
file_path = str(audio_file)
|
||||
with open(file_path, "rb") as f:
|
||||
file_content = f.read()
|
||||
|
|
@ -66,8 +77,14 @@ def process_audio_file(audio_file: FileTypes) -> ProcessedAudioFile:
|
|||
content = audio_file[1]
|
||||
if isinstance(content, (bytes, bytearray)):
|
||||
file_content = bytes(content)
|
||||
elif isinstance(content, (str, os.PathLike)):
|
||||
# File path or PathLike
|
||||
elif isinstance(content, str):
|
||||
raise ValueError(
|
||||
"process_audio_file does not accept bare str tuple "
|
||||
"contents. Pass bytes, an open file handle, or a "
|
||||
"pathlib.Path."
|
||||
)
|
||||
elif isinstance(content, os.PathLike):
|
||||
# PathLike: SDK convenience for local-file uploads.
|
||||
with open(str(content), "rb") as f:
|
||||
file_content = f.read()
|
||||
elif hasattr(content, "read"):
|
||||
|
|
@ -149,7 +166,14 @@ def get_audio_file_content_hash(file_obj: FileTypes) -> str:
|
|||
try:
|
||||
if isinstance(file_content_obj, (bytes, bytearray)):
|
||||
file_content = bytes(file_content_obj)
|
||||
elif isinstance(file_content_obj, (str, os.PathLike)):
|
||||
elif isinstance(file_content_obj, str):
|
||||
# Bare strings are not treated as file paths in this helper —
|
||||
# the cache-key path is reached from request handlers where the
|
||||
# value is attacker-controlled. Fall back to hashing the string
|
||||
# itself rather than opening it.
|
||||
fallback_filename = file_content_obj
|
||||
file_content = None
|
||||
elif isinstance(file_content_obj, os.PathLike):
|
||||
try:
|
||||
with open(str(file_content_obj), "rb") as f:
|
||||
file_content = f.read()
|
||||
|
|
@ -229,8 +253,15 @@ def calculate_request_duration(file: FileTypes) -> Optional[float]:
|
|||
if isinstance(file, (bytes, bytearray)):
|
||||
# Raw bytes
|
||||
file_content = bytes(file)
|
||||
elif isinstance(file, (str, os.PathLike)):
|
||||
# File path
|
||||
elif isinstance(file, str):
|
||||
# Bare strings are rejected — see extract_file_data.
|
||||
raise ValueError(
|
||||
"calculate_request_duration does not accept bare str inputs. "
|
||||
"Pass bytes, an open file handle, a (filename, content) "
|
||||
"tuple, or a pathlib.Path."
|
||||
)
|
||||
elif isinstance(file, os.PathLike):
|
||||
# File path (PathLike): SDK convenience.
|
||||
with open(str(file), "rb") as f:
|
||||
file_content = f.read()
|
||||
elif isinstance(file, tuple):
|
||||
|
|
|
|||
|
|
@ -1212,7 +1212,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
# Log the exact result from the LLM API, for streaming - log the type of response received
|
||||
litellm.error_logs["POST_CALL"] = locals()
|
||||
if isinstance(original_response, dict):
|
||||
original_response = json.dumps(original_response)
|
||||
original_response = json.dumps(original_response, default=str)
|
||||
try:
|
||||
self.model_call_details["input"] = input
|
||||
self.model_call_details["api_key"] = api_key
|
||||
|
|
|
|||
|
|
@ -755,14 +755,25 @@ def extract_file_data(file_data: FileTypes) -> ExtractedFileData:
|
|||
else:
|
||||
file_content = file_data
|
||||
# Convert content to bytes
|
||||
if isinstance(file_content, (str, PathLike)):
|
||||
# If it's a path, open and read the file
|
||||
# Extract filename from path if not already set
|
||||
if isinstance(file_content, str):
|
||||
# Bare string inputs are rejected: when this helper runs in a proxy
|
||||
# request handler the string came from an attacker-controlled form
|
||||
# field, and opening it as a path is an arbitrary file read on the
|
||||
# proxy host. SDK callers who want to upload from a path should
|
||||
# either pass a pathlib.Path (a PathLike instance — see the branch
|
||||
# below) or open the file themselves and pass the handle / bytes.
|
||||
raise ValueError(
|
||||
"extract_file_data does not accept bare str inputs. Pass bytes, "
|
||||
"an open file handle, a (filename, content) tuple, or a "
|
||||
"pathlib.Path. To upload a local file from a path, call "
|
||||
"open(path, 'rb') yourself."
|
||||
)
|
||||
if isinstance(file_content, PathLike):
|
||||
# PathLike (pathlib.Path) is a Python-level type that HTTP form
|
||||
# values can't fabricate. Treat as a local file path for SDK
|
||||
# convenience.
|
||||
if filename is None:
|
||||
if isinstance(file_content, PathLike):
|
||||
filename = Path(file_content).name
|
||||
else:
|
||||
filename = Path(str(file_content)).name
|
||||
filename = Path(file_content).name
|
||||
with open(file_content, "rb") as f:
|
||||
content = f.read()
|
||||
elif isinstance(file_content, io.IOBase):
|
||||
|
|
|
|||
|
|
@ -1809,9 +1809,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
Translate messages to anthropic format.
|
||||
"""
|
||||
## VALIDATE REQUEST
|
||||
"""
|
||||
Anthropic doesn't support tool calling without `tools=` param specified.
|
||||
"""
|
||||
"""Anthropic requires ``tools`` when messages include tool blocks; LiteLLM injects a dummy tool if omitted (no ``modify_params`` needed)."""
|
||||
from litellm.litellm_core_utils.prompt_templates.factory import (
|
||||
anthropic_messages_pt,
|
||||
)
|
||||
|
|
@ -1821,16 +1819,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
and messages is not None
|
||||
and has_tool_call_blocks(messages)
|
||||
):
|
||||
if litellm.modify_params:
|
||||
optional_params["tools"], _ = self._map_tools(
|
||||
add_dummy_tool(custom_llm_provider="anthropic")
|
||||
)
|
||||
else:
|
||||
raise litellm.UnsupportedParamsError(
|
||||
message="Anthropic doesn't support tool calling without `tools=` param specified. Pass `tools=` param OR set `litellm.modify_params = True` // `litellm_settings::modify_params: True` to add dummy tool to the request.",
|
||||
model="",
|
||||
llm_provider="anthropic",
|
||||
)
|
||||
optional_params["tools"], _ = self._map_tools(
|
||||
add_dummy_tool(custom_llm_provider="anthropic")
|
||||
)
|
||||
|
||||
# Drop thinking param if thinking is enabled but thinking_blocks are missing
|
||||
# This prevents the error: "Expected thinking or redacted_thinking, but found tool_use"
|
||||
|
|
|
|||
|
|
@ -1428,7 +1428,13 @@ class BaseAWSLLM:
|
|||
|
||||
def _sign_request(
|
||||
self,
|
||||
service_name: Literal["bedrock", "sagemaker", "bedrock-agentcore", "s3vectors"],
|
||||
service_name: Literal[
|
||||
"bedrock",
|
||||
"sagemaker",
|
||||
"bedrock-agentcore",
|
||||
"s3vectors",
|
||||
"aws-external-anthropic",
|
||||
],
|
||||
headers: dict,
|
||||
optional_params: dict,
|
||||
request_data: dict,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,79 @@
|
|||
from datetime import datetime
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
from openai.types.batch import BatchRequestCounts
|
||||
from openai.types.batch import Metadata as OpenAIBatchMetadata
|
||||
|
||||
from litellm.types.utils import LiteLLMBatch
|
||||
|
||||
# AWS Bedrock model-invocation-job statuses → OpenAI Batch statuses.
|
||||
# Mirrors the mapping used by `BedrockBatchesConfig.transform_create_batch_response`
|
||||
# so create / retrieve return consistent statuses.
|
||||
_BEDROCK_MIJ_STATUS_TO_OPENAI = {
|
||||
"Submitted": "validating",
|
||||
"Validating": "validating",
|
||||
"Scheduled": "validating",
|
||||
"InProgress": "in_progress",
|
||||
"Stopping": "cancelling",
|
||||
"Stopped": "cancelled",
|
||||
"Completed": "completed",
|
||||
"PartiallyCompleted": "completed",
|
||||
"Failed": "failed",
|
||||
"Expired": "expired",
|
||||
}
|
||||
|
||||
|
||||
def _extract_region_from_bedrock_arn(arn: str) -> Optional[str]:
|
||||
"""ARN shape: ``arn:aws:bedrock:<region>:<account>:<type>/<id>``"""
|
||||
try:
|
||||
parts = arn.split(":")
|
||||
if len(parts) >= 4 and parts[2] == "bedrock":
|
||||
return parts[3] or None
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _extract_job_id_from_arn(arn: str) -> Optional[str]:
|
||||
"""``arn:aws:bedrock:<region>:<acct>:model-invocation-job/<job-id>`` -> ``<job-id>``."""
|
||||
if ":model-invocation-job/" not in arn:
|
||||
return None
|
||||
return arn.rsplit("/", 1)[-1] or None
|
||||
|
||||
|
||||
def _predict_output_file_uri(
|
||||
output_prefix: str, input_uri: str, job_id: Optional[str]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Compute the deterministic per-job result file URI Bedrock writes to.
|
||||
|
||||
Bedrock lays results out as::
|
||||
|
||||
<output_prefix>/<job-id>/<basename(input_uri)>.out
|
||||
|
||||
We compute it client-side so OpenAI-style ``client.files.content(output_file_id)``
|
||||
works without an extra S3 ``ListObjectsV2`` round-trip. Returns ``None`` if we
|
||||
don't have enough info; callers should fall back to the bare prefix.
|
||||
"""
|
||||
if not output_prefix or not input_uri or not job_id:
|
||||
return None
|
||||
if not output_prefix.endswith("/"):
|
||||
output_prefix = output_prefix + "/"
|
||||
input_basename = input_uri.rsplit("/", 1)[-1]
|
||||
if not input_basename:
|
||||
return None
|
||||
return f"{output_prefix}{job_id}/{input_basename}.out"
|
||||
|
||||
|
||||
def _to_epoch(value: Any) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value)
|
||||
if isinstance(value, datetime):
|
||||
return int(value.timestamp())
|
||||
return None
|
||||
|
||||
|
||||
class BedrockBatchesHandler:
|
||||
"""
|
||||
|
|
@ -97,3 +168,173 @@ class BedrockBatchesHandler:
|
|||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_thread)
|
||||
return future.result()
|
||||
|
||||
@staticmethod
|
||||
def _handle_model_invocation_job_status(
|
||||
batch_id: str,
|
||||
aws_region_name: Optional[str] = None,
|
||||
logging_obj=None,
|
||||
**kwargs,
|
||||
) -> "LiteLLMBatch":
|
||||
"""
|
||||
Handle ``GetModelInvocationJob`` status check for AWS Bedrock bulk batch
|
||||
inference jobs (the ARN type returned by ``CreateModelInvocationJob``).
|
||||
|
||||
``CreateModelInvocationJob`` lives on the Bedrock **control plane**
|
||||
(``bedrock.<region>.amazonaws.com``), distinct from the data-plane
|
||||
``bedrock-runtime`` endpoint that serves Twelve Labs async-invoke ARNs.
|
||||
The two ARN families therefore can't share a handler — see
|
||||
``litellm/batches/main.py`` for the dispatch.
|
||||
|
||||
Args:
|
||||
batch_id: A ``arn:aws:bedrock:<region>:<acct>:model-invocation-job/<id>``
|
||||
ARN (or just the trailing job id; both are accepted by
|
||||
``GetModelInvocationJob``).
|
||||
aws_region_name: Region for the boto3 ``bedrock`` client. If omitted,
|
||||
we fall back to parsing the region out of ``batch_id`` itself.
|
||||
logging_obj: Optional litellm logging object.
|
||||
**kwargs: Optional AWS credential overrides
|
||||
(``aws_access_key_id``, ``aws_secret_access_key``,
|
||||
``aws_session_token``, ``aws_profile_name``,
|
||||
``aws_role_name``, ``aws_session_name``,
|
||||
``aws_web_identity_token``, ``aws_sts_endpoint``,
|
||||
``aws_external_id``). Unknown keys are ignored.
|
||||
|
||||
Returns:
|
||||
``LiteLLMBatch`` shaped like an OpenAI Batch resource. Note that
|
||||
``request_counts`` is always ``(0, 0, 0)`` because
|
||||
``GetModelInvocationJob`` does not surface per-record counts;
|
||||
callers that need accurate counts should parse
|
||||
``manifest.json.out`` from the output S3 prefix.
|
||||
"""
|
||||
try:
|
||||
import boto3
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Missing boto3 to call bedrock. Run 'pip install boto3'."
|
||||
) from exc
|
||||
|
||||
# Resolve region: explicit > parsed-from-ARN > us-east-1 (boto3 default).
|
||||
region = (
|
||||
aws_region_name or _extract_region_from_bedrock_arn(batch_id) or "us-east-1"
|
||||
)
|
||||
|
||||
# Resolve credentials through the same path the rest of the bedrock
|
||||
# provider uses, so model_list / env / role-assumption configs are
|
||||
# honored. We instantiate BedrockBatchesConfig (which extends
|
||||
# BaseAWSLLM) lazily to avoid a circular import at module load.
|
||||
from litellm.llms.bedrock.batches.transformation import BedrockBatchesConfig
|
||||
|
||||
creds = BedrockBatchesConfig().get_credentials(
|
||||
aws_access_key_id=kwargs.get("aws_access_key_id"),
|
||||
aws_secret_access_key=kwargs.get("aws_secret_access_key"),
|
||||
aws_session_token=kwargs.get("aws_session_token"),
|
||||
aws_region_name=region,
|
||||
aws_session_name=kwargs.get("aws_session_name"),
|
||||
aws_profile_name=kwargs.get("aws_profile_name"),
|
||||
aws_role_name=kwargs.get("aws_role_name"),
|
||||
aws_web_identity_token=kwargs.get("aws_web_identity_token"),
|
||||
aws_sts_endpoint=kwargs.get("aws_sts_endpoint"),
|
||||
aws_external_id=kwargs.get("aws_external_id"),
|
||||
)
|
||||
|
||||
client = boto3.client(
|
||||
"bedrock",
|
||||
region_name=region,
|
||||
aws_access_key_id=creds.access_key,
|
||||
aws_secret_access_key=creds.secret_key,
|
||||
aws_session_token=creds.token,
|
||||
)
|
||||
|
||||
if logging_obj is not None:
|
||||
# Use the bare job id in the logged URL so we don't double up the
|
||||
# `model-invocation-job/` segment when `batch_id` is a full ARN.
|
||||
# `GetModelInvocationJob` accepts either form, but only the bare id
|
||||
# produces a sensible-looking URL in logs.
|
||||
url_path_id = _extract_job_id_from_arn(batch_id) or batch_id
|
||||
logging_obj.pre_call(
|
||||
input=batch_id,
|
||||
api_key="",
|
||||
additional_args={
|
||||
"complete_input_dict": {"jobIdentifier": batch_id},
|
||||
"api_base": (
|
||||
f"https://bedrock.{region}.amazonaws.com/"
|
||||
f"model-invocation-job/{url_path_id}"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
response = client.get_model_invocation_job(jobIdentifier=batch_id)
|
||||
|
||||
if logging_obj is not None:
|
||||
logging_obj.post_call(
|
||||
input=batch_id,
|
||||
api_key="",
|
||||
original_response=response,
|
||||
additional_args={"complete_input_dict": {"jobIdentifier": batch_id}},
|
||||
)
|
||||
|
||||
bedrock_status = str(response.get("status", ""))
|
||||
openai_status = cast(
|
||||
Any,
|
||||
_BEDROCK_MIJ_STATUS_TO_OPENAI.get(bedrock_status, "in_progress"),
|
||||
)
|
||||
|
||||
input_uri = (
|
||||
response.get("inputDataConfig", {})
|
||||
.get("s3InputDataConfig", {})
|
||||
.get("s3Uri", "")
|
||||
)
|
||||
output_prefix = (
|
||||
response.get("outputDataConfig", {})
|
||||
.get("s3OutputDataConfig", {})
|
||||
.get("s3Uri", "")
|
||||
)
|
||||
|
||||
# Bedrock returns the output *prefix* the user supplied at job creation.
|
||||
# Actual results land at <prefix>/<job-id>/<basename(input)>.out — we
|
||||
# surface that single-file URI as `output_file_id` so the OpenAI-style
|
||||
# download flow works without an extra S3 listing call. We deliberately
|
||||
# do NOT fall back to the bare prefix when prediction fails: a prefix
|
||||
# is not a downloadable object, so handing it back as `output_file_id`
|
||||
# would reproduce the very NoSuchKey bug this handler exists to fix.
|
||||
# The bare prefix is preserved in metadata for callers that want the
|
||||
# `manifest.json.out` or want to do their own listing.
|
||||
job_arn = response.get("jobArn", batch_id)
|
||||
job_id = _extract_job_id_from_arn(job_arn)
|
||||
output_file_uri = _predict_output_file_uri(output_prefix, input_uri, job_id)
|
||||
|
||||
completed_at = _to_epoch(response.get("endTime"))
|
||||
|
||||
# Note: metadata uses "" (not None) for unknown URIs to satisfy the
|
||||
# OpenAI Batch metadata schema, which is `dict[str, str]`. The
|
||||
# `output_file_id` field on the LiteLLMBatch itself does carry None
|
||||
# correctly (see below), so callers should branch on that, not on
|
||||
# `metadata["output_file_uri"]`.
|
||||
openai_batch_metadata: OpenAIBatchMetadata = {
|
||||
"model_arn": response.get("modelId", ""),
|
||||
"job_arn": job_arn,
|
||||
"job_name": response.get("jobName", ""),
|
||||
"failure_message": response.get("message") or "",
|
||||
"input_s3_uri": input_uri,
|
||||
"output_s3_uri": output_prefix,
|
||||
"output_file_uri": output_file_uri or "",
|
||||
}
|
||||
|
||||
return LiteLLMBatch(
|
||||
id=job_arn,
|
||||
object="batch",
|
||||
status=openai_status,
|
||||
created_at=_to_epoch(response.get("submitTime")) or 0,
|
||||
in_progress_at=_to_epoch(response.get("lastModifiedTime")),
|
||||
completed_at=completed_at if openai_status == "completed" else None,
|
||||
failed_at=completed_at if openai_status == "failed" else None,
|
||||
cancelled_at=completed_at if openai_status == "cancelled" else None,
|
||||
expired_at=completed_at if openai_status == "expired" else None,
|
||||
request_counts=BatchRequestCounts(total=0, completed=0, failed=0),
|
||||
metadata=openai_batch_metadata,
|
||||
completion_window="24h",
|
||||
endpoint="/v1/chat/completions",
|
||||
input_file_id=input_uri,
|
||||
output_file_id=output_file_uri if openai_status == "completed" else None,
|
||||
)
|
||||
|
|
|
|||
8
litellm/llms/bedrock/claude_platform/__init__.py
Normal file
8
litellm/llms/bedrock/claude_platform/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from .transformation import (
|
||||
BedrockClaudePlatformConfig,
|
||||
)
|
||||
from .messages_transformation import (
|
||||
BedrockClaudePlatformMessagesConfig,
|
||||
)
|
||||
|
||||
__all__ = ["BedrockClaudePlatformConfig", "BedrockClaudePlatformMessagesConfig"]
|
||||
107
litellm/llms/bedrock/claude_platform/common_utils.py
Normal file
107
litellm/llms/bedrock/claude_platform/common_utils.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
from typing import Literal, Optional, Tuple
|
||||
|
||||
import litellm
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
|
||||
|
||||
CLAUDE_PLATFORM_SERVICE_NAME: Literal["aws-external-anthropic"] = (
|
||||
"aws-external-anthropic"
|
||||
)
|
||||
CLAUDE_PLATFORM_BEDROCK_ROUTE = "claude_platform/"
|
||||
|
||||
|
||||
def strip_claude_platform_route(model: str) -> str:
|
||||
if model.startswith(CLAUDE_PLATFORM_BEDROCK_ROUTE):
|
||||
return model.replace(CLAUDE_PLATFORM_BEDROCK_ROUTE, "", 1)
|
||||
return model
|
||||
|
||||
|
||||
class BedrockClaudePlatformMixin(BaseAWSLLM):
|
||||
@staticmethod
|
||||
def _get_workspace_id(optional_params: dict, litellm_params: dict) -> Optional[str]:
|
||||
workspace_id = (
|
||||
optional_params.get("workspace_id")
|
||||
or litellm_params.get("workspace_id")
|
||||
or optional_params.get("aws_workspace_id")
|
||||
or litellm_params.get("aws_workspace_id")
|
||||
or optional_params.get("anthropic-workspace-id")
|
||||
or litellm_params.get("anthropic-workspace-id")
|
||||
)
|
||||
if workspace_id is None:
|
||||
workspace_id = optional_params.get(
|
||||
"anthropic_workspace_id"
|
||||
) or litellm_params.get("anthropic_workspace_id")
|
||||
if workspace_id is not None:
|
||||
return str(workspace_id)
|
||||
return get_secret_str("ANTHROPIC_AWS_WORKSPACE_ID") or get_secret_str(
|
||||
"ANTHROPIC_WORKSPACE_ID"
|
||||
)
|
||||
|
||||
def _get_required_aws_region_name(self, optional_params: dict) -> str:
|
||||
aws_region_name = (
|
||||
optional_params.get("aws_region_name")
|
||||
or get_secret_str("AWS_REGION_NAME")
|
||||
or get_secret_str("AWS_REGION")
|
||||
or get_secret_str("AWS_DEFAULT_REGION")
|
||||
)
|
||||
if aws_region_name is None:
|
||||
raise litellm.AuthenticationError(
|
||||
message=(
|
||||
"Missing AWS region for Claude Platform on AWS. Pass "
|
||||
"`aws_region_name` or set a standard AWS region environment value."
|
||||
),
|
||||
llm_provider="bedrock",
|
||||
model="",
|
||||
)
|
||||
self._validate_aws_region_name(str(aws_region_name))
|
||||
return str(aws_region_name)
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
api_base: Optional[str],
|
||||
api_key: Optional[str],
|
||||
model: str,
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
stream: Optional[bool] = None,
|
||||
) -> str:
|
||||
api_base = (
|
||||
api_base
|
||||
or litellm.api_base
|
||||
or get_secret_str("ANTHROPIC_AWS_BASE_URL")
|
||||
or get_secret_str("ANTHROPIC_AWS_API_BASE")
|
||||
)
|
||||
if api_base is None:
|
||||
aws_region_name = self._get_required_aws_region_name(optional_params)
|
||||
api_base = (
|
||||
f"https://{CLAUDE_PLATFORM_SERVICE_NAME}.{aws_region_name}.api.aws"
|
||||
)
|
||||
if not api_base.endswith("/v1/messages"):
|
||||
api_base = f"{api_base.rstrip('/')}/v1/messages"
|
||||
return api_base
|
||||
|
||||
def sign_request(
|
||||
self,
|
||||
headers: dict,
|
||||
optional_params: dict,
|
||||
request_data: dict,
|
||||
api_base: str,
|
||||
api_key: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
stream: Optional[bool] = None,
|
||||
fake_stream: Optional[bool] = None,
|
||||
) -> Tuple[dict, Optional[bytes]]:
|
||||
if api_key or get_secret_str("ANTHROPIC_AWS_API_KEY"):
|
||||
return headers, None
|
||||
|
||||
return self._sign_request(
|
||||
service_name=CLAUDE_PLATFORM_SERVICE_NAME,
|
||||
headers=headers,
|
||||
optional_params=optional_params,
|
||||
request_data=request_data,
|
||||
api_base=api_base,
|
||||
model=model,
|
||||
stream=stream,
|
||||
fake_stream=fake_stream,
|
||||
)
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import litellm
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
|
||||
DEFAULT_ANTHROPIC_API_VERSION,
|
||||
AnthropicMessagesConfig,
|
||||
)
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
||||
from .common_utils import BedrockClaudePlatformMixin, strip_claude_platform_route
|
||||
|
||||
|
||||
class BedrockClaudePlatformMessagesConfig(
|
||||
BedrockClaudePlatformMixin, AnthropicMessagesConfig
|
||||
):
|
||||
def validate_anthropic_messages_environment(
|
||||
self,
|
||||
headers: dict,
|
||||
model: str,
|
||||
messages: List[Any],
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
api_key: Optional[str] = None,
|
||||
api_base: Optional[str] = None,
|
||||
) -> Tuple[dict, Optional[str]]:
|
||||
workspace_id = self._get_workspace_id(optional_params, litellm_params)
|
||||
if workspace_id is None:
|
||||
raise litellm.AuthenticationError(
|
||||
message=(
|
||||
"Missing workspace ID for Claude Platform on AWS. Pass "
|
||||
"`workspace_id` or configure the provider workspace setting."
|
||||
),
|
||||
llm_provider="bedrock",
|
||||
model=model,
|
||||
)
|
||||
|
||||
resolved_api_key = api_key or get_secret_str("ANTHROPIC_AWS_API_KEY")
|
||||
headers = {
|
||||
**headers,
|
||||
"anthropic-version": headers.get(
|
||||
"anthropic-version", DEFAULT_ANTHROPIC_API_VERSION
|
||||
),
|
||||
"content-type": headers.get("content-type", "application/json"),
|
||||
"anthropic-workspace-id": workspace_id,
|
||||
}
|
||||
if resolved_api_key and "x-api-key" not in headers:
|
||||
headers["x-api-key"] = resolved_api_key
|
||||
|
||||
headers = self._update_headers_with_anthropic_beta(
|
||||
headers=headers,
|
||||
optional_params=optional_params,
|
||||
)
|
||||
|
||||
return headers, api_base
|
||||
|
||||
def transform_anthropic_messages_request(
|
||||
self,
|
||||
model: str,
|
||||
messages: List[Dict],
|
||||
anthropic_messages_optional_request_params: Dict,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> Dict:
|
||||
return super().transform_anthropic_messages_request(
|
||||
model=strip_claude_platform_route(model),
|
||||
messages=messages,
|
||||
anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
|
||||
litellm_params=litellm_params,
|
||||
headers=headers,
|
||||
)
|
||||
94
litellm/llms/bedrock/claude_platform/transformation.py
Normal file
94
litellm/llms/bedrock/claude_platform/transformation.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import litellm
|
||||
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
|
||||
from .common_utils import BedrockClaudePlatformMixin
|
||||
|
||||
|
||||
class BedrockClaudePlatformConfig(BedrockClaudePlatformMixin, AnthropicConfig):
|
||||
"""
|
||||
Bedrock Claude Platform uses Anthropic's Messages API with AWS gateway auth.
|
||||
"""
|
||||
|
||||
@property
|
||||
def custom_llm_provider(self) -> Optional[str]:
|
||||
return "bedrock"
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict,
|
||||
model: str,
|
||||
messages: List[AllMessageValues],
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
api_key: Optional[str] = None,
|
||||
api_base: Optional[str] = None,
|
||||
) -> Dict:
|
||||
workspace_id = self._get_workspace_id(optional_params, litellm_params)
|
||||
if workspace_id is None:
|
||||
raise litellm.AuthenticationError(
|
||||
message=(
|
||||
"Missing workspace ID for Claude Platform on AWS. Pass "
|
||||
"`workspace_id` or configure the provider workspace setting."
|
||||
),
|
||||
llm_provider="bedrock",
|
||||
model=model,
|
||||
)
|
||||
|
||||
api_key = api_key or get_secret_str("ANTHROPIC_AWS_API_KEY")
|
||||
anthropic_headers = self.get_anthropic_headers(
|
||||
api_key=api_key,
|
||||
auth_token=None,
|
||||
computer_tool_used=self.is_computer_tool_used(
|
||||
tools=optional_params.get("tools")
|
||||
),
|
||||
prompt_caching_set=self.is_cache_control_set(messages=messages),
|
||||
pdf_used=self.is_pdf_used(messages=messages),
|
||||
file_id_used=self.is_file_id_used(messages=messages),
|
||||
mcp_server_used=self.is_mcp_server_used(
|
||||
mcp_servers=optional_params.get("mcp_servers")
|
||||
),
|
||||
web_search_tool_used=self.is_web_search_tool_used(
|
||||
tools=optional_params.get("tools")
|
||||
),
|
||||
tool_search_used=self.is_tool_search_used(
|
||||
tools=optional_params.get("tools")
|
||||
),
|
||||
programmatic_tool_calling_used=self.is_programmatic_tool_calling_used(
|
||||
tools=optional_params.get("tools")
|
||||
),
|
||||
input_examples_used=self.is_input_examples_used(
|
||||
tools=optional_params.get("tools")
|
||||
),
|
||||
effort_used=self.is_effort_used(
|
||||
optional_params=optional_params, model=model
|
||||
),
|
||||
user_anthropic_beta_headers=self._get_user_anthropic_beta_headers(
|
||||
anthropic_beta_header=headers.get("anthropic-beta")
|
||||
),
|
||||
code_execution_tool_used=self.is_code_execution_tool_used(
|
||||
tools=optional_params.get("tools")
|
||||
),
|
||||
container_with_skills_used=self.is_container_with_skills_used(
|
||||
optional_params=optional_params
|
||||
),
|
||||
)
|
||||
anthropic_headers["anthropic-workspace-id"] = workspace_id
|
||||
return {**headers, **anthropic_headers}
|
||||
|
||||
def get_model_response_iterator(
|
||||
self,
|
||||
streaming_response: Any,
|
||||
sync_stream: bool,
|
||||
json_mode: Optional[bool] = False,
|
||||
) -> Any:
|
||||
from litellm.llms.anthropic.chat.handler import ModelResponseIterator
|
||||
|
||||
return ModelResponseIterator(
|
||||
streaming_response=streaming_response,
|
||||
sync_stream=sync_stream,
|
||||
json_mode=bool(json_mode),
|
||||
)
|
||||
|
|
@ -692,6 +692,7 @@ class BedrockModelInfo(BaseLLMModelInfo):
|
|||
) -> Literal[
|
||||
"converse",
|
||||
"invoke",
|
||||
"claude_platform",
|
||||
"converse_like",
|
||||
"agent",
|
||||
"agentcore",
|
||||
|
|
@ -706,6 +707,7 @@ class BedrockModelInfo(BaseLLMModelInfo):
|
|||
str,
|
||||
Literal[
|
||||
"invoke",
|
||||
"claude_platform",
|
||||
"converse_like",
|
||||
"converse",
|
||||
"agent",
|
||||
|
|
@ -716,6 +718,7 @@ class BedrockModelInfo(BaseLLMModelInfo):
|
|||
],
|
||||
] = {
|
||||
"invoke/": "invoke",
|
||||
"claude_platform/": "claude_platform",
|
||||
"converse_like/": "converse_like",
|
||||
"converse/": "converse",
|
||||
"agent/": "agent",
|
||||
|
|
@ -753,6 +756,36 @@ class BedrockModelInfo(BaseLLMModelInfo):
|
|||
"""
|
||||
return "converse/" in model
|
||||
|
||||
@staticmethod
|
||||
def _explicit_claude_platform_route(model: str) -> bool:
|
||||
"""
|
||||
Check if the model is an explicit Claude Platform on AWS route.
|
||||
"""
|
||||
return "claude_platform/" in model
|
||||
|
||||
@staticmethod
|
||||
def get_claude_platform_model(model: str) -> str:
|
||||
"""
|
||||
Strip the Claude Platform route prefix from a Bedrock model name.
|
||||
"""
|
||||
return model.replace("claude_platform/", "", 1)
|
||||
|
||||
@staticmethod
|
||||
def map_claude_platform_auth_params(
|
||||
passed_params: dict, optional_params: dict
|
||||
) -> dict:
|
||||
"""
|
||||
Map Claude Platform route auth params that are not OpenAI request params.
|
||||
"""
|
||||
for key in (
|
||||
"workspace_id",
|
||||
"aws_workspace_id",
|
||||
"anthropic_workspace_id",
|
||||
):
|
||||
if key in passed_params:
|
||||
optional_params[key] = passed_params[key]
|
||||
return optional_params
|
||||
|
||||
@staticmethod
|
||||
def _explicit_invoke_route(model: str) -> bool:
|
||||
"""
|
||||
|
|
@ -815,6 +848,12 @@ class BedrockModelInfo(BaseLLMModelInfo):
|
|||
All other routes should return None since they will go through litellm.completion
|
||||
"""
|
||||
|
||||
#########################################################
|
||||
# Claude Platform route uses Anthropic Messages API via the AWS gateway.
|
||||
#########################################################
|
||||
if BedrockModelInfo._explicit_claude_platform_route(model):
|
||||
return litellm.BedrockClaudePlatformMessagesConfig()
|
||||
|
||||
#########################################################
|
||||
# Converse routes should go through litellm.completion()
|
||||
if BedrockModelInfo._explicit_converse_route(model):
|
||||
|
|
@ -860,7 +899,9 @@ def get_bedrock_chat_config(model: str):
|
|||
base_model = BedrockModelInfo.get_base_model(model)
|
||||
|
||||
# Handle explicit routes first
|
||||
if bedrock_route == "converse" or bedrock_route == "converse_like":
|
||||
if bedrock_route == "claude_platform":
|
||||
return litellm.BedrockClaudePlatformConfig()
|
||||
elif bedrock_route == "converse" or bedrock_route == "converse_like":
|
||||
return litellm.AmazonConverseConfig()
|
||||
elif bedrock_route == "openai":
|
||||
return litellm.AmazonBedrockOpenAIConfig()
|
||||
|
|
|
|||
|
|
@ -408,6 +408,47 @@ class AmazonAnthropicClaudeMessagesConfig(
|
|||
if self._supports_tool_search_on_bedrock(model):
|
||||
beta_set.add("tool-search-tool-2025-10-19")
|
||||
|
||||
@staticmethod
|
||||
def _filter_context_management_for_bedrock_invoke(
|
||||
anthropic_messages_request: Dict,
|
||||
beta_set: set,
|
||||
) -> None:
|
||||
"""
|
||||
Bedrock InvokeModel accepts ``context_management`` only when it carries
|
||||
``compact_20260112`` edits paired with the ``compact-2026-01-12``
|
||||
anthropic-beta header. Other edit types (notably ``clear_thinking_20251015``,
|
||||
which Claude Code sends on every request) are LiteLLM-internal and would
|
||||
cause Bedrock to 400 with ``"context_management: Extra inputs are not
|
||||
permitted"``.
|
||||
|
||||
Filter the edits list to the supported subset, add the beta header when
|
||||
compact edits remain, and drop ``context_management`` entirely when no
|
||||
supported edits are left so the safety-net allowlist can pass it through.
|
||||
|
||||
Ref: https://github.com/BerriAI/litellm/issues/27532
|
||||
"""
|
||||
cm = anthropic_messages_request.get("context_management")
|
||||
if not isinstance(cm, dict):
|
||||
return
|
||||
edits = cm.get("edits")
|
||||
if not isinstance(edits, list):
|
||||
anthropic_messages_request.pop("context_management", None)
|
||||
return
|
||||
|
||||
compact_edits = [
|
||||
e
|
||||
for e in edits
|
||||
if isinstance(e, dict) and e.get("type") == "compact_20260112"
|
||||
]
|
||||
if compact_edits:
|
||||
beta_set.add("compact-2026-01-12")
|
||||
anthropic_messages_request["context_management"] = {
|
||||
**cm,
|
||||
"edits": compact_edits,
|
||||
}
|
||||
else:
|
||||
anthropic_messages_request.pop("context_management", None)
|
||||
|
||||
def _convert_output_format_to_inline_schema(
|
||||
self,
|
||||
output_format: Dict,
|
||||
|
|
@ -551,6 +592,11 @@ class AmazonAnthropicClaudeMessagesConfig(
|
|||
if injected_thinking_for_clear_thinking:
|
||||
beta_set.add("interleaved-thinking-2025-05-14")
|
||||
|
||||
self._filter_context_management_for_bedrock_invoke(
|
||||
anthropic_messages_request=anthropic_messages_request,
|
||||
beta_set=beta_set,
|
||||
)
|
||||
|
||||
self._get_tool_search_beta_header_for_bedrock(
|
||||
model=model,
|
||||
tool_search_used=tool_search_used,
|
||||
|
|
@ -597,8 +643,9 @@ class AmazonAnthropicClaudeMessagesConfig(
|
|||
anthropic_messages_request.pop("output_config", None)
|
||||
|
||||
# 7. Final safety net: filter top-level fields to the Bedrock Invoke allowlist.
|
||||
# Catches Anthropic-only extensions (context_management, output_config, speed,
|
||||
# mcp_servers, ...) and any future additions Claude Code may start sending.
|
||||
# Catches Anthropic-only extensions (output_config, speed, mcp_servers, ...)
|
||||
# and any future additions Claude Code may start sending. ``context_management``
|
||||
# has already been pre-filtered to its Bedrock-supported subset above.
|
||||
allowed = self.BEDROCK_INVOKE_ALLOWED_TOP_LEVEL_FIELDS
|
||||
stripped = sorted(k for k in anthropic_messages_request if k not in allowed)
|
||||
if stripped:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@
|
|||
from typing import Optional, Union
|
||||
|
||||
import litellm
|
||||
from litellm.utils import _is_explicitly_disabled_factory, _supports_factory
|
||||
from litellm.utils import (
|
||||
_is_explicitly_disabled_factory,
|
||||
_supports_factory,
|
||||
)
|
||||
|
||||
from .gpt_transformation import OpenAIGPTConfig
|
||||
|
||||
|
|
|
|||
|
|
@ -156,5 +156,17 @@ class OVHCloudAudioTranscriptionConfig(BaseAudioTranscriptionConfig):
|
|||
text = response_json.get("text") or response_json.get("transcript") or ""
|
||||
response = TranscriptionResponse(text=text)
|
||||
|
||||
# OVHCloud field migration (deadline: 2026-05-11):
|
||||
# `duration` is replaced by `seconds` in STT responses.
|
||||
# Prefer `seconds`, fall back to `duration`, normalize to `duration`
|
||||
# so downstream consumers see a consistent key.
|
||||
duration = (
|
||||
response_json["seconds"]
|
||||
if "seconds" in response_json and response_json["seconds"] is not None
|
||||
else response_json.get("duration")
|
||||
)
|
||||
if duration is not None:
|
||||
response_json["duration"] = duration
|
||||
|
||||
response._hidden_params = response_json
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig
|
|||
from litellm.llms.ovhcloud.utils import OVHCloudException
|
||||
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
|
||||
|
||||
|
|
@ -98,10 +99,16 @@ class OVHCloudChatCompletionStreamingHandler(BaseModelResponseIterator):
|
|||
|
||||
new_choices = []
|
||||
for choice in chunk["choices"]:
|
||||
if "delta" in choice and "reasoning" in choice["delta"]:
|
||||
choice["delta"]["reasoning_content"] = choice["delta"].get(
|
||||
"reasoning"
|
||||
)
|
||||
if "delta" in choice:
|
||||
delta = choice["delta"]
|
||||
# OVHCloud field migration (deadline: 2026-05-11):
|
||||
# `reasoning_content` is replaced by `reasoning`.
|
||||
# Normalise to `reasoning_content` so downstream consumers
|
||||
# see a consistent key during the transition window.
|
||||
reasoning_new = delta.get("reasoning")
|
||||
reasoning_legacy = delta.get("reasoning_content")
|
||||
if reasoning_new is not None and reasoning_legacy is None:
|
||||
delta["reasoning_content"] = reasoning_new
|
||||
new_choices.append(choice)
|
||||
|
||||
return ModelResponseStream(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# LiteLLM main module: public completion, embedding, streaming, and moderation entrypoints.
|
||||
#
|
||||
# +-----------------------------------------------+
|
||||
# | |
|
||||
# | Give Feedback / Get Help |
|
||||
|
|
@ -59,7 +61,13 @@ import litellm
|
|||
from litellm import client
|
||||
|
||||
# Other utils are imported directly to avoid circular imports
|
||||
from litellm.utils import exception_type, get_litellm_params, get_optional_params
|
||||
from litellm.utils import (
|
||||
exception_type,
|
||||
get_litellm_params,
|
||||
get_optional_params,
|
||||
peek_reasoning_summary_aliases,
|
||||
strip_reasoning_summary_aliases_from_optional_params,
|
||||
)
|
||||
|
||||
# Logging is imported lazily when needed to avoid loading litellm_logging at import time
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -946,6 +954,7 @@ def responses_api_bridge_check(
|
|||
web_search_options: Optional[OpenAIWebSearchOptions] = None,
|
||||
tools: Optional[List[Any]] = None,
|
||||
reasoning_effort: Optional[Any] = None,
|
||||
reasoning_summary: Optional[Any] = None,
|
||||
) -> Tuple[dict, str]:
|
||||
model_info: Dict[str, Any] = {}
|
||||
|
||||
|
|
@ -982,14 +991,23 @@ def responses_api_bridge_check(
|
|||
mode = "responses"
|
||||
model_info["mode"] = mode
|
||||
|
||||
# OpenAI/Azure gpt-5.4+ chat-completions calls with both tools + reasoning_effort
|
||||
# must be bridged to Responses API.
|
||||
# OpenAI/Azure GPT-5 chat-completions that need Responses-only fields (e.g.
|
||||
# ``reasoningSummary`` in ``extra_body``) must be bridged; Chat Completions rejects
|
||||
# those keys.
|
||||
#
|
||||
# - gpt-5.4+: tools + reasoning_effort (original) or any reasoning-summary alias.
|
||||
# - Older GPT-5 names (e.g. ``gpt-5``, ``gpt-5.1``): bridge only when a reasoning
|
||||
# summary alias is present with ``reasoning_effort`` (tools alone stay on chat).
|
||||
if (
|
||||
custom_llm_provider in ("openai", "azure")
|
||||
and OpenAIGPT5Config.is_model_gpt_5_4_plus_model(model)
|
||||
and tools
|
||||
and reasoning_effort is not None
|
||||
and model_info.get("mode") != "responses"
|
||||
and OpenAIGPT5Config.is_model_gpt_5_model(model)
|
||||
and not OpenAIGPT5Config.is_model_gpt_5_search_model(model)
|
||||
and reasoning_effort is not None
|
||||
and (
|
||||
reasoning_summary is not None
|
||||
or (OpenAIGPT5Config.is_model_gpt_5_4_plus_model(model) and tools)
|
||||
)
|
||||
):
|
||||
model_info["mode"] = "responses"
|
||||
model = model.replace("responses/", "")
|
||||
|
|
@ -1510,7 +1528,11 @@ def completion( # type: ignore # noqa: PLR0915
|
|||
"logit_bias": logit_bias,
|
||||
"user": user,
|
||||
# params to identify the model
|
||||
"model": model,
|
||||
"model": (
|
||||
model_info.get("base_model")
|
||||
if isinstance(model_info, dict) and model_info.get("base_model")
|
||||
else model
|
||||
),
|
||||
"custom_llm_provider": custom_llm_provider,
|
||||
"response_format": response_format,
|
||||
"seed": seed,
|
||||
|
|
@ -1634,8 +1656,10 @@ def completion( # type: ignore # noqa: PLR0915
|
|||
## RESPONSES API BRIDGE LOGIC ## - check if model has 'mode: responses' in litellm.model_cost map
|
||||
# Only run the second bridge check if the first one didn't already
|
||||
# detect responses mode (e.g. via the "responses/" prefix). The second
|
||||
# check handles cases like gpt-5.4+ with tools+reasoning_effort that
|
||||
# the first (early) check doesn't cover.
|
||||
# check handles cases like gpt-5.4+ with tools+reasoning_effort or
|
||||
# reasoningSummary/reasoning_summary without tools (AI SDK) that the first
|
||||
# (early) check doesn't cover.
|
||||
_reasoning_summary_for_bridge = peek_reasoning_summary_aliases(optional_params)
|
||||
if responses_api_model_info.get("mode") != "responses":
|
||||
responses_api_model_info, model = responses_api_bridge_check(
|
||||
model=model,
|
||||
|
|
@ -1643,14 +1667,29 @@ def completion( # type: ignore # noqa: PLR0915
|
|||
web_search_options=web_search_options,
|
||||
tools=tools,
|
||||
reasoning_effort=reasoning_effort,
|
||||
reasoning_summary=_reasoning_summary_for_bridge,
|
||||
)
|
||||
|
||||
if responses_api_model_info.get("mode") == "responses":
|
||||
from litellm.completion_extras import responses_api_bridge
|
||||
|
||||
optional_params, rs_val = (
|
||||
strip_reasoning_summary_aliases_from_optional_params(optional_params)
|
||||
)
|
||||
|
||||
if isinstance(reasoning_effort, dict) and "summary" in reasoning_effort:
|
||||
optional_params = dict(optional_params)
|
||||
optional_params["reasoning_effort"] = reasoning_effort
|
||||
elif rs_val is not None:
|
||||
eff = optional_params.get("reasoning_effort", reasoning_effort)
|
||||
if isinstance(eff, dict):
|
||||
optional_params["reasoning_effort"] = {**eff, "summary": rs_val}
|
||||
elif eff is not None:
|
||||
optional_params["reasoning_effort"] = {
|
||||
"effort": eff,
|
||||
"summary": rs_val,
|
||||
}
|
||||
else:
|
||||
optional_params["reasoning_effort"] = {"summary": rs_val}
|
||||
|
||||
return responses_api_bridge.completion(
|
||||
model=model,
|
||||
|
|
@ -1669,6 +1708,16 @@ def completion( # type: ignore # noqa: PLR0915
|
|||
encoding=_get_encoding(),
|
||||
stream=stream,
|
||||
)
|
||||
elif (
|
||||
custom_llm_provider == "openai"
|
||||
and OpenAIGPT5Config.is_model_gpt_5_model(model)
|
||||
) or (
|
||||
custom_llm_provider == "azure"
|
||||
and litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model(model)
|
||||
):
|
||||
optional_params, _ = strip_reasoning_summary_aliases_from_optional_params(
|
||||
optional_params
|
||||
)
|
||||
|
||||
if custom_llm_provider == "azure":
|
||||
# azure configs
|
||||
|
|
@ -3813,7 +3862,33 @@ def completion( # type: ignore # noqa: PLR0915
|
|||
)
|
||||
|
||||
bedrock_route = BedrockModelInfo.get_bedrock_route(model)
|
||||
if bedrock_route == "converse":
|
||||
if bedrock_route == "claude_platform":
|
||||
provider_config = ProviderConfigManager.get_provider_chat_config(
|
||||
model=model,
|
||||
provider=LlmProviders.BEDROCK,
|
||||
)
|
||||
model = BedrockModelInfo.get_claude_platform_model(model)
|
||||
response = base_llm_http_handler.completion(
|
||||
model=model,
|
||||
stream=stream,
|
||||
messages=messages,
|
||||
acompletion=acompletion,
|
||||
api_base=api_base,
|
||||
model_response=model_response,
|
||||
optional_params=optional_params,
|
||||
litellm_params=litellm_params,
|
||||
shared_session=shared_session,
|
||||
custom_llm_provider="bedrock",
|
||||
timeout=timeout,
|
||||
headers=headers,
|
||||
encoding=_get_encoding(),
|
||||
api_key=api_key,
|
||||
logging_obj=logging,
|
||||
client=client,
|
||||
provider_config=provider_config,
|
||||
)
|
||||
return response
|
||||
elif bedrock_route == "converse":
|
||||
model = model.replace("converse/", "")
|
||||
response = bedrock_converse_chat_completion.completion(
|
||||
model=model,
|
||||
|
|
|
|||
|
|
@ -21104,6 +21104,38 @@
|
|||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"gpt-realtime-2": {
|
||||
"cache_creation_input_audio_token_cost": 4e-07,
|
||||
"cache_read_input_token_cost": 4e-07,
|
||||
"input_cost_per_audio_token": 3.2e-05,
|
||||
"input_cost_per_image": 5e-06,
|
||||
"input_cost_per_token": 4e-06,
|
||||
"litellm_provider": "openai",
|
||||
"max_input_tokens": 32000,
|
||||
"max_output_tokens": 4096,
|
||||
"max_tokens": 4096,
|
||||
"mode": "chat",
|
||||
"output_cost_per_audio_token": 6.4e-05,
|
||||
"output_cost_per_token": 1.6e-05,
|
||||
"supported_endpoints": [
|
||||
"/v1/realtime"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"audio"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supports_audio_input": true,
|
||||
"supports_audio_output": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"gpt-realtime-mini": {
|
||||
"cache_creation_input_audio_token_cost": 3e-07,
|
||||
"cache_read_input_audio_token_cost": 3e-07,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import os
|
|||
import re
|
||||
from functools import partial
|
||||
from io import IOBase
|
||||
from pathlib import Path
|
||||
from typing import Any, Coroutine, Dict, Optional, Union
|
||||
|
||||
import httpx
|
||||
|
|
@ -376,11 +375,13 @@ def convert_file_document_to_url_document(document: Dict[str, Any]) -> Dict[str,
|
|||
with an inline base64 data URI.
|
||||
|
||||
Accepts document dicts like:
|
||||
{"type": "file", "file": "/path/to/document.pdf"} # file path string
|
||||
{"type": "file", "file": Path("/path/to/doc.pdf")} # pathlib.Path
|
||||
{"type": "file", "file": <binary file-like object>} # file-like object (BinaryIO)
|
||||
{"type": "file", "file": b"raw bytes"} # raw bytes
|
||||
|
||||
Bare ``str`` paths are not accepted — pass a ``pathlib.Path`` or
|
||||
``open(path, "rb")`` instead. See the str check below for the rationale.
|
||||
|
||||
Returns:
|
||||
{"type": "document_url", "document_url": "data:<mime>;base64,<data>"}
|
||||
or {"type": "image_url", "image_url": "data:<mime>;base64,<data>"}
|
||||
|
|
@ -389,14 +390,28 @@ def convert_file_document_to_url_document(document: Dict[str, Any]) -> Dict[str,
|
|||
if file_input is None:
|
||||
raise ValueError(
|
||||
"document with type='file' must include a 'file' field containing "
|
||||
"a file path (str), pathlib.Path, file-like object, or bytes"
|
||||
"a pathlib.Path, file-like object, or bytes"
|
||||
)
|
||||
|
||||
file_bytes: bytes
|
||||
mime_type: str = "application/octet-stream"
|
||||
file_name: Optional[str] = None
|
||||
|
||||
if isinstance(file_input, (str, Path)):
|
||||
if isinstance(file_input, str):
|
||||
# Bare strings are rejected here. The OCR ``document`` accepts a
|
||||
# ``{"type": "file", "file": <value>}`` shape, and when this helper
|
||||
# runs in a proxy request handler ``<value>`` is attacker-controlled.
|
||||
# Opening it as a path is an arbitrary local file read on the proxy
|
||||
# host, which is then base64-encoded and forwarded to the OCR
|
||||
# provider — an exfiltration primitive.
|
||||
raise ValueError(
|
||||
"OCR file input does not accept bare str values. Pass bytes, "
|
||||
"a pathlib.Path, or a file-like object. To OCR a local file "
|
||||
"from a path, call open(path, 'rb') yourself."
|
||||
)
|
||||
if isinstance(file_input, os.PathLike):
|
||||
# os.PathLike (pathlib.Path and custom __fspath__ classes) is a
|
||||
# Python-level type that HTTP form values can't fabricate.
|
||||
file_path = str(file_input)
|
||||
if not os.path.isfile(file_path):
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
|
@ -417,7 +432,7 @@ def convert_file_document_to_url_document(document: Dict[str, Any]) -> Dict[str,
|
|||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported file input type: {type(file_input)}. "
|
||||
"Expected str (file path), pathlib.Path, bytes, or a file-like object."
|
||||
"Expected pathlib.Path, bytes, or a file-like object."
|
||||
)
|
||||
|
||||
if not file_bytes:
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
)
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import (
|
||||
TOKEN_NO_CACHE_HEADERS,
|
||||
validate_loopback_redirect_uri,
|
||||
get_request_base_url,
|
||||
validate_trusted_redirect_uri,
|
||||
)
|
||||
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
|
||||
|
|
@ -29,51 +30,6 @@ router = APIRouter(
|
|||
)
|
||||
|
||||
|
||||
def get_request_base_url(request: Request) -> str:
|
||||
"""
|
||||
Get the base URL for the request, considering X-Forwarded-* headers.
|
||||
|
||||
X-Forwarded-Proto / X-Forwarded-Host / X-Forwarded-Port are only honoured
|
||||
when the request comes from a configured trusted proxy
|
||||
(``use_x_forwarded_for`` enabled AND caller in ``mcp_trusted_proxy_ranges``).
|
||||
Otherwise the request's literal ``base_url`` is returned, so an
|
||||
untrusted caller cannot poison OAuth-discovery / redirect_uri values
|
||||
by injecting headers.
|
||||
|
||||
Args:
|
||||
request: FastAPI Request object
|
||||
|
||||
Returns:
|
||||
The reconstructed base URL (e.g., "https://proxy.example.com")
|
||||
"""
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
parsed = urlparse(base_url)
|
||||
|
||||
if not IPAddressUtils.is_request_from_trusted_proxy(request):
|
||||
return base_url
|
||||
|
||||
x_forwarded_proto = request.headers.get("X-Forwarded-Proto")
|
||||
x_forwarded_host = request.headers.get("X-Forwarded-Host")
|
||||
x_forwarded_port = request.headers.get("X-Forwarded-Port")
|
||||
|
||||
scheme = x_forwarded_proto if x_forwarded_proto else parsed.scheme
|
||||
|
||||
if x_forwarded_host:
|
||||
# X-Forwarded-Host may already include port (e.g., "example.com:8080")
|
||||
if ":" in x_forwarded_host and not x_forwarded_host.startswith("["):
|
||||
netloc = x_forwarded_host
|
||||
elif x_forwarded_port:
|
||||
netloc = f"{x_forwarded_host}:{x_forwarded_port}"
|
||||
else:
|
||||
netloc = x_forwarded_host
|
||||
else:
|
||||
netloc = parsed.netloc
|
||||
if x_forwarded_port and ":" not in netloc:
|
||||
netloc = f"{netloc}:{x_forwarded_port}"
|
||||
|
||||
return urlunparse((scheme, netloc, parsed.path, "", "", ""))
|
||||
|
||||
|
||||
def encode_state_with_base_url(
|
||||
base_url: str,
|
||||
original_state: str,
|
||||
|
|
@ -127,12 +83,14 @@ def decode_state_hash(encrypted_state: str) -> dict:
|
|||
return state_data
|
||||
|
||||
|
||||
def _get_validated_client_redirect_uri(state_data: Dict[str, Any]) -> str:
|
||||
"""Return a loopback client redirect URI from OAuth state."""
|
||||
def _get_validated_client_redirect_uri(
|
||||
request: Request, state_data: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Return a trusted (same-origin or loopback) client redirect URI from OAuth state."""
|
||||
redirect_uri = state_data.get("client_redirect_uri") or state_data.get("base_url")
|
||||
if not redirect_uri or not isinstance(redirect_uri, str):
|
||||
raise HTTPException(status_code=400, detail="Invalid redirect URI")
|
||||
validate_loopback_redirect_uri(redirect_uri)
|
||||
validate_trusted_redirect_uri(request, redirect_uri)
|
||||
return redirect_uri
|
||||
|
||||
|
||||
|
|
@ -338,12 +296,12 @@ async def authorize_with_server(
|
|||
status_code=400, detail="MCP server authorization url is not set"
|
||||
)
|
||||
|
||||
# Loopback-only redirect_uri. The URI is encrypted into the OAuth
|
||||
# state and decoded on /callback to redirect the user back; a non-
|
||||
# loopback URI would be an open-redirect + code-theft primitive
|
||||
# (VERIA-57 root cause B). MCP clients are native apps — loopback is
|
||||
# the spec-compliant callback pattern.
|
||||
validate_loopback_redirect_uri(redirect_uri)
|
||||
# Loopback OR same-origin redirect_uri. The URI is encrypted into the
|
||||
# OAuth state and decoded on /callback to redirect the user back;
|
||||
# restricting to trusted origins blocks the open-redirect +
|
||||
# code-theft primitive (VERIA-57 root cause B). Loopback supports
|
||||
# native MCP clients; same-origin supports the proxy's own UI callback.
|
||||
validate_trusted_redirect_uri(request, redirect_uri)
|
||||
parsed = urlparse(redirect_uri)
|
||||
base_url = urlunparse(parsed._replace(query=""))
|
||||
request_base_url = get_request_base_url(request)
|
||||
|
|
@ -660,17 +618,18 @@ async def token_endpoint(
|
|||
|
||||
|
||||
@router.get("/callback")
|
||||
async def callback(code: str, state: str):
|
||||
async def callback(request: Request, code: str, state: str):
|
||||
try:
|
||||
state_data = decode_state_hash(state)
|
||||
original_state = state_data["original_state"]
|
||||
|
||||
# Re-validate loopback at the sink. /authorize rejects non-loopback
|
||||
# Re-validate at the sink. /authorize rejects untrusted
|
||||
# redirect_uri before encoding into state, but encrypted states
|
||||
# minted before that check was added have no expiry and remain
|
||||
# valid indefinitely. Validating here blocks the open-redirect +
|
||||
# code-theft primitive even for pre-fix states.
|
||||
redirect_uri = _get_validated_client_redirect_uri(state_data)
|
||||
# valid indefinitely. Validating here (same-origin OR loopback)
|
||||
# blocks the open-redirect + code-theft primitive even for pre-fix
|
||||
# states while allowing the UI's same-origin callback to work.
|
||||
redirect_uri = _get_validated_client_redirect_uri(request, state_data)
|
||||
|
||||
params = {"code": code, "state": original_state}
|
||||
complete_returned_url = _append_query_params(redirect_uri, params)
|
||||
|
|
|
|||
|
|
@ -599,16 +599,57 @@ class MCPServerManager:
|
|||
)
|
||||
raise e
|
||||
|
||||
def _cleanup_server_tool_routing_artifacts(self, server: MCPServer) -> None:
|
||||
"""Drop OpenAPI global tools and name-mapping rows owned by ``server``.
|
||||
|
||||
When a server leaves ``self.registry`` (eviction, ``remove_server``, etc.),
|
||||
OpenAPI tools remain in ``global_mcp_tool_registry`` and
|
||||
``tool_name_to_mcp_server_name_mapping`` unless removed here. Stale
|
||||
mappings make ``_get_mcp_server_from_tool_name`` resolve to a prefix that
|
||||
no longer exists in the live registry.
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.tool_registry import (
|
||||
global_mcp_tool_registry,
|
||||
)
|
||||
|
||||
prefix_root = normalize_server_name(get_server_prefix(server))
|
||||
if server.spec_path and prefix_root:
|
||||
openapi_key_prefix = prefix_root + MCP_TOOL_PREFIX_SEPARATOR
|
||||
global_mcp_tool_registry.unregister_tools_with_prefix(openapi_key_prefix)
|
||||
|
||||
owned_raw: Set[str] = set()
|
||||
for p in iter_known_server_prefixes(server):
|
||||
if p:
|
||||
owned_raw.add(p)
|
||||
if server.name:
|
||||
owned_raw.add(server.name)
|
||||
|
||||
owned_normalized = {normalize_server_name(x) for x in owned_raw}
|
||||
|
||||
stale_mapping_keys: List[str] = []
|
||||
for tool_name, mapped_server in list(
|
||||
self.tool_name_to_mcp_server_name_mapping.items()
|
||||
):
|
||||
if mapped_server in owned_raw:
|
||||
stale_mapping_keys.append(tool_name)
|
||||
elif normalize_server_name(str(mapped_server)) in owned_normalized:
|
||||
stale_mapping_keys.append(tool_name)
|
||||
|
||||
for key in stale_mapping_keys:
|
||||
del self.tool_name_to_mcp_server_name_mapping[key]
|
||||
|
||||
def remove_server(self, mcp_server: LiteLLM_MCPServerTable):
|
||||
"""
|
||||
Remove a server from the registry
|
||||
"""
|
||||
if mcp_server.server_name in self.get_registry():
|
||||
del self.registry[mcp_server.server_name]
|
||||
verbose_logger.debug(f"Removed MCP Server: {mcp_server.server_name}")
|
||||
elif mcp_server.server_id in self.get_registry():
|
||||
del self.registry[mcp_server.server_id]
|
||||
verbose_logger.debug(f"Removed MCP Server: {mcp_server.server_id}")
|
||||
evicted: Optional[MCPServer] = self.registry.pop(mcp_server.server_id, None)
|
||||
if evicted is None and mcp_server.server_name:
|
||||
evicted = self.registry.pop(mcp_server.server_name, None)
|
||||
if evicted is not None:
|
||||
verbose_logger.debug(
|
||||
"Removed MCP Server: %s", mcp_server.server_id or mcp_server.server_name
|
||||
)
|
||||
self._cleanup_server_tool_routing_artifacts(evicted)
|
||||
else:
|
||||
verbose_logger.warning(
|
||||
f"Server ID {mcp_server.server_id} not found in registry"
|
||||
|
|
@ -806,6 +847,13 @@ class MCPServerManager:
|
|||
self.initialize_tool_name_to_mcp_server_name_mapping()
|
||||
|
||||
async def add_server(self, mcp_server: LiteLLM_MCPServerTable):
|
||||
# The runtime registry is the allowlist for tool calls and health
|
||||
# probes (which spawn the underlying transport, including stdio
|
||||
# subprocesses). Match the eligibility set used by the bulk DB
|
||||
# filter in reload_servers_from_database() — NULL is legacy and
|
||||
# "approved" is a legacy alias for "active".
|
||||
if mcp_server.approval_status not in (None, "active", "approved"):
|
||||
return
|
||||
try:
|
||||
if mcp_server.server_id not in self.registry:
|
||||
new_server = await self.build_mcp_server_from_table(mcp_server)
|
||||
|
|
@ -819,6 +867,16 @@ class MCPServerManager:
|
|||
raise e
|
||||
|
||||
async def update_server(self, mcp_server: LiteLLM_MCPServerTable):
|
||||
# If a previously-active server has been moved out of the active
|
||||
# state, evict any stale registry entry so subsequent tool calls and
|
||||
# health probes can't reach it.
|
||||
if mcp_server.approval_status not in (None, "active", "approved"):
|
||||
evicted = self.registry.pop(mcp_server.server_id, None)
|
||||
if evicted is None and mcp_server.server_name:
|
||||
evicted = self.registry.pop(mcp_server.server_name, None)
|
||||
if evicted is not None:
|
||||
self._cleanup_server_tool_routing_artifacts(evicted)
|
||||
return
|
||||
try:
|
||||
if mcp_server.server_id in self.registry:
|
||||
new_server = await self.build_mcp_server_from_table(mcp_server)
|
||||
|
|
|
|||
|
|
@ -2,15 +2,63 @@
|
|||
(BYOK + discoverable / pass-through OAuth proxy)."""
|
||||
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
|
||||
|
||||
# RFC 6749 §5.1 / OAuth 2.1 draft-15 §4.1.3: token-endpoint responses
|
||||
# must not be cached — both success and error bodies may reveal secrets.
|
||||
TOKEN_NO_CACHE_HEADERS = {"Cache-Control": "no-store", "Pragma": "no-cache"}
|
||||
|
||||
|
||||
def get_request_base_url(request: Request) -> str:
|
||||
"""
|
||||
Get the base URL for the request, considering X-Forwarded-* headers.
|
||||
|
||||
X-Forwarded-Proto / X-Forwarded-Host / X-Forwarded-Port are only honoured
|
||||
when the request comes from a configured trusted proxy
|
||||
(``use_x_forwarded_for`` enabled AND caller in ``mcp_trusted_proxy_ranges``).
|
||||
Otherwise the request's literal ``base_url`` is returned, so an
|
||||
untrusted caller cannot poison OAuth-discovery / redirect_uri values
|
||||
by injecting headers.
|
||||
|
||||
Args:
|
||||
request: FastAPI Request object
|
||||
|
||||
Returns:
|
||||
The reconstructed base URL (e.g., "https://proxy.example.com")
|
||||
"""
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
parsed = urlparse(base_url)
|
||||
|
||||
if not IPAddressUtils.is_request_from_trusted_proxy(request):
|
||||
return base_url
|
||||
|
||||
x_forwarded_proto = request.headers.get("X-Forwarded-Proto")
|
||||
x_forwarded_host = request.headers.get("X-Forwarded-Host")
|
||||
x_forwarded_port = request.headers.get("X-Forwarded-Port")
|
||||
|
||||
scheme = x_forwarded_proto if x_forwarded_proto else parsed.scheme
|
||||
|
||||
if x_forwarded_host:
|
||||
# X-Forwarded-Host may already include port (e.g., "example.com:8080")
|
||||
if ":" in x_forwarded_host and not x_forwarded_host.startswith("["):
|
||||
netloc = x_forwarded_host
|
||||
elif x_forwarded_port:
|
||||
netloc = f"{x_forwarded_host}:{x_forwarded_port}"
|
||||
else:
|
||||
netloc = x_forwarded_host
|
||||
else:
|
||||
netloc = parsed.netloc
|
||||
if x_forwarded_port and ":" not in netloc:
|
||||
netloc = f"{netloc}:{x_forwarded_port}"
|
||||
|
||||
return urlunparse((scheme, netloc, parsed.path, "", "", ""))
|
||||
|
||||
|
||||
def validate_loopback_redirect_uri(redirect_uri: str) -> None:
|
||||
"""Require a loopback ``redirect_uri`` (OAuth 2.1 §4.1.2.1 + RFC 8252
|
||||
§7.3 native-app pattern). MCP clients are native apps that listen on
|
||||
|
|
@ -46,3 +94,60 @@ def validate_loopback_redirect_uri(redirect_uri: str) -> None:
|
|||
# don't let it bubble up as a 500.
|
||||
pass
|
||||
raise HTTPException(status_code=400, detail="invalid_request")
|
||||
|
||||
|
||||
def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None:
|
||||
"""Accept same-origin (proxy's own origin) OR loopback ``redirect_uri``.
|
||||
|
||||
Same-origin is required for the LiteLLM UI's OAuth flow: the UI
|
||||
redirects to ``<proxy>/ui/mcp/oauth/callback`` which is not loopback
|
||||
but is on the proxy's own trusted HTTPS origin. An attacker cannot
|
||||
host content on the proxy's own origin without already owning the
|
||||
proxy, so the open-redirect / code-theft primitive that motivated
|
||||
:func:`validate_loopback_redirect_uri` does not apply here.
|
||||
|
||||
Loopback continues to be accepted for native MCP clients (per
|
||||
OAuth 2.1 §4.1.2.1 + RFC 8252 §7.3).
|
||||
|
||||
Use this in the discoverable OAuth proxy endpoints that serve both
|
||||
native clients and the proxy's own UI. BYOK endpoints that only
|
||||
support native clients should keep
|
||||
:func:`validate_loopback_redirect_uri`.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(redirect_uri)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="invalid_request")
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise HTTPException(status_code=400, detail="invalid_request")
|
||||
if parsed.fragment:
|
||||
raise HTTPException(status_code=400, detail="invalid_request")
|
||||
|
||||
# Same-origin: scheme + netloc (host[:port]) must match the proxy's
|
||||
# own base URL at this request (honouring trusted X-Forwarded-*).
|
||||
try:
|
||||
proxy_base = urlparse(get_request_base_url(request))
|
||||
if (
|
||||
parsed.netloc
|
||||
and parsed.scheme == proxy_base.scheme
|
||||
and parsed.netloc.lower() == proxy_base.netloc.lower()
|
||||
):
|
||||
return
|
||||
except Exception as exc:
|
||||
# If we can't determine the proxy's origin, fall through to
|
||||
# loopback. Log so the failure is diagnosable in production.
|
||||
verbose_logger.warning(
|
||||
"validate_trusted_redirect_uri: could not determine proxy origin, "
|
||||
"falling back to loopback-only check. error=%s",
|
||||
exc,
|
||||
)
|
||||
|
||||
host = (parsed.hostname or "").lower()
|
||||
if host == "localhost":
|
||||
return
|
||||
try:
|
||||
if ip_address(host).is_loopback:
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
raise HTTPException(status_code=400, detail="invalid_request")
|
||||
|
|
|
|||
|
|
@ -59,6 +59,22 @@ class MCPToolRegistry:
|
|||
]
|
||||
return list(self.tools.values())
|
||||
|
||||
def unregister_tools_with_prefix(self, prefix: str) -> int:
|
||||
"""Remove tools whose registered name starts with ``prefix``.
|
||||
|
||||
Used when an OpenAPI-backed MCP server leaves the runtime registry so
|
||||
stale tool handlers cannot be invoked after eviction.
|
||||
"""
|
||||
if not prefix:
|
||||
return 0
|
||||
removed = 0
|
||||
for name in list(self.tools.keys()):
|
||||
if name.startswith(prefix):
|
||||
del self.tools[name]
|
||||
removed += 1
|
||||
verbose_logger.debug("Unregistered MCP tool %s", name)
|
||||
return removed
|
||||
|
||||
def convert_tools_to_mcp_sdk_tool_type(
|
||||
self, tools: List[MCPTool]
|
||||
) -> List["MCPToolSDKTool"]:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,30 +1,30 @@
|
|||
1:"$Sreact.fragment"
|
||||
2:I[347257,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"ClientPageRoot"]
|
||||
3:I[952683,["/litellm-asset-prefix/_next/static/chunks/9e09de50158b3159.js","/litellm-asset-prefix/_next/static/chunks/7e5fe5584502da06.js","/litellm-asset-prefix/_next/static/chunks/0493aafc4891dd29.js","/litellm-asset-prefix/_next/static/chunks/f7e1d08418645368.js","/litellm-asset-prefix/_next/static/chunks/b3d198d6c56a21b8.js","/litellm-asset-prefix/_next/static/chunks/403c4d96324c23a6.js","/litellm-asset-prefix/_next/static/chunks/37e77c06e99eb8ff.js","/litellm-asset-prefix/_next/static/chunks/adb8beb738574863.js","/litellm-asset-prefix/_next/static/chunks/0549bc9afa7d4888.js","/litellm-asset-prefix/_next/static/chunks/0b470ffc60999bf4.js","/litellm-asset-prefix/_next/static/chunks/c847ecdf8c790b0b.js","/litellm-asset-prefix/_next/static/chunks/baadbd26839e7b66.js","/litellm-asset-prefix/_next/static/chunks/ee5f9a39a526e423.js","/litellm-asset-prefix/_next/static/chunks/6eee262391715440.js","/litellm-asset-prefix/_next/static/chunks/4e17b625d75327a7.js","/litellm-asset-prefix/_next/static/chunks/7b788dd93ad868b3.js","/litellm-asset-prefix/_next/static/chunks/a06cc76a774dd182.js","/litellm-asset-prefix/_next/static/chunks/264fd32eefec52b6.js","/litellm-asset-prefix/_next/static/chunks/86828bdbafb8b581.js","/litellm-asset-prefix/_next/static/chunks/10dc4591ef08a91f.js","/litellm-asset-prefix/_next/static/chunks/e099566e8bd4ee4e.js","/litellm-asset-prefix/_next/static/chunks/fbe12a36d22e9554.js","/litellm-asset-prefix/_next/static/chunks/2971c4658f1bcd7d.js","/litellm-asset-prefix/_next/static/chunks/134f728fa7099e3e.js","/litellm-asset-prefix/_next/static/chunks/679dbd657c8b5aef.js","/litellm-asset-prefix/_next/static/chunks/94f7208f5087e27c.js","/litellm-asset-prefix/_next/static/chunks/43f6fc3c2ab9cf23.js","/litellm-asset-prefix/_next/static/chunks/4e06277331e725da.js","/litellm-asset-prefix/_next/static/chunks/3b30ab8eaa03bc21.js","/litellm-asset-prefix/_next/static/chunks/ac3cf77acb5bf234.js","/litellm-asset-prefix/_next/static/chunks/fb125648f2dae104.js","/litellm-asset-prefix/_next/static/chunks/7e417dd24c8becd0.js","/litellm-asset-prefix/_next/static/chunks/a09028cd611c08ef.js","/litellm-asset-prefix/_next/static/chunks/6967a3b4ecbd3785.js","/litellm-asset-prefix/_next/static/chunks/3e917c79aadd945b.js","/litellm-asset-prefix/_next/static/chunks/9bbebdeb3f1cb03f.js","/litellm-asset-prefix/_next/static/chunks/0a65da2cd24e2ab6.js","/litellm-asset-prefix/_next/static/chunks/908828a91f602d8b.js","/litellm-asset-prefix/_next/static/chunks/5f2d62a75803a3f7.js","/litellm-asset-prefix/_next/static/chunks/ca5fbafaf3826374.js","/litellm-asset-prefix/_next/static/chunks/d3ac82723ec9e30d.js","/litellm-asset-prefix/_next/static/chunks/9b0ee76cbdef1a2a.js","/litellm-asset-prefix/_next/static/chunks/1bc2898be56acd1b.js","/litellm-asset-prefix/_next/static/chunks/fcdf7322b0aa3e2e.js","/litellm-asset-prefix/_next/static/chunks/878832edb30e99a4.js","/litellm-asset-prefix/_next/static/chunks/496b84010c33cf69.js","/litellm-asset-prefix/_next/static/chunks/8e3d0ce9505a304f.js","/litellm-asset-prefix/_next/static/chunks/e1f23fd814ac3500.js","/litellm-asset-prefix/_next/static/chunks/88c74f8b4b20d25a.js","/litellm-asset-prefix/_next/static/chunks/99cf9cf99df5ccfc.js","/litellm-asset-prefix/_next/static/chunks/4980372eaa37b78b.js","/litellm-asset-prefix/_next/static/chunks/0cdfadbcf4b8c9e4.js","/litellm-asset-prefix/_next/static/chunks/8f3bf592254c6c3b.js","/litellm-asset-prefix/_next/static/chunks/8c17e934bd227606.js","/litellm-asset-prefix/_next/static/chunks/b98447395b5d37ef.js"],"default"]
|
||||
3:I[952683,["/litellm-asset-prefix/_next/static/chunks/9e09de50158b3159.js","/litellm-asset-prefix/_next/static/chunks/7e5fe5584502da06.js","/litellm-asset-prefix/_next/static/chunks/0493aafc4891dd29.js","/litellm-asset-prefix/_next/static/chunks/f7e1d08418645368.js","/litellm-asset-prefix/_next/static/chunks/b3d198d6c56a21b8.js","/litellm-asset-prefix/_next/static/chunks/403c4d96324c23a6.js","/litellm-asset-prefix/_next/static/chunks/1d1c8edf97a801b6.js","/litellm-asset-prefix/_next/static/chunks/adb8beb738574863.js","/litellm-asset-prefix/_next/static/chunks/0549bc9afa7d4888.js","/litellm-asset-prefix/_next/static/chunks/0b470ffc60999bf4.js","/litellm-asset-prefix/_next/static/chunks/c847ecdf8c790b0b.js","/litellm-asset-prefix/_next/static/chunks/e099566e8bd4ee4e.js","/litellm-asset-prefix/_next/static/chunks/ee5f9a39a526e423.js","/litellm-asset-prefix/_next/static/chunks/b1c98cc932a0ab19.js","/litellm-asset-prefix/_next/static/chunks/4e17b625d75327a7.js","/litellm-asset-prefix/_next/static/chunks/7b788dd93ad868b3.js","/litellm-asset-prefix/_next/static/chunks/a06cc76a774dd182.js","/litellm-asset-prefix/_next/static/chunks/ca5fbafaf3826374.js","/litellm-asset-prefix/_next/static/chunks/7caea73b77a79d3c.js","/litellm-asset-prefix/_next/static/chunks/10dc4591ef08a91f.js","/litellm-asset-prefix/_next/static/chunks/0b3d09ff6c6e4335.js","/litellm-asset-prefix/_next/static/chunks/baadbd26839e7b66.js","/litellm-asset-prefix/_next/static/chunks/2971c4658f1bcd7d.js","/litellm-asset-prefix/_next/static/chunks/134f728fa7099e3e.js","/litellm-asset-prefix/_next/static/chunks/679dbd657c8b5aef.js","/litellm-asset-prefix/_next/static/chunks/94f7208f5087e27c.js","/litellm-asset-prefix/_next/static/chunks/43f6fc3c2ab9cf23.js","/litellm-asset-prefix/_next/static/chunks/4e06277331e725da.js","/litellm-asset-prefix/_next/static/chunks/3b30ab8eaa03bc21.js","/litellm-asset-prefix/_next/static/chunks/7e417dd24c8becd0.js","/litellm-asset-prefix/_next/static/chunks/da1c7742cc6fe8b4.js","/litellm-asset-prefix/_next/static/chunks/ca7a3fdb635fb7dc.js","/litellm-asset-prefix/_next/static/chunks/a09028cd611c08ef.js","/litellm-asset-prefix/_next/static/chunks/77e1b16e6f85230c.js","/litellm-asset-prefix/_next/static/chunks/ad02f56c287539eb.js","/litellm-asset-prefix/_next/static/chunks/908828a91f602d8b.js","/litellm-asset-prefix/_next/static/chunks/0a65da2cd24e2ab6.js","/litellm-asset-prefix/_next/static/chunks/fcdf7322b0aa3e2e.js","/litellm-asset-prefix/_next/static/chunks/a8f7c8c5eeb6e042.js","/litellm-asset-prefix/_next/static/chunks/4980372eaa37b78b.js","/litellm-asset-prefix/_next/static/chunks/d3ac82723ec9e30d.js","/litellm-asset-prefix/_next/static/chunks/496b84010c33cf69.js","/litellm-asset-prefix/_next/static/chunks/1bc2898be56acd1b.js","/litellm-asset-prefix/_next/static/chunks/6188170a32c9a3c3.js","/litellm-asset-prefix/_next/static/chunks/878832edb30e99a4.js","/litellm-asset-prefix/_next/static/chunks/934dbc43f8c1abde.js","/litellm-asset-prefix/_next/static/chunks/a0f7bfbaffe81a17.js","/litellm-asset-prefix/_next/static/chunks/e1f23fd814ac3500.js","/litellm-asset-prefix/_next/static/chunks/88c74f8b4b20d25a.js","/litellm-asset-prefix/_next/static/chunks/b948aa17e97c458d.js","/litellm-asset-prefix/_next/static/chunks/20acf4fa815c638e.js","/litellm-asset-prefix/_next/static/chunks/659ce28f2cb74401.js","/litellm-asset-prefix/_next/static/chunks/d6ab357d1bbb53f0.js","/litellm-asset-prefix/_next/static/chunks/99cf9cf99df5ccfc.js","/litellm-asset-prefix/_next/static/chunks/b3d631e60d6e8e9b.js"],"default"]
|
||||
1a:I[897367,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"OutletBoundary"]
|
||||
1b:"$Sreact.suspense"
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/3f3fa56b5786d58c.css","style"]
|
||||
0:{"buildId":"8TZ2JbOi7SZ6BCj9ScTHW","rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/3f3fa56b5786d58c.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/0493aafc4891dd29.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/f7e1d08418645368.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/b3d198d6c56a21b8.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/403c4d96324c23a6.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/37e77c06e99eb8ff.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/adb8beb738574863.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/0549bc9afa7d4888.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/0b470ffc60999bf4.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/c847ecdf8c790b0b.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/baadbd26839e7b66.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/ee5f9a39a526e423.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/6eee262391715440.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/4e17b625d75327a7.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/7b788dd93ad868b3.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/a06cc76a774dd182.js","async":true}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/264fd32eefec52b6.js","async":true}],["$","script","script-16",{"src":"/litellm-asset-prefix/_next/static/chunks/86828bdbafb8b581.js","async":true}],["$","script","script-17",{"src":"/litellm-asset-prefix/_next/static/chunks/10dc4591ef08a91f.js","async":true}],["$","script","script-18",{"src":"/litellm-asset-prefix/_next/static/chunks/e099566e8bd4ee4e.js","async":true}],["$","script","script-19",{"src":"/litellm-asset-prefix/_next/static/chunks/fbe12a36d22e9554.js","async":true}],["$","script","script-20",{"src":"/litellm-asset-prefix/_next/static/chunks/2971c4658f1bcd7d.js","async":true}],["$","script","script-21",{"src":"/litellm-asset-prefix/_next/static/chunks/134f728fa7099e3e.js","async":true}],["$","script","script-22",{"src":"/litellm-asset-prefix/_next/static/chunks/679dbd657c8b5aef.js","async":true}],["$","script","script-23",{"src":"/litellm-asset-prefix/_next/static/chunks/94f7208f5087e27c.js","async":true}],["$","script","script-24",{"src":"/litellm-asset-prefix/_next/static/chunks/43f6fc3c2ab9cf23.js","async":true}],["$","script","script-25",{"src":"/litellm-asset-prefix/_next/static/chunks/4e06277331e725da.js","async":true}],["$","script","script-26",{"src":"/litellm-asset-prefix/_next/static/chunks/3b30ab8eaa03bc21.js","async":true}],["$","script","script-27",{"src":"/litellm-asset-prefix/_next/static/chunks/ac3cf77acb5bf234.js","async":true}],["$","script","script-28",{"src":"/litellm-asset-prefix/_next/static/chunks/fb125648f2dae104.js","async":true}],["$","script","script-29",{"src":"/litellm-asset-prefix/_next/static/chunks/7e417dd24c8becd0.js","async":true}],["$","script","script-30",{"src":"/litellm-asset-prefix/_next/static/chunks/a09028cd611c08ef.js","async":true}],["$","script","script-31",{"src":"/litellm-asset-prefix/_next/static/chunks/6967a3b4ecbd3785.js","async":true}],["$","script","script-32",{"src":"/litellm-asset-prefix/_next/static/chunks/3e917c79aadd945b.js","async":true}],["$","script","script-33",{"src":"/litellm-asset-prefix/_next/static/chunks/9bbebdeb3f1cb03f.js","async":true}],"$L6","$L7","$L8","$L9","$La","$Lb","$Lc","$Ld","$Le","$Lf","$L10","$L11","$L12","$L13","$L14","$L15","$L16","$L17","$L18"],"$L19"]}],"loading":null,"isPartial":false}
|
||||
0:{"buildId":"vipo1KaFppvC6fyoT1UMK","rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/3f3fa56b5786d58c.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/0493aafc4891dd29.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/f7e1d08418645368.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/b3d198d6c56a21b8.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/403c4d96324c23a6.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/1d1c8edf97a801b6.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/adb8beb738574863.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/0549bc9afa7d4888.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/0b470ffc60999bf4.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/c847ecdf8c790b0b.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/e099566e8bd4ee4e.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/ee5f9a39a526e423.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/b1c98cc932a0ab19.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/4e17b625d75327a7.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/7b788dd93ad868b3.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/a06cc76a774dd182.js","async":true}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/ca5fbafaf3826374.js","async":true}],["$","script","script-16",{"src":"/litellm-asset-prefix/_next/static/chunks/7caea73b77a79d3c.js","async":true}],["$","script","script-17",{"src":"/litellm-asset-prefix/_next/static/chunks/10dc4591ef08a91f.js","async":true}],["$","script","script-18",{"src":"/litellm-asset-prefix/_next/static/chunks/0b3d09ff6c6e4335.js","async":true}],["$","script","script-19",{"src":"/litellm-asset-prefix/_next/static/chunks/baadbd26839e7b66.js","async":true}],["$","script","script-20",{"src":"/litellm-asset-prefix/_next/static/chunks/2971c4658f1bcd7d.js","async":true}],["$","script","script-21",{"src":"/litellm-asset-prefix/_next/static/chunks/134f728fa7099e3e.js","async":true}],["$","script","script-22",{"src":"/litellm-asset-prefix/_next/static/chunks/679dbd657c8b5aef.js","async":true}],["$","script","script-23",{"src":"/litellm-asset-prefix/_next/static/chunks/94f7208f5087e27c.js","async":true}],["$","script","script-24",{"src":"/litellm-asset-prefix/_next/static/chunks/43f6fc3c2ab9cf23.js","async":true}],["$","script","script-25",{"src":"/litellm-asset-prefix/_next/static/chunks/4e06277331e725da.js","async":true}],["$","script","script-26",{"src":"/litellm-asset-prefix/_next/static/chunks/3b30ab8eaa03bc21.js","async":true}],["$","script","script-27",{"src":"/litellm-asset-prefix/_next/static/chunks/7e417dd24c8becd0.js","async":true}],["$","script","script-28",{"src":"/litellm-asset-prefix/_next/static/chunks/da1c7742cc6fe8b4.js","async":true}],["$","script","script-29",{"src":"/litellm-asset-prefix/_next/static/chunks/ca7a3fdb635fb7dc.js","async":true}],["$","script","script-30",{"src":"/litellm-asset-prefix/_next/static/chunks/a09028cd611c08ef.js","async":true}],["$","script","script-31",{"src":"/litellm-asset-prefix/_next/static/chunks/77e1b16e6f85230c.js","async":true}],["$","script","script-32",{"src":"/litellm-asset-prefix/_next/static/chunks/ad02f56c287539eb.js","async":true}],["$","script","script-33",{"src":"/litellm-asset-prefix/_next/static/chunks/908828a91f602d8b.js","async":true}],"$L6","$L7","$L8","$L9","$La","$Lb","$Lc","$Ld","$Le","$Lf","$L10","$L11","$L12","$L13","$L14","$L15","$L16","$L17","$L18"],"$L19"]}],"loading":null,"isPartial":false}
|
||||
4:{}
|
||||
5:"$0:rsc:props:children:0:props:serverProvidedParams:params"
|
||||
6:["$","script","script-34",{"src":"/litellm-asset-prefix/_next/static/chunks/0a65da2cd24e2ab6.js","async":true}]
|
||||
7:["$","script","script-35",{"src":"/litellm-asset-prefix/_next/static/chunks/908828a91f602d8b.js","async":true}]
|
||||
8:["$","script","script-36",{"src":"/litellm-asset-prefix/_next/static/chunks/5f2d62a75803a3f7.js","async":true}]
|
||||
9:["$","script","script-37",{"src":"/litellm-asset-prefix/_next/static/chunks/ca5fbafaf3826374.js","async":true}]
|
||||
7:["$","script","script-35",{"src":"/litellm-asset-prefix/_next/static/chunks/fcdf7322b0aa3e2e.js","async":true}]
|
||||
8:["$","script","script-36",{"src":"/litellm-asset-prefix/_next/static/chunks/a8f7c8c5eeb6e042.js","async":true}]
|
||||
9:["$","script","script-37",{"src":"/litellm-asset-prefix/_next/static/chunks/4980372eaa37b78b.js","async":true}]
|
||||
a:["$","script","script-38",{"src":"/litellm-asset-prefix/_next/static/chunks/d3ac82723ec9e30d.js","async":true}]
|
||||
b:["$","script","script-39",{"src":"/litellm-asset-prefix/_next/static/chunks/9b0ee76cbdef1a2a.js","async":true}]
|
||||
b:["$","script","script-39",{"src":"/litellm-asset-prefix/_next/static/chunks/496b84010c33cf69.js","async":true}]
|
||||
c:["$","script","script-40",{"src":"/litellm-asset-prefix/_next/static/chunks/1bc2898be56acd1b.js","async":true}]
|
||||
d:["$","script","script-41",{"src":"/litellm-asset-prefix/_next/static/chunks/fcdf7322b0aa3e2e.js","async":true}]
|
||||
d:["$","script","script-41",{"src":"/litellm-asset-prefix/_next/static/chunks/6188170a32c9a3c3.js","async":true}]
|
||||
e:["$","script","script-42",{"src":"/litellm-asset-prefix/_next/static/chunks/878832edb30e99a4.js","async":true}]
|
||||
f:["$","script","script-43",{"src":"/litellm-asset-prefix/_next/static/chunks/496b84010c33cf69.js","async":true}]
|
||||
10:["$","script","script-44",{"src":"/litellm-asset-prefix/_next/static/chunks/8e3d0ce9505a304f.js","async":true}]
|
||||
f:["$","script","script-43",{"src":"/litellm-asset-prefix/_next/static/chunks/934dbc43f8c1abde.js","async":true}]
|
||||
10:["$","script","script-44",{"src":"/litellm-asset-prefix/_next/static/chunks/a0f7bfbaffe81a17.js","async":true}]
|
||||
11:["$","script","script-45",{"src":"/litellm-asset-prefix/_next/static/chunks/e1f23fd814ac3500.js","async":true}]
|
||||
12:["$","script","script-46",{"src":"/litellm-asset-prefix/_next/static/chunks/88c74f8b4b20d25a.js","async":true}]
|
||||
13:["$","script","script-47",{"src":"/litellm-asset-prefix/_next/static/chunks/99cf9cf99df5ccfc.js","async":true}]
|
||||
14:["$","script","script-48",{"src":"/litellm-asset-prefix/_next/static/chunks/4980372eaa37b78b.js","async":true}]
|
||||
15:["$","script","script-49",{"src":"/litellm-asset-prefix/_next/static/chunks/0cdfadbcf4b8c9e4.js","async":true}]
|
||||
16:["$","script","script-50",{"src":"/litellm-asset-prefix/_next/static/chunks/8f3bf592254c6c3b.js","async":true}]
|
||||
17:["$","script","script-51",{"src":"/litellm-asset-prefix/_next/static/chunks/8c17e934bd227606.js","async":true}]
|
||||
18:["$","script","script-52",{"src":"/litellm-asset-prefix/_next/static/chunks/b98447395b5d37ef.js","async":true}]
|
||||
13:["$","script","script-47",{"src":"/litellm-asset-prefix/_next/static/chunks/b948aa17e97c458d.js","async":true}]
|
||||
14:["$","script","script-48",{"src":"/litellm-asset-prefix/_next/static/chunks/20acf4fa815c638e.js","async":true}]
|
||||
15:["$","script","script-49",{"src":"/litellm-asset-prefix/_next/static/chunks/659ce28f2cb74401.js","async":true}]
|
||||
16:["$","script","script-50",{"src":"/litellm-asset-prefix/_next/static/chunks/d6ab357d1bbb53f0.js","async":true}]
|
||||
17:["$","script","script-51",{"src":"/litellm-asset-prefix/_next/static/chunks/99cf9cf99df5ccfc.js","async":true}]
|
||||
18:["$","script","script-52",{"src":"/litellm-asset-prefix/_next/static/chunks/b3d631e60d6e8e9b.js","async":true}]
|
||||
19:["$","$L1a",null,{"children":["$","$1b",null,{"name":"Next.MetadataOutlet","children":"$@1c"}]}]
|
||||
1c:null
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -3,4 +3,4 @@
|
|||
3:I[897367,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"MetadataBoundary"]
|
||||
4:"$Sreact.suspense"
|
||||
5:I[27201,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"IconMark"]
|
||||
0:{"buildId":"8TZ2JbOi7SZ6BCj9ScTHW","rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"LiteLLM Dashboard"}],["$","meta","1",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.1d32c690.ico","sizes":"48x48","type":"image/x-icon"}],["$","link","3",{"rel":"icon","href":"./favicon.ico"}],["$","$L5","4",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"loading":null,"isPartial":false}
|
||||
0:{"buildId":"vipo1KaFppvC6fyoT1UMK","rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"LiteLLM Dashboard"}],["$","meta","1",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.1d32c690.ico","sizes":"48x48","type":"image/x-icon"}],["$","link","3",{"rel":"icon","href":"./favicon.ico"}],["$","$L5","4",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"loading":null,"isPartial":false}
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@
|
|||
5:I[837457,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"default"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/4e20891f2fd03463.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/91037395c95e366d.css","style"]
|
||||
0:{"buildId":"8TZ2JbOi7SZ6BCj9ScTHW","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/4e20891f2fd03463.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/91037395c95e366d.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/9e09de50158b3159.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/7e5fe5584502da06.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"inter_5972bc34-module__OU16Qa__className","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","template":["$","$L5",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]}]}]]}],"loading":null,"isPartial":false}
|
||||
0:{"buildId":"vipo1KaFppvC6fyoT1UMK","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/4e20891f2fd03463.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/91037395c95e366d.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/9e09de50158b3159.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/7e5fe5584502da06.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"inter_5972bc34-module__OU16Qa__className","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","template":["$","$L5",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]}]}]]}],"loading":null,"isPartial":false}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
:HL["/litellm-asset-prefix/_next/static/chunks/91037395c95e366d.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/media/83afe278b6a6bb3c-s.p.3a6ba036.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/3f3fa56b5786d58c.css","style"]
|
||||
0:{"buildId":"8TZ2JbOi7SZ6BCj9ScTHW","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":true},"staleTime":300}
|
||||
0:{"buildId":"vipo1KaFppvC6fyoT1UMK","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":true},"staleTime":300}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue