Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_enterprise_request_metering

This commit is contained in:
Yassin Kortam 2026-07-09 10:42:08 +03:00
commit b64f299c3e
415 changed files with 29337 additions and 6024 deletions

View file

@ -1029,6 +1029,8 @@ jobs:
- *python312_image
working_directory: ~/project
resource_class: large
environment:
REQUEST_TIMEOUT: "180"
steps:
- checkout
@ -1058,7 +1060,8 @@ jobs:
-v -x \
--junitxml=test-results/junit.xml \
--durations=5 \
-n 8"
-n 8 \
--reruns 1 --only-rerun Timeout"
no_output_timeout: 15m
# Store test results

View file

@ -4,9 +4,11 @@ on:
push:
branches:
- main
- litellm_internal_staging
pull_request:
branches:
- main
- litellm_internal_staging
# Allow CodSpeed to trigger backtest performance analysis
# in order to generate initial data
workflow_dispatch:
@ -22,7 +24,7 @@ concurrency:
jobs:
benchmarks:
runs-on: ubuntu-24.04
timeout-minutes: 15
timeout-minutes: 60
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0

View file

@ -0,0 +1,113 @@
name: Terraform Provider
on:
push:
paths:
- "terraform/provider/**"
- ".github/workflows/test-terraform-provider.yml"
pull_request:
branches:
- main
- litellm_internal_staging
- litellm_oss_staging
- "litellm_**"
paths:
- "terraform/provider/**"
- "litellm/proxy/**"
- ".github/workflows/test-terraform-provider.yml"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
provider-checks:
name: gofmt, vet, build, test
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: terraform/provider
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version-file: terraform/provider/go.mod
cache: true
cache-dependency-path: terraform/provider/go.sum
- name: gofmt
run: |
UNFORMATTED=$(gofmt -l .)
if [ -n "${UNFORMATTED}" ]; then
echo "::error::gofmt required for: ${UNFORMATTED}"
exit 1
fi
- name: go vet
run: go vet ./...
- name: Build
run: go build ./...
- name: Test
run: go test -timeout 120s ./...
endpoint-drift:
name: Provider endpoints vs proxy OpenAPI schema
runs-on: ubuntu-latest
timeout-minutes: 20
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: |
.github/scripts/uv_sync_with_retries.sh --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
- name: Generate proxy OpenAPI schema
run: |
uv run --no-sync python terraform/provider/tools/dump_openapi.py "${RUNNER_TEMP}/openapi.json"
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version-file: terraform/provider/go.mod
cache: true
cache-dependency-path: terraform/provider/go.sum
- name: Audit provider endpoints against the schema
working-directory: terraform/provider
run: go run ./tools/endpointaudit -provider-dir ./litellm -spec "${RUNNER_TEMP}/openapi.json"

View file

@ -16,6 +16,7 @@ jobs:
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
root_path: ["/api/v1", "/llmproxy"]
@ -108,8 +109,26 @@ jobs:
- name: Install UI deps and Chromium
working-directory: ui/litellm-dashboard
run: |
npm ci
npx playwright install --with-deps chromium
retry() {
local attempt=1
local max_attempts=4
until "$@"; do
if [ "$attempt" -ge "$max_attempts" ]; then
echo "Command failed after $attempt attempts: $*"
return 1
fi
echo "Attempt $attempt failed: $*. Retrying in $((attempt * 15))s..."
sleep $((attempt * 15))
attempt=$((attempt + 1))
done
}
npm config set fetch-retries 5
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
retry npm ci
retry npx playwright install --with-deps chromium
- name: Run SERVER_ROOT_PATH redirect e2e
working-directory: ui/litellm-dashboard

View file

@ -21,11 +21,11 @@ End-to-end tests belong in `tests/e2e/` and must follow the harness conventions
When creating PRs, don't set base to `main`. `litellm_internal_staging` serves that purpose
When writing a PR body, treat the comments and imperative instructions inside @.github/pull_request_template.md as rules to follow, not just layout
When writing a PR body, treat the comments and imperative instructions inside @.github/pull_request_template.md as rules to follow, not just layout. Agent harnesses may strip HTML comments from copies of that file injected into context, so read .github/pull_request_template.md from disk before writing a PR body to make sure you see every comment rule
If you're resolving a linear ticket, in the "## Linear ticket" section of the PR, say "Resolves LIT-1234", replacing "LIT-1234" with the actual ticket id that you're resolving. If you don't have the ticket id, don't make one up or search for it. Just leave the section blank
Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, just tell me which URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, what fields to fill out, etc. along with the other commands to run in an ordered list, and I'll do it myself and post the screenshots after you make the PR
Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`; the Admin UI dev server is `npm run dev` in `ui/litellm-dashboard`, served on port 3000) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, just tell me which URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, what fields to fill out, etc. along with the other commands to run in an ordered list, and I'll do it myself and post the screenshots after you make the PR
If you ever make public-facing PR descriptions, comments, issues, commit messages, etc., always follow these guidelines to sound less AI-y:
- don't use emojis

View file

@ -919,9 +919,9 @@ class BaseEmailLogger(CustomLogger):
"""
Construct invitation link for the user
# http://localhost:4000/ui?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b
# http://localhost:4000/ui/onboarding?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b
"""
return f"{base_url}/ui?invitation_id={invitation_id}"
return f"{base_url}/ui/onboarding?invitation_id={invitation_id}"
async def send_email(
self,

View file

@ -1,6 +1,6 @@
[project]
name = "litellm-enterprise"
version = "0.1.47"
version = "0.1.48"
description = "Package for LiteLLM Enterprise features"
readme = "README.md"
requires-python = ">=3.9"
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
module-root = ""
[tool.commitizen]
version = "0.1.47"
version = "0.1.48"
version_files = [
"pyproject.toml:^version",
"../pyproject.toml:litellm-enterprise==",

View file

@ -25,17 +25,25 @@ DatabaseURLSettings.from_env().apply_to_env()
from litellm.proxy.proxy_server import app
from gateway.routes.allowlist import GATEWAY_EXACT_PATHS, GATEWAY_PATH_PREFIXES
from gateway.routes.allowlist import (
GATEWAY_EXACT_PATHS,
GATEWAY_MOUNT_PATHS,
GATEWAY_PATH_PREFIXES,
)
def _is_gateway_route(route) -> bool:
"""Keep the route on the gateway if its path is in the LLM data-plane surface."""
"""Keep the route on the gateway if its path is in the LLM data-plane surface.
Prometheus registers /metrics as a Mount (``app.mount("/metrics", make_asgi_app())``),
so Mounts are matched against GATEWAY_MOUNT_PATHS instead of being dropped with
the UI static mounts.
"""
path = getattr(route, "path", None)
if path is None:
return False
if isinstance(route, Mount):
# Gateway never serves the static UI or its asset bundles.
return False
return path in GATEWAY_MOUNT_PATHS
if path in GATEWAY_EXACT_PATHS:
return True
return any(path.startswith(prefix) for prefix in GATEWAY_PATH_PREFIXES)

View file

@ -106,7 +106,7 @@ GATEWAY_PATH_PREFIXES: tuple[str, ...] = (
# Health & ops
"/health",
"/metrics",
"/watsonx"
"/watsonx",
)
GATEWAY_EXACT_PATHS: frozenset[str] = frozenset(
@ -120,3 +120,9 @@ GATEWAY_EXACT_PATHS: frozenset[str] = frozenset(
"/test",
}
)
GATEWAY_MOUNT_PATHS: frozenset[str] = frozenset(
{
"/metrics",
}
)

View file

@ -0,0 +1,8 @@
-- Timestamp sorts before some already-applied migrations; this is safe: the
-- runner is `prisma migrate deploy`, which applies every pending migration
-- regardless of name order (utils.py has an informational check for exactly
-- this), and IF NOT EXISTS keeps a re-apply idempotent.
-- AlterTable
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "token_exchange_endpoint" TEXT;
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "audience" TEXT;
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "subject_token_type" TEXT;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "token_exchange_profile" TEXT;

View file

@ -329,6 +329,12 @@ model LiteLLM_MCPServerTable {
token_url String?
registration_url String?
oauth2_flow String?
token_exchange_endpoint String?
// Named for the RFC 8693 "audience" token-exchange request parameter (that flow only).
// RFC 8707 resource indicators are a separate concept, named "resource" in the v2 egress types.
audience String?
subject_token_type String?
token_exchange_profile String?
allow_all_keys Boolean @default(false)
available_on_public_internet Boolean @default(true)
delegate_auth_to_upstream Boolean @default(false)

View file

@ -379,6 +379,7 @@ budget_duration: Optional[str] = (
None # proxy only - resets budget after fixed duration. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d").
)
default_soft_budget: float = DEFAULT_SOFT_BUDGET # by default all litellm proxy keys have a soft budget of 50.0
budget_exceeded_throttle_percentage: Optional[float] = None
forward_traceparent_to_llm_provider: bool = False

View file

@ -11,7 +11,6 @@ from litellm._logging import verbose_logger
from litellm.a2a_protocol.cost_calculator import A2ACostCalculator
from litellm.a2a_protocol.utils import A2ARequestUtils
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.thread_pool_executor import executor
if TYPE_CHECKING:
from a2a.types import SendStreamingMessageRequest, SendStreamingMessageResponse
@ -128,22 +127,15 @@ class A2AStreamingIterator:
# Call success handlers - they will build standard_logging_object
asyncio.create_task(
self.logging_obj.async_success_handler(
result=result,
self.logging_obj.dispatch_success_handlers(
result,
start_time=self.start_time,
end_time=end_time,
cache_hit=None,
prefer_async_handlers=True,
)
)
executor.submit(
self.logging_obj.success_handler,
result=result,
cache_hit=None,
start_time=self.start_time,
end_time=end_time,
)
verbose_logger.info(
f"A2A streaming completed: prompt_tokens={prompt_tokens}, "
f"completion_tokens={completion_tokens}, total_tokens={total_tokens}, "

View file

@ -1504,6 +1504,7 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [
"public_model_groups_links",
"cost_discount_config",
"cost_margin_config",
"budget_exceeded_throttle_percentage",
]
SPECIAL_LITELLM_AUTH_TOKEN = ["ui-token"]
DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL = int(os.getenv("DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL", 60))

View file

@ -52,6 +52,7 @@ from litellm.integrations.otel.model.semconv import (
GenAIProvider,
JsonRpc,
LiteLLM,
LiteLLMError,
MCPMethod,
Metric,
Network,
@ -87,6 +88,7 @@ __all__ = [
"HTTP",
"JsonRpc",
"LiteLLM",
"LiteLLMError",
"MCP",
"MCPMethod",
"Metric",

View file

@ -16,9 +16,10 @@ from litellm.integrations.otel.model.payloads import (
MCPListToolsSpanData,
MCPToolCallSpanData,
ServiceSpanData,
SpanError,
)
from litellm.integrations.otel.plumbing.providers import to_otel_span_kind
from litellm.integrations.otel.model.semconv import Error, ExceptionEvent
from litellm.integrations.otel.model.semconv import Error, ExceptionEvent, LiteLLMError
from litellm.integrations.otel.model.spans import (
SPAN_REGISTRY,
SpanRole,
@ -49,6 +50,27 @@ _NAME_BUILDERS: dict[SpanRole, Callable[..., str]] = {
_DEDUP_CACHE_MAX = 10_000
def _stamp_otel_error_attributes(span: Span, error_type: str, resolved_message: str) -> None:
"""Stamp the OTel-semconv error attributes (``error.type`` + ``error.message``).
``error_type`` and ``resolved_message`` are ``finish_span``'s already-computed
fallback chains, so the pair on the status, event, and attributes stays in
lockstep."""
span.set_attribute(Error.TYPE, error_type)
span.set_attribute(Error.MESSAGE, resolved_message)
def _stamp_litellm_error_attributes(span: Span, error: SpanError) -> None:
"""Stamp litellm-specific error detail attributes. Emitted only when the
corresponding field is populated so guardrail-shape errors carrying only a
message aren't polluted with empty detail keys."""
if error.code:
span.set_attribute(LiteLLMError.CODE, error.code)
if error.stack_trace:
span.set_attribute(LiteLLMError.STACK_TRACE, error.stack_trace)
if error.llm_provider:
span.set_attribute(LiteLLMError.LLM_PROVIDER, error.llm_provider)
class SpanEmitter:
def __init__(
self,
@ -190,12 +212,13 @@ class SpanEmitter:
if error and (error.error_type or error.message):
error_type = error.error_type or "error"
message = error.message or error.error_type or "error"
span.set_attribute(Error.TYPE, error_type)
_stamp_otel_error_attributes(span, error_type, message)
_stamp_litellm_error_attributes(span, error)
span.set_status(Status(StatusCode.ERROR, message))
# Carry the full message on the standard ``exception`` event so backends
# map it as full text under ``exception.message``. Setting it as a bare
# string attribute instead lets backends like Elasticsearch dynamic-map
# it to a ``keyword`` capped at 1024 chars, truncating the message.
# Also emit the semconv ``exception`` event so backends that
# dynamic-map unknown string span attrs to ``keyword`` (e.g.
# Elasticsearch with a 1024-char ``ignore_above``) still see the
# full untruncated message on the recognized event field.
span.add_event(
ExceptionEvent.NAME,
{ExceptionEvent.TYPE: error_type, ExceptionEvent.MESSAGE: message},

View file

@ -368,7 +368,11 @@ class OpenTelemetryV2(CustomLogger):
# it (named provisionally) so it isn't leaked as an open span.
carrier.span.end(end_time=to_ns(end_time))
return None
data = LLMCallSpanData.from_standard_logging_payload(payload, capture_content=self.config.capture_span_content)
data = LLMCallSpanData.from_standard_logging_payload(
payload,
capture_content=self.config.capture_span_content,
time_to_first_chunk_seconds=call.time_to_first_chunk_seconds,
)
end_time_ns = to_ns(end_time)
if carrier.span is not None:
# Born at the boundary: stamp attributes from the typed payload, set

View file

@ -55,6 +55,7 @@ class GenAIMapper:
GenAI.RESPONSE_MODEL: lambda d: d.response_model,
GenAI.RESPONSE_ID: lambda d: d.response_id,
GenAI.RESPONSE_FINISH_REASONS: lambda d: list(d.finish_reasons) if d.finish_reasons else None,
GenAI.RESPONSE_TIME_TO_FIRST_CHUNK: lambda d: d.time_to_first_chunk_seconds,
GenAI.USAGE_INPUT_TOKENS: lambda d: d.usage.input_tokens,
GenAI.USAGE_OUTPUT_TOKENS: lambda d: d.usage.output_tokens,
Error.TYPE: lambda d: d.error.error_type if d.error else None,

View file

@ -41,7 +41,7 @@ from typing import TYPE_CHECKING, Any, Mapping, cast
from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL
from litellm.integrations.otel.model.semconv import resolve_operation
from litellm.integrations.otel.model.utils import as_str
from litellm.integrations.otel.model.utils import as_str, to_seconds
if TYPE_CHECKING:
from litellm.types.utils import StandardLoggingPayload
@ -201,6 +201,7 @@ class LLMCallEvent:
# span is renamed from the typed payload at close (``finish_span``); this only
# needs to be reasonable for a span that never gets closed (a leak).
provisional_span_name: str
time_to_first_chunk_seconds: float | None
@classmethod
def from_dict(cls, kwargs: Mapping[str, Any]) -> "LLMCallEvent":
@ -214,9 +215,25 @@ class LLMCallEvent:
dynamic_params=kwargs.get("standard_callback_dynamic_params"),
is_no_upstream_call=bool(kwargs.get(LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL)),
provisional_span_name=f"{operation.value} {model}".strip(),
time_to_first_chunk_seconds=time_to_first_chunk_seconds(kwargs),
)
def time_to_first_chunk_seconds(kwargs: Mapping[str, Any]) -> float | None:
"""Seconds from the upstream request being issued (``api_call_start_time``)
to the first streamed chunk (``completion_start_time``); ``None`` for
non-streaming calls, where ``completion_start_time`` is backfilled with the
end time and would not measure first-chunk latency."""
optional_params = cast(Mapping[str, Any], kwargs.get("optional_params") or {})
if not optional_params.get("stream"):
return None
api_call_start = to_seconds(kwargs.get("api_call_start_time"))
completion_start = to_seconds(kwargs.get("completion_start_time"))
if api_call_start is None or completion_start is None:
return None
return completion_start - api_call_start
def _call_id(payload: "StandardLoggingPayload | None", kwargs: Mapping[str, Any]) -> str | None:
"""The call id from the payload (when closed) or the bare kwargs (at pre_call)."""
if payload is not None:

View file

@ -141,6 +141,9 @@ class LLMCost:
class SpanError:
error_type: str | None = None
message: str | None = None
code: str | None = None
stack_trace: str | None = None
llm_provider: str | None = None
@dataclass(frozen=True)
@ -305,10 +308,14 @@ class LLMCallSpanData:
messages_in: tuple[Mapping[str, object], ...] = ()
choices_out: tuple[Mapping[str, object], ...] = ()
system_fingerprint: str | None = None
time_to_first_chunk_seconds: float | None = None
@classmethod
def from_standard_logging_payload(
cls, payload: "StandardLoggingPayload", capture_content: bool = False
cls,
payload: "StandardLoggingPayload",
capture_content: bool = False,
time_to_first_chunk_seconds: float | None = None,
) -> "LLMCallSpanData":
params = cast(Mapping[str, object], payload.get("model_parameters") or {})
# The single parse of the request's metadata — the request-vs-provider
@ -349,6 +356,7 @@ class LLMCallSpanData:
messages_in=_dicts(payload.get("messages")) if capture_content else (),
choices_out=choices_out if capture_content else (),
system_fingerprint=as_str(response.get("system_fingerprint")),
time_to_first_chunk_seconds=time_to_first_chunk_seconds,
)
@ -566,6 +574,9 @@ def _parse_error(payload: "StandardLoggingPayload") -> SpanError | None:
return SpanError(
error_type=as_str(info.get("error_class")) or as_str(info.get("error_code")),
message=as_str(info.get("error_message")) or as_str(payload.get("error_str")),
code=as_str(info.get("error_code")),
stack_trace=as_str(info.get("traceback")),
llm_provider=as_str(info.get("llm_provider")),
)

View file

@ -69,6 +69,7 @@ class GenAI:
RESPONSE_ID: Final = "gen_ai.response.id"
RESPONSE_MODEL: Final = "gen_ai.response.model"
RESPONSE_FINISH_REASONS: Final = "gen_ai.response.finish_reasons"
RESPONSE_TIME_TO_FIRST_CHUNK: Final = "gen_ai.response.time_to_first_chunk"
# usage
USAGE_INPUT_TOKENS: Final = "gen_ai.usage.input_tokens"
USAGE_OUTPUT_TOKENS: Final = "gen_ai.usage.output_tokens"
@ -143,7 +144,27 @@ class Client:
class Error:
"""OTel-defined error attribute keys, from the semconv ``error.*`` registry.
``MESSAGE`` is marked *Deprecated* upstream in favor of domain-specific
error message keys plus ``exception.message`` on the exception event, but
is still defined and stamped by litellm's v1 integration; keeping it here
for byte-for-byte parity."""
TYPE: Final = "error.type"
MESSAGE: Final = "error.message"
class LiteLLMError:
"""LiteLLM-specific error attribute keys. Emitted under the ``error.*``
namespace (not ``litellm.*``) for byte-for-byte compat with the v1
integration in ``opentelemetry.py``; consumers reading these keys on v1
spans read the same keys on v2 spans. OTel semconv does not define any of
these three, and per its extension rules a namespace may carry additional
vendor keys as long as they don't collide with defined names."""
CODE: Final = "error.code"
STACK_TRACE: Final = "error.stack_trace"
LLM_PROVIDER: Final = "error.llm_provider"
class ExceptionEvent:

View file

@ -21,6 +21,7 @@ from litellm.integrations.opentelemetry import (
_build_metric_attribute_filter,
_resolve_metric_attribute_filter,
)
from litellm.integrations.otel.model.metadata import time_to_first_chunk_seconds
from litellm.integrations.otel.model.semconv import Metric, resolve_operation
from litellm.integrations.otel.model.utils import to_seconds
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
@ -181,13 +182,10 @@ class GenAIMetricRecorder:
self._metrics.token_usage.record(usage.get("completion_tokens", 0), attributes=out_attrs)
def _record_time_to_first_token(self, kwargs: Mapping[str, Any], common_attrs: dict) -> None:
if not kwargs.get("optional_params", {}).get("stream", False):
time_to_first_chunk = time_to_first_chunk_seconds(kwargs)
if time_to_first_chunk is None:
return
api_call_start = to_seconds(kwargs.get("api_call_start_time"))
completion_start = to_seconds(kwargs.get("completion_start_time"))
if api_call_start is None or completion_start is None:
return
self._metrics.time_to_first_token.record(completion_start - api_call_start, attributes=common_attrs)
self._metrics.time_to_first_token.record(time_to_first_chunk, attributes=common_attrs)
def _record_time_per_output_token(
self,

View file

@ -3619,6 +3619,7 @@ class PrometheusLogger(CustomLogger):
hashed_token=user_api_key_dict.token,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
check_cache_only=True,
)
if key_object:
user_api_key_dict.budget_reset_at = key_object.budget_reset_at

View file

@ -174,22 +174,15 @@ class InteractionsAPIStreamingIterator(BaseInteractionsAPIStreamingIterator):
logging_response = copy.deepcopy(self.completed_response)
asyncio.create_task(
self.logging_obj.async_success_handler(
result=logging_response,
self.logging_obj.dispatch_success_handlers(
logging_response,
start_time=self.start_time,
end_time=datetime.now(),
cache_hit=None,
prefer_async_handlers=True,
)
)
executor.submit(
self.logging_obj.success_handler,
result=logging_response,
cache_hit=None,
start_time=self.start_time,
end_time=datetime.now(),
)
class SyncInteractionsAPIStreamingIterator(BaseInteractionsAPIStreamingIterator):
"""

View file

@ -95,6 +95,17 @@ class HealthCheckHelpers:
"""
import litellm
logging_obj = filtered_model_params.get("litellm_logging_obj")
if logging_obj is not None:
api_base = filtered_model_params.get("api_base")
logging_obj.update_from_kwargs(
kwargs=filtered_model_params,
model=filtered_model_params.get("model"),
user=None,
optional_params={},
litellm_params={"api_base": api_base} if api_base else None,
)
if custom_llm_provider in LIST_BATCHES_SUPPORTED_PROVIDERS:
return await litellm.alist_batches(**filtered_model_params)
else:
@ -188,6 +199,7 @@ class HealthCheckHelpers:
api_base=model_params.get("api_base", None),
api_key=model_params.get("api_key", None),
api_version=model_params.get("api_version", None),
model_params=model_params,
),
"batch": lambda: HealthCheckHelpers._batch_health_check(
custom_llm_provider=custom_llm_provider,

View file

@ -3626,6 +3626,7 @@ class BedrockImageProcessor:
def _convert_to_bedrock_tool_call_invoke(
tool_calls: list,
model: Optional[str] = None,
) -> List[BedrockContentBlock]:
"""
OpenAI tool invokes:
@ -3701,7 +3702,13 @@ def _convert_to_bedrock_tool_call_invoke(
# cache_control applies to the whole original
# tool call; attach after the last split block.
if tool.get("cache_control", None) is not None:
_parts_list.append(BedrockContentBlock(cachePoint=CachePointBlock(type="default")))
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
{"cache_control": tool["cache_control"]},
block_type="content_block",
model=model,
)
if _cache_point_block is not None:
_parts_list.append(_cache_point_block)
continue
# Fallback: no objects extracted — use empty dict.
arguments_dict = {}
@ -3712,8 +3719,13 @@ def _convert_to_bedrock_tool_call_invoke(
# Check for cache_control and add a separate cachePoint block
if tool.get("cache_control", None) is not None:
cache_point_block = BedrockContentBlock(cachePoint=CachePointBlock(type="default"))
_parts_list.append(cache_point_block)
cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
{"cache_control": tool["cache_control"]},
block_type="content_block",
model=model,
)
if cache_point_block is not None:
_parts_list.append(cache_point_block)
return _parts_list
except Exception as e:
raise Exception(
@ -4377,6 +4389,7 @@ class BedrockConverseMessagesProcessor:
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
message_block=cast(OpenAIMessageContentListBlock, element),
block_type="content_block",
model=model,
)
if _cache_point_block is not None:
_parts.append(_cache_point_block)
@ -4384,7 +4397,7 @@ class BedrockConverseMessagesProcessor:
elif message_block["content"] and isinstance(message_block["content"], str):
_part = BedrockContentBlock(text=messages[msg_i]["content"])
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
message_block, block_type="content_block"
message_block, block_type="content_block", model=model
)
user_content.append(_part)
if _cache_point_block is not None:
@ -4416,22 +4429,27 @@ class BedrockConverseMessagesProcessor:
tool_content.append(tool_call_result)
# Check if we need to add a separate cachePoint block
has_cache_control = False
tool_msg_cache_control = None
# Check for message-level cache_control
if current_message.get("cache_control", None) is not None:
has_cache_control = True
tool_msg_cache_control = current_message["cache_control"]
# Check for content-level cache_control in list content
elif isinstance(current_message.get("content"), list):
for content_element in current_message["content"]:
if isinstance(content_element, dict) and content_element.get("cache_control", None) is not None:
has_cache_control = True
tool_msg_cache_control = content_element["cache_control"]
break
# Add a separate cachePoint block if cache_control is present
if has_cache_control:
cache_point_block = BedrockContentBlock(cachePoint=CachePointBlock(type="default"))
tool_content.append(cache_point_block)
if tool_msg_cache_control is not None:
cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
{"cache_control": tool_msg_cache_control},
block_type="content_block",
model=model,
)
if cache_point_block is not None:
tool_content.append(cache_point_block)
msg_i += 1
# Deduplicate toolResult blocks with the same toolUseId
@ -4509,6 +4527,7 @@ class BedrockConverseMessagesProcessor:
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
message_block=cast(OpenAIMessageContentListBlock, element),
block_type="content_block",
model=model,
)
if _cache_point_block is not None:
assistants_parts.append(_cache_point_block)
@ -4520,14 +4539,14 @@ class BedrockConverseMessagesProcessor:
# If content is empty/whitespace, skip it (don't add a placeholder)
# Add cache point block for assistant string content
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
assistant_message_block, block_type="content_block"
assistant_message_block, block_type="content_block", model=model
)
if _cache_point_block is not None:
assistant_content.append(_cache_point_block)
_tool_calls = assistant_message_block.get("tool_calls", [])
if _tool_calls:
assistant_content.extend(_convert_to_bedrock_tool_call_invoke(_tool_calls))
assistant_content.extend(_convert_to_bedrock_tool_call_invoke(_tool_calls, model=model))
msg_i += 1
@ -4745,6 +4764,7 @@ def _bedrock_converse_messages_pt(
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
message_block=cast(OpenAIMessageContentListBlock, element),
block_type="content_block",
model=model,
)
if _cache_point_block is not None:
_parts.append(_cache_point_block)
@ -4752,7 +4772,7 @@ def _bedrock_converse_messages_pt(
elif message_block["content"] and isinstance(message_block["content"], str):
_part = BedrockContentBlock(text=messages[msg_i]["content"])
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
message_block, block_type="content_block"
message_block, block_type="content_block", model=model
)
user_content.append(_part)
if _cache_point_block is not None:
@ -4786,22 +4806,27 @@ def _bedrock_converse_messages_pt(
tool_content.append(tool_call_result)
# Check if we need to add a separate cachePoint block
has_cache_control = False
tool_msg_cache_control = None
# Check for message-level cache_control
if current_message.get("cache_control", None) is not None:
has_cache_control = True
tool_msg_cache_control = current_message["cache_control"]
# Check for content-level cache_control in list content
elif isinstance(current_message.get("content"), list):
for content_element in current_message["content"]:
if isinstance(content_element, dict) and content_element.get("cache_control", None) is not None:
has_cache_control = True
tool_msg_cache_control = content_element["cache_control"]
break
# Add a separate cachePoint block if cache_control is present
if has_cache_control:
cache_point_block = BedrockContentBlock(cachePoint=CachePointBlock(type="default"))
tool_content.append(cache_point_block)
if tool_msg_cache_control is not None:
cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
{"cache_control": tool_msg_cache_control},
block_type="content_block",
model=model,
)
if cache_point_block is not None:
tool_content.append(cache_point_block)
msg_i += 1
# Deduplicate toolResult blocks with the same toolUseId
@ -4882,6 +4907,7 @@ def _bedrock_converse_messages_pt(
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
message_block=cast(OpenAIMessageContentListBlock, element),
block_type="content_block",
model=model,
)
if _cache_point_block is not None:
assistants_parts.append(_cache_point_block)
@ -4892,13 +4918,13 @@ def _bedrock_converse_messages_pt(
assistant_content.append(BedrockContentBlock(text=_assistant_content))
# Add cache point block for assistant string content
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
assistant_message_block, block_type="content_block"
assistant_message_block, block_type="content_block", model=model
)
if _cache_point_block is not None:
assistant_content.append(_cache_point_block)
_tool_calls = assistant_message_block.get("tool_calls", [])
if _tool_calls:
assistant_content.extend(_convert_to_bedrock_tool_call_invoke(_tool_calls))
assistant_content.extend(_convert_to_bedrock_tool_call_invoke(_tool_calls, model=model))
msg_i += 1

View file

@ -1,5 +1,4 @@
import asyncio
import concurrent.futures
import json
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Union, cast
@ -25,9 +24,6 @@ if TYPE_CHECKING:
else:
CLIENT_CONNECTION_CLASS = Any
# Create a thread pool with a maximum of 10 threads
executor = concurrent.futures.ThreadPoolExecutor(max_workers=10)
class RealtimeEventNormalizer(Protocol):
def should_drop(self, event: object) -> bool: ...
@ -315,13 +311,12 @@ class RealTimeStreaming:
if self.session_tools or self.tool_calls:
self.logging_obj.model_call_details["realtime_tools"] = self.session_tools
self.logging_obj.model_call_details["realtime_tool_calls"] = self.tool_calls
## ASYNC LOGGING
# Route through the bounded logging worker (per-coroutine timeout +
# concurrency cap) instead of a bare create_task, so a slow callback
# can't leave suspended tasks pinning each call's response in memory.
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(self.logging_obj.async_success_handler(self.messages))
## SYNC LOGGING
executor.submit(self.logging_obj.success_handler(self.messages))
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(
self.logging_obj.dispatch_success_handlers(self.messages, prefer_async_handlers=True)
)
async def _send_to_backend(self, message: str) -> bool:
"""Send a message to the backend WebSocket.

View file

@ -50,6 +50,8 @@ _STS_REGION_FROM_ENDPOINT_PATTERN = re.compile(
r"(?:^|\.)sts(?:-fips)?\.([a-z0-9-]+)\.(?:amazonaws\.com(?:\.cn)?|vpce\.amazonaws\.com)"
)
SIGV4_COMPUTED_HEADERS = frozenset({"authorization", "x-amz-date", "x-amz-security-token", "date"})
class Boto3CredentialsInfo(BaseModel):
credentials: Credentials
@ -1400,11 +1402,13 @@ class BaseAWSLLM:
# Add back all original headers (including forwarded ones) after signature calculation
for header_name, header_value in headers.items():
if header_value is not None:
if header_value is not None and header_name.lower() not in SIGV4_COMPUTED_HEADERS:
request.headers[header_name] = header_value
if (
extra_headers is not None and "Authorization" in extra_headers
extra_headers is not None
and "Authorization" in extra_headers
and not extra_headers["Authorization"].startswith("AWS4-HMAC-SHA256")
): # prevent sigv4 from overwriting the auth header
request.headers["Authorization"] = extra_headers["Authorization"]
prepped = request.prepare()
@ -1527,9 +1531,15 @@ class BaseAWSLLM:
# Add back original headers after signing. Only headers in SignedHeaders
# are integrity-protected; forwarded headers (x-forwarded-*) must remain unsigned.
for header_name, header_value in headers.items():
if header_value is not None:
if header_value is not None and header_name.lower() not in SIGV4_COMPUTED_HEADERS:
request_headers_dict[header_name] = header_value
if headers is not None and "Authorization" in headers: # prevent sigv4 from overwriting the auth header
request_headers_dict["Authorization"] = headers["Authorization"]
incoming_authorization = next(
(value for name, value in headers.items() if name.lower() == "authorization" and value is not None),
None,
)
if incoming_authorization is not None and not incoming_authorization.startswith(
"AWS4-HMAC-SHA256"
): # prevent sigv4 from overwriting the auth header
request_headers_dict["Authorization"] = incoming_authorization
return request_headers_dict, request.body

View file

@ -7,13 +7,14 @@ The bedrock-mantle endpoint uses the Anthropic Messages API format but is served
at a different endpoint (bedrock-mantle.{region}.api.aws) with AWS SigV4 auth.
"""
from typing import TYPE_CHECKING, Any, List, Optional
from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator, List, Optional
from litellm.llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import (
AmazonAnthropicClaudeConfig,
)
from litellm.llms.bedrock.common_utils import build_mantle_messages_url
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import ModelResponse
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
@ -91,10 +92,14 @@ class AmazonMantleConfig(AmazonAnthropicClaudeConfig):
litellm_params=litellm_params,
headers=headers,
)
# The parent strips "model" from the body (Invoke API puts it in URL).
# The mantle endpoint (Messages API) requires "model" in the body.
request["model"] = model_id
return request
# The parent strips "model" and "stream" from the body (Invoke API puts
# the model in the URL and streams via a dedicated endpoint). The mantle
# endpoint (Messages API) requires both in the body.
return self._restore_mantle_body_fields(
request=request,
model_id=model_id,
optional_params=optional_params,
)
async def async_transform_request(
self,
@ -114,5 +119,31 @@ class AmazonMantleConfig(AmazonAnthropicClaudeConfig):
headers=headers,
)
await self._async_convert_document_url_sources_to_base64(request)
request["model"] = model_id
return request
return self._restore_mantle_body_fields(
request=request,
model_id=model_id,
optional_params=optional_params,
)
@staticmethod
def _restore_mantle_body_fields(request: dict, model_id: str, optional_params: dict) -> dict:
stream_fields: dict = {"stream": True} if optional_params.get("stream") is True else {}
return {**request, "model": model_id, **stream_fields}
@property
def has_custom_stream_wrapper(self) -> bool:
return False
def get_model_response_iterator(
self,
streaming_response: Iterator[str] | AsyncIterator[str] | ModelResponse,
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=json_mode,
)

View file

@ -6,8 +6,13 @@ AmazonAnthropicClaudeMessagesConfig. Overrides only the URL and model-prefix
stripping that are specific to the bedrock-mantle endpoint.
"""
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional, Tuple
import httpx
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
AnthropicMessagesConfig,
)
from litellm.llms.bedrock.common_utils import build_mantle_messages_url
from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import (
AmazonAnthropicClaudeMessagesConfig,
@ -89,8 +94,26 @@ class AmazonMantleMessagesConfig(AmazonAnthropicClaudeMessagesConfig):
headers=headers,
)
# Parent (AmazonAnthropicClaudeMessagesConfig) removes "model" from the
# body (Bedrock Invoke puts model in the URL). The mantle endpoint
# (Messages API) requires "model" in the request body.
request["model"] = model_id
return request
# Parent (AmazonAnthropicClaudeMessagesConfig) removes "model" and
# "stream" from the body (Bedrock Invoke puts the model in the URL and
# streams via a dedicated endpoint). The mantle endpoint (Messages API)
# requires both in the request body.
stream_fields: dict[str, bool] = (
{"stream": True} if anthropic_messages_optional_request_params.get("stream") is True else {}
)
return {**request, "model": model_id, **stream_fields}
def get_async_streaming_response_iterator(
self,
model: str,
httpx_response: httpx.Response,
request_body: dict,
litellm_logging_obj: LiteLLMLoggingObj,
) -> AsyncIterator:
return AnthropicMessagesConfig.get_async_streaming_response_iterator(
self,
model=model,
httpx_response=httpx_response,
request_body=request_body,
litellm_logging_obj=litellm_logging_obj,
)

View file

@ -9,6 +9,7 @@ import json
import os
import threading
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple
from urllib.parse import urlparse
import litellm
from litellm._logging import verbose_logger
@ -315,6 +316,9 @@ class VertexBase:
api_base=api_base,
)
if partner == VertexPartnerProvider.llama:
return default_api_base
if len(default_api_base.split(":")) > 1:
endpoint = default_api_base.split(":")[-1]
else:
@ -615,7 +619,8 @@ class VertexBase:
Handles custom api_base for:
1. Gemini (Google AI Studio) - constructs /models/{model}:{endpoint}
2. Vertex AI with standard proxies - constructs {api_base}:{endpoint}
2. Vertex AI with standard proxies - constructs {api_base}:{endpoint};
if api_base has no path (bare host), grafts the default vertex URL path onto it
3. Vertex AI with PSC endpoints - constructs full path structure
{api_base}/v1/projects/{project}/locations/{location}/endpoints/{model}:{endpoint}
(only when use_psc_endpoint_format=True)
@ -660,8 +665,9 @@ class VertexBase:
model_for_url,
endpoint,
)
elif urlparse(api_base).path in ("", "/"):
url = api_base.rstrip("/") + urlparse(url).path
else:
# Fallback to simple format if we don't have all parameters
url = "{}:{}".format(api_base, endpoint)
if stream is True:
url = url + "?alt=sse"

View file

@ -1082,6 +1082,54 @@ def _build_custom_pricing_entry(
return entry
def _get_router_deployment_id(kwargs: dict) -> Optional[str]:
for metadata_key in ("litellm_metadata", "metadata"):
metadata = kwargs.get(metadata_key) or {}
if not isinstance(metadata, dict):
continue
deployment_model_info = metadata.get("model_info") or {}
if not isinstance(deployment_model_info, dict):
continue
deployment_id = deployment_model_info.get("id")
if deployment_id is not None:
return str(deployment_id)
return None
def _register_custom_pricing_for_request(
model: str,
custom_llm_provider: str,
kwargs: dict,
model_info: Optional[dict],
) -> None:
"""Register per-request custom pricing in litellm.model_cost.
Router-originated requests (identified by the deployment id the router puts
in metadata) get their full pricing registered under that unique id only;
the shared ``{provider}/{model}`` key receives the entry with pricing fields
stripped, mirroring Router._create_deployment. This keeps one deployment's
pricing overrides (e.g. a zero-cost wildcard) from clobbering built-in
pricing used by sibling deployments of the same backend model. Direct SDK
calls keep the legacy behavior of registering the shared key with pricing.
"""
entry = _build_custom_pricing_entry(
custom_llm_provider=custom_llm_provider,
kwargs=kwargs,
model_info=model_info,
)
shared_key = f"{custom_llm_provider}/{model}"
deployment_id = _get_router_deployment_id(kwargs)
if deployment_id is None:
litellm.register_model({shared_key: entry})
return
litellm.register_model(
{
deployment_id: entry,
shared_key: CustomPricingLiteLLMParams.strip_custom_pricing_fields(entry),
}
)
def _complete_azure(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
_azure_detection_model = ctx._azure_detection_model
acompletion = ctx.acompletion
@ -5108,14 +5156,11 @@ def completion( # type: ignore
if (
input_cost_per_token is not None and output_cost_per_token is not None
) or input_cost_per_second is not None:
litellm.register_model(
{
f"{custom_llm_provider}/{model}": _build_custom_pricing_entry(
custom_llm_provider=custom_llm_provider,
kwargs=kwargs,
model_info=model_info,
)
}
_register_custom_pricing_for_request(
model=model,
custom_llm_provider=custom_llm_provider,
kwargs=kwargs,
model_info=model_info,
)
### BUILD CUSTOM PROMPT TEMPLATE -- IF GIVEN ###
custom_prompt_dict = {} # type: ignore
@ -5959,14 +6004,11 @@ def embedding(
### REGISTER CUSTOM MODEL PRICING -- IF GIVEN ###
if (input_cost_per_token is not None and output_cost_per_token is not None) or input_cost_per_second is not None:
litellm.register_model(
{
f"{custom_llm_provider}/{model}": _build_custom_pricing_entry(
custom_llm_provider=custom_llm_provider,
kwargs=kwargs,
model_info=kwargs.get("model_info"),
)
}
_register_custom_pricing_for_request(
model=model,
custom_llm_provider=custom_llm_provider,
kwargs=kwargs,
model_info=kwargs.get("model_info"),
)
litellm_params_dict = get_litellm_params(**kwargs)

View file

@ -23503,6 +23503,76 @@
"supports_system_messages": true,
"supports_tool_choice": true
},
"gpt-realtime-2.1": {
"cache_creation_input_audio_token_cost": 4e-07,
"cache_read_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": 128000,
"max_output_tokens": 32000,
"max_tokens": 32000,
"mode": "chat",
"output_cost_per_audio_token": 6.4e-05,
"output_cost_per_token": 2.4e-05,
"regional_processing_uplift_multiplier_eu": 1.1,
"regional_processing_uplift_multiplier_us": 1.1,
"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-2.1-mini": {
"cache_creation_input_audio_token_cost": 3e-07,
"cache_read_input_audio_token_cost": 3e-07,
"cache_read_input_token_cost": 6e-08,
"input_cost_per_audio_token": 1e-05,
"input_cost_per_image": 8e-07,
"input_cost_per_token": 6e-07,
"litellm_provider": "openai",
"max_input_tokens": 128000,
"max_output_tokens": 4096,
"max_tokens": 4096,
"mode": "chat",
"output_cost_per_audio_token": 2e-05,
"output_cost_per_token": 2.4e-06,
"regional_processing_uplift_multiplier_eu": 1.1,
"regional_processing_uplift_multiplier_us": 1.1,
"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,
@ -38031,6 +38101,48 @@
"supports_vision": true,
"supports_web_search": true
},
"xai/grok-4.5": {
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_above_200k_tokens": 1e-06,
"input_cost_per_token": 2e-06,
"input_cost_per_token_above_200k_tokens": 4e-06,
"litellm_provider": "xai",
"max_input_tokens": 500000,
"max_output_tokens": 500000,
"max_tokens": 500000,
"mode": "chat",
"output_cost_per_token": 6e-06,
"output_cost_per_token_above_200k_tokens": 1.2e-05,
"source": "https://docs.x.ai/docs/models",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true
},
"xai/grok-4.5-latest": {
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_above_200k_tokens": 1e-06,
"input_cost_per_token": 2e-06,
"input_cost_per_token_above_200k_tokens": 4e-06,
"litellm_provider": "xai",
"max_input_tokens": 500000,
"max_output_tokens": 500000,
"max_tokens": 500000,
"mode": "chat",
"output_cost_per_token": 6e-06,
"output_cost_per_token_above_200k_tokens": 1.2e-05,
"source": "https://docs.x.ai/docs/models",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true
},
"xai/grok-beta": {
"input_cost_per_token": 5e-06,
"litellm_provider": "xai",

View file

@ -83,6 +83,15 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
token_url: Optional[str] = None
registration_url: Optional[str] = None
oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = None
# Token Exchange (OBO) fields — RFC 8693. ``audience`` is named for the RFC's
# request parameter (token-exchange only); RFC 8707 resource indicators are a
# separate concept named ``resource`` in the v2 egress types. A null
# ``subject_token_type`` means DEFAULT_SUBJECT_TOKEN_TYPE (litellm.types.mcp),
# applied at the egress build sites.
token_exchange_endpoint: Optional[str] = None
audience: Optional[str] = None
subject_token_type: Optional[str] = None
token_exchange_profile: Optional[str] = None
allow_all_keys: bool = False
available_on_public_internet: bool = True
delegate_auth_to_upstream: bool = False

View file

@ -28,6 +28,7 @@ from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import (
build_token_endpoint_client_auth,
)
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE
if TYPE_CHECKING:
from litellm.types.mcp_server.mcp_server_manager import MCPServer
@ -35,8 +36,6 @@ if TYPE_CHECKING:
# RFC 8693 grant type constant
TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
DEFAULT_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
class TokenExchangeHandler:
"""Handles OAuth 2.0 Token Exchange (RFC 8693) for MCP servers.

View file

@ -8,6 +8,7 @@ from starlette.types import Scope
from litellm._logging import verbose_logger
from litellm.proxy._types import (
UI_TEAM_ID,
LiteLLM_TeamTable,
ProxyException,
SpecialHeaders,
@ -219,6 +220,12 @@ class MCPRequestHandler:
# when EVERY target is auth_type=oauth2 with delegate_auth_to_upstream
# set; fails closed otherwise.
validated_user_api_key_auth = UserAPIKeyAuth()
elif MCPRequestHandler._target_servers_are_true_passthrough(
path=request_route,
mcp_servers=mcp_servers,
client_ip=IPAddressUtils.get_mcp_client_ip(request),
):
validated_user_api_key_auth = UserAPIKeyAuth()
elif oauth2_headers:
# Authorization on a non-delegated server: the bearer must be a real
# LiteLLM credential, so a failed validation is a genuine 401/403 and
@ -357,6 +364,7 @@ class MCPRequestHandler:
# Inline imports avoid a circular dependency: mcp_server_manager imports
# from this module.
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
MCPServerManager,
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
@ -382,7 +390,45 @@ class MCPRequestHandler:
# fetches the upstream token automatically using stored credentials,
# so allowing anonymous bypass would let any external caller invoke
# tools authenticated as LiteLLM's service account.
if server.has_client_credentials:
#
# Resolve the flow rather than reading has_client_credentials directly:
# this is a security gate, and a legacy row whose oauth2_flow was never
# stamped still carries the M2M credential shape (client_id/secret +
# token_url, no authorization_url). Treating an unstamped-but-M2M-shaped
# row as non-M2M here would reopen the anonymous bypass the explicit
# column no longer closes on its own. Shares the one resolution helper
# with the egress backstop and the anonymous-delegate allowlist; all fail
# closed on the ambiguous shape and are removed together once no null rows
# remain. A pure-PKCE delegate server (no stored credentials) resolves to a
# non-M2M flow and keeps its bypass.
if MCPServerManager.effective_oauth2_flow(server) == "client_credentials":
return False
return True
@staticmethod
def _target_servers_are_true_passthrough(
path: str, mcp_servers: Optional[list[str]], client_ip: Optional[str]
) -> bool:
"""
True only when EVERY MCP server the request targets is ``auth_type == true_passthrough``.
Fails closed when any target does not opt in or cannot be resolved.
Used by :meth:`process_mcp_request` to skip LiteLLM admission auth entirely: the gateway is a
transparent proxy and the caller's ``Authorization`` is an upstream token, never a LiteLLM key.
Mirrors :meth:`_target_servers_delegate_auth_to_upstream`; a mixed-target request keeps normal auth.
"""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
target_names = MCPRequestHandler._resolve_target_server_names(path=path, mcp_servers_header=mcp_servers)
if not target_names:
return False
for name in target_names:
server = global_mcp_server_manager.get_mcp_server_by_name(name, client_ip=client_ip)
if server is None or server.auth_type != MCPAuth.true_passthrough:
return False
return True
@ -545,10 +591,19 @@ class MCPRequestHandler:
ASGI headers are in format: List[List[bytes, bytes]]
We need to convert them to the format Headers expects.
Collapsing the ASGI list into a dict keeps the last value for a duplicated
header name, so a request carrying more than one ``Authorization`` is
rejected first: for the client-forwarded token modes the gateway relays the
caller's ``Authorization`` upstream, so a duplicate would make which token is
forwarded ambiguous (and diverge from what admission inspected). Multiple
``Authorization`` headers is malformed for bearer auth anyway (RFC 9110: not
a comma-combinable field), so fail closed with a 400.
"""
raw_headers = scope.get("headers", [])
MCPRequestHandler._reject_duplicate_authorization(raw_headers)
try:
# ASGI headers are list of [name: bytes, value: bytes] pairs
raw_headers = scope.get("headers", [])
# Convert bytes to strings and create dict for Headers constructor
headers_dict = {name.decode("latin-1"): value.decode("latin-1") for name, value in raw_headers}
return Headers(headers_dict)
@ -557,6 +612,26 @@ class MCPRequestHandler:
# Return empty Headers object with empty dict
return Headers({})
@staticmethod
def _reject_duplicate_authorization(raw_headers: object) -> None:
"""Raise 400 when the raw ASGI headers carry more than one ``Authorization`` header."""
if not isinstance(raw_headers, (list, tuple)):
return
count = 0
for entry in raw_headers:
if not isinstance(entry, (list, tuple)) or len(entry) < 1:
continue
name = entry[0]
if isinstance(name, (bytes, bytearray)) and bytes(name).lower() == b"authorization":
count += 1
elif isinstance(name, str) and name.lower() == "authorization":
count += 1
if count > 1:
raise HTTPException(
status_code=400,
detail="Multiple Authorization headers are not allowed",
)
@staticmethod
async def get_allowed_mcp_servers(
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
@ -726,6 +801,9 @@ class MCPRequestHandler:
if not user_api_key_auth or not user_api_key_auth.team_id or not prisma_client:
return None
if user_api_key_auth.team_id == UI_TEAM_ID:
return None
# Get the team object (which has object_permission already loaded)
team_obj: Optional[LiteLLM_TeamTable] = await get_team_object(
team_id=user_api_key_auth.team_id,
@ -1021,6 +1099,9 @@ class MCPRequestHandler:
if user_api_key_auth is None or not user_api_key_auth.team_id or prisma_client is None:
return []
if user_api_key_auth.team_id == UI_TEAM_ID:
return []
team_obj: Optional[LiteLLM_TeamTable] = await get_team_object(
team_id=user_api_key_auth.team_id,
prisma_client=prisma_client,
@ -1503,6 +1584,9 @@ class MCPRequestHandler:
verbose_logger.debug("prisma_client is None")
return []
if user_api_key_auth.team_id == UI_TEAM_ID:
return []
try:
team_obj: Optional[LiteLLM_TeamTable] = await get_team_object(
team_id=user_api_key_auth.team_id,

View file

@ -46,6 +46,35 @@ from litellm.types.mcp import MCPCredentials
if TYPE_CHECKING:
from litellm.types.mcp_server.mcp_server_manager import MCPServer
_AUTH_FLOW_SCOPED_FIELDS: frozenset = frozenset(
{
"authorization_url",
"token_url",
"registration_url",
"oauth2_flow",
"token_exchange_endpoint",
"audience",
"subject_token_type",
"token_exchange_profile",
}
)
# Token-exchange settings with dedicated columns that also exist on
# ``MCPCredentials`` as a legacy shape (rows and REST callers that predate the
# columns). Every write lifts blob values into the columns and strips them from
# the stored blob, so the read-time ``column or blob`` fallback only serves rows
# the current code has never written — a cleared column can then never be
# silently resurrected by a stale blob copy. These keys are stored plaintext
# (endpoints/identifiers, not secrets), so values lift as-is.
_TOKEN_EXCHANGE_COLUMN_FIELDS: frozenset = frozenset(
{
"token_exchange_endpoint",
"audience",
"subject_token_type",
"token_exchange_profile",
}
)
def _is_global_env_var_scope(scope: Any) -> bool:
"""``scope="user"`` entries are placeholders the user fills in; everything
@ -241,6 +270,14 @@ def _prepare_mcp_server_data(
# Handle credentials serialization
credentials = data_dict.get("credentials")
if credentials is not None:
# Lift legacy blob-shaped token-exchange settings into their dedicated
# columns (an explicit top-level value wins, including an explicit
# null) and strip them from the blob so it never seeds the read-time
# fallback for rows written by current code.
for te_field in _TOKEN_EXCHANGE_COLUMN_FIELDS:
blob_value = credentials.pop(te_field, None)
if blob_value is not None and te_field not in data_dict:
data_dict[te_field] = blob_value
data_dict["credentials"] = encrypt_credentials(credentials=credentials, encryption_key=_get_salt_key())
data_dict["credentials"] = safe_dumps(data_dict["credentials"])
@ -603,19 +640,41 @@ async def update_mcp_server(
# Pre-fetch existing record once if we need it for auth_type or credential logic
existing = None
has_credentials = "credentials" in data_dict and data_dict["credentials"] is not None
if data.auth_type or has_credentials:
# An explicit token-exchange column write (set or clear) also migrates the
# legacy blob copies below, so the existing row is needed for those updates.
explicit_te_write = bool(_TOKEN_EXCHANGE_COLUMN_FIELDS & data_dict.keys())
if data.auth_type or has_credentials or explicit_te_write:
existing = await MCPServerRepository(prisma_client).table.find_unique(where={"server_id": data.server_id})
auth_type_changed = bool(
data.auth_type and existing and existing.auth_type is not None and existing.auth_type != data.auth_type
)
# Clear stale credentials when auth_type changes but no new credentials provided
if (
data.auth_type
and "credentials" not in data_dict
and existing
and existing.auth_type is not None
and existing.auth_type != data.auth_type
):
if auth_type_changed and "credentials" not in data_dict:
data_dict["credentials"] = None
if auth_type_changed:
data_dict.update({field: None for field in _AUTH_FLOW_SCOPED_FIELDS if field not in data_dict})
# An explicit column write that does not touch credentials must still migrate
# the row's legacy blob copies: lift values for columns the caller left
# untouched, strip every copy from the blob. Without this, clearing a column
# (e.g. to re-enable RFC 9728/8414 discovery) would leave the blob copy in
# place, and the next credentials update's migrate-on-write would silently
# repopulate the column the admin just cleared. (When credentials ARE in the
# update, the merge below performs the same migration.)
if explicit_te_write and "credentials" not in data_dict and existing is not None and existing.credentials:
existing_creds = (
json.loads(existing.credentials) if isinstance(existing.credentials, str) else dict(existing.credentials)
)
if _TOKEN_EXCHANGE_COLUMN_FIELDS & existing_creds.keys():
for te_field in _TOKEN_EXCHANGE_COLUMN_FIELDS:
legacy_value = existing_creds.pop(te_field, None)
if legacy_value is not None and te_field not in data_dict and getattr(existing, te_field, None) is None:
data_dict[te_field] = legacy_value
data_dict["credentials"] = safe_dumps(existing_creds)
# Merge credentials: preserve existing fields not present in the update.
# Without this, a partial credential update (e.g. changing only region)
# would wipe encrypted secrets that the UI cannot display back.
@ -638,6 +697,19 @@ async def update_mcp_server(
)
# New values override existing; existing keys not in update are preserved
merged = {**existing_creds, **new_creds}
# Migrate-on-write for legacy rows: token-exchange settings the
# old blob shape carried move to their dedicated columns (unless
# the caller set the column this update, or the row already has
# one) and are never re-persisted in the blob. Stored plaintext,
# so the merged value lifts as-is.
for te_field in _TOKEN_EXCHANGE_COLUMN_FIELDS:
legacy_value = merged.pop(te_field, None)
if (
legacy_value is not None
and te_field not in data_dict
and getattr(existing, te_field, None) is None
):
data_dict[te_field] = legacy_value
data_dict["credentials"] = safe_dumps(merged)
# Add audit fields

View file

@ -448,6 +448,12 @@ async def _store_per_user_token_server_side(
)
return # Don't warm Redis if DB write failed
from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415
global_mcp_server_manager,
)
await global_mcp_server_manager.invalidate_user_oauth_token_cache(user_id, server.server_id)
# Warm the Redis cache so the first subsequent MCP call is a cache hit
ttl = _compute_per_user_token_ttl(server, expires_in)
await mcp_per_user_token_cache.set(
@ -575,8 +581,12 @@ async def exchange_token_with_server(
if mcp_server.token_url is None:
raise HTTPException(status_code=400, detail="MCP server token url is not set")
# The id and secret must come from the same source. When the server-side client_id wins,
# falling back to the caller's secret pairs the persisted client with a foreign secret; the
# register short-circuit hands clients a placeholder secret ("dummy"), so a re-auth against a
# persisted public PKCE client (no stored secret) would send that placeholder and the IdP 401s.
resolved_client_id = mcp_server.client_id if mcp_server.client_id else client_id
resolved_client_secret = mcp_server.client_secret if mcp_server.client_secret else client_secret
resolved_client_secret = mcp_server.client_secret if mcp_server.client_id else client_secret
try:
client_auth = build_token_endpoint_client_auth(
auth_method=mcp_server.token_endpoint_auth_method,
@ -1292,11 +1302,15 @@ async def _build_oauth_protected_resource_response(
"""
Build OAuth protected resource response with the appropriate URL pattern.
For pass-through MCP servers (``MCPServer.is_oauth_passthrough``), the
gateway proxies the upstream's own ``oauth-protected-resource`` metadata
so that standards-compliant MCP clients discover the **upstream** IdP
instead of the gateway. The ``resource`` field is rewritten to the
gateway's own URL so clients present the bearer token back to the gateway.
For pass-through MCP servers, the gateway proxies the upstream's own
``oauth-protected-resource`` metadata so standards-compliant MCP clients
discover the **upstream** IdP instead of the gateway. For ``true_passthrough``
and ``oauth_delegate`` the metadata is returned verbatim (``resource`` stays
the upstream): the caller's token is forwarded to and validated by the
upstream, so its audience must be the upstream — rewriting it to the gateway
would make a strict IdP (e.g. Entra) refuse to mint it or the upstream reject
it. Only the legacy ``is_oauth_passthrough`` opt-in rewrites ``resource`` to
the gateway's own URL so clients present the bearer token back to the gateway.
Args:
request: FastAPI Request object
@ -1337,7 +1351,9 @@ async def _build_oauth_protected_resource_response(
# Pass-through branch: proxy the upstream's own metadata so discovery
# directs the client at the real IdP (Okta, Keycloak, …) instead of us.
if mcp_server is not None and mcp_server.is_oauth_passthrough:
if mcp_server is not None and (
mcp_server.is_oauth_passthrough or mcp_server.is_oauth_delegate or mcp_server.is_true_passthrough
):
try:
upstream_metadata = await fetch_upstream_oauth_protected_resource(mcp_server)
except Exception as exc:
@ -1353,8 +1369,9 @@ async def _build_oauth_protected_resource_response(
)
if upstream_metadata is not None:
response = {**upstream_metadata, "resource": resource_url}
return response
if mcp_server.is_true_passthrough or mcp_server.is_oauth_delegate:
return upstream_metadata
return {**upstream_metadata, "resource": resource_url}
# Upstream responded but with non-200 or non-dict payload. For
# pass-through servers the gateway is NOT the authorization server,

View file

@ -73,3 +73,18 @@ class MCPUpstreamAuthError(Exception):
detail=detail,
headers={"www-authenticate": challenge} if challenge else None,
)
class MCPToolResultError(Exception):
"""An MCP tool call completed with ``isError=True`` in its result.
Never raised on the wire path: streamable HTTP MCP correctly returns tool
failures as HTTP 200 with ``result.isError: true`` per the MCP spec. This
exception only drives the standard failure logging (``status="failure"``
payload, OTel ERROR span) for such results.
Lives here rather than ``utils.py`` deliberately: tests reload ``utils``
to re-read its env-derived constants, and a reload would fork this class
into two identities, breaking ``isinstance`` checks against instances
created before the reload.
"""

View file

@ -70,6 +70,9 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import
to_server_spec,
to_subject,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import (
InvalidatableOAuthTokenStore,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.per_user_oauth_store import (
LazyPerUserOAuthTokenStore,
)
@ -78,6 +81,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.token_exchange_
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
AuthorizationCodeConfig,
PassthroughConfig,
ServerSpec,
TokenExchangeConfig,
)
@ -115,7 +119,7 @@ from litellm.proxy.common_utils.user_api_key_cache import get_management_object_
from litellm.proxy.utils import ProxyLogging, get_server_root_path
from litellm.repositories.table_repositories import MCPServerRepository
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.mcp import MCPAuth, MCPStdioConfig
from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE, MCPAuth, MCPStdioConfig
from litellm.types.mcp_server.mcp_server_manager import (
MCPInfo,
MCPOAuthMetadata,
@ -213,6 +217,13 @@ def _should_strip_caller_authorization(
pass-through cold-start case (RFC 9728) the bearer in
``Authorization`` is the upstream OAuth token and must be
forwarded, so we keep it.
- **oauth_delegate servers**: admission always runs and there is no
anonymous path, so the caller's separate ``Authorization`` is
forwarded only when a distinct ``x-litellm-api-key`` carried
admission. Without that header the ``Authorization`` *was* the
admission credential — a virtual key, an IdP JWT, or an SSO / OIDC /
session token whose ``api_key`` is ``None`` — and must never reach
the upstream, so it is stripped regardless of the ``api_key`` value.
"""
if mcp_server.auth_type == MCPAuth.oauth2_token_exchange:
# OBO: the inbound Authorization is the subject token. It is exchanged at the IdP and only the
@ -226,11 +237,13 @@ def _should_strip_caller_authorization(
# upstream — it would override another user's stored credential. Delegate and
# pass-through return None from to_server_spec and keep forwarding the bearer.
return True
if not mcp_server.is_oauth_passthrough:
if not (mcp_server.is_oauth_passthrough or mcp_server.is_oauth_delegate):
return False
normalized_raw_headers = {str(k).lower(): v for k, v in (raw_headers or {}).items() if isinstance(k, str)}
has_explicit_litellm_admission_header = normalized_raw_headers.get("x-litellm-api-key") is not None
if mcp_server.is_oauth_delegate:
return not has_explicit_litellm_admission_header
admission_consumed_authorization_as_litellm_key = (
user_api_key_auth is not None
and bool(getattr(user_api_key_auth, "api_key", None))
@ -253,6 +266,159 @@ def _without_authorization(
return filtered or None
def _format_byok_openapi_auth_header(mcp_server: MCPServer, mcp_auth_header: str) -> str:
"""Format a raw BYOK credential for OpenAPI tool ``Authorization`` injection."""
if mcp_server.auth_type == MCPAuth.api_key:
return f"ApiKey {mcp_auth_header}"
if mcp_server.auth_type == MCPAuth.basic:
return f"Basic {mcp_auth_header}"
return f"Bearer {mcp_auth_header}"
def _openapi_forwarded_extra_headers(
mcp_server: MCPServer,
raw_headers: Optional[dict[str, str]],
user_api_key_auth: Optional[UserAPIKeyAuth],
) -> Optional[dict[str, str]]:
if not mcp_server.extra_headers or not raw_headers:
return None
normalized_raw = {str(k).lower(): v for k, v in raw_headers.items() if isinstance(k, str)}
skip_caller_authorization = _should_strip_caller_authorization(
mcp_server=mcp_server,
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
)
forwarded: dict[str, str] = {}
for header_name in mcp_server.extra_headers:
if not isinstance(header_name, str):
continue
if skip_caller_authorization and header_name.lower() == "authorization":
continue
value = normalized_raw.get(header_name.lower())
if value is not None:
forwarded[header_name] = value
return forwarded or None
async def _resolve_byok_mcp_auth_header(
mcp_server: MCPServer,
user_api_key_auth: Optional[UserAPIKeyAuth],
mcp_auth_header: Optional[str],
) -> Optional[str]:
"""Resolve BYOK credential for tool calls that bypass ``execute_mcp_tool``."""
if not mcp_server.is_byok:
return mcp_auth_header
from litellm.proxy._experimental.mcp_server.server import (
_check_byok_credential,
_get_byok_credential,
)
if not mcp_auth_header:
byok_cred = await _get_byok_credential(mcp_server, user_api_key_auth)
if byok_cred is None:
raise HTTPException(
status_code=401,
detail={
"error": "byok_auth_required",
"server_id": mcp_server.server_id,
"server_name": mcp_server.server_name or mcp_server.name,
"message": (
"No stored credential found for this BYOK server. "
"Complete the OAuth authorization flow to provide your API key."
),
},
headers={"WWW-Authenticate": 'Bearer resource_metadata="/.well-known/oauth-protected-resource"'},
)
return byok_cred
await _check_byok_credential(mcp_server, user_api_key_auth)
return mcp_auth_header
def _client_forwarded_authorization_headers(
mcp_server: MCPServer,
oauth2_headers: Optional[dict[str, str]],
raw_headers: Optional[dict[str, str]],
user_api_key_auth: Optional[UserAPIKeyAuth],
) -> Optional[dict[str, str]]:
"""Egress headers for the client-forwarded-token modes (``true_passthrough`` / ``oauth_delegate``).
Forwards the caller's ``Authorization`` to the upstream, stripped when
``_should_strip_caller_authorization`` says it was consumed as the LiteLLM admission key. Shared by
``_call_regular_mcp_tool`` and ``server.py``'s ``_prepare_mcp_server_headers`` so the two egress
paths cannot drift, mirroring the ``_should_strip_caller_authorization`` split.
"""
extra_headers = oauth2_headers.copy() if oauth2_headers else None
if extra_headers and _should_strip_caller_authorization(
mcp_server=mcp_server,
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
):
return _without_authorization(extra_headers)
return extra_headers
def _take_forwarded_authorization(
headers: Optional[dict[str, str]],
) -> tuple[Optional[str], Optional[dict[str, str]]]:
"""Pop the ``Authorization`` value out of ``headers`` (case-insensitive), returning it with the
remaining headers, so the passthrough resolver arm is the single Authorization source rather than
the header also riding in ``extra_headers`` (which the resolved auth would then defer to)."""
if not headers:
return None, headers
value = next((v for k, v in headers.items() if k.lower() == "authorization"), None)
return value, _without_authorization(headers)
def _passthrough_token_from_mcp_auth_header(
mcp_auth_header: Optional[Union[str, dict[str, str]]],
) -> Optional[str]:
"""The caller's per-server upstream credential for a passthrough-mode server, or None.
Sourced from ``x-mcp-{alias}-authorization`` (string or per-header dict form) or the deprecated
global ``x-mcp-auth`` fallback. Per-server headers are the multi-server shape: they bind one
token to one server, so an aggregate scope with several passthrough-mode servers never replays
a single credential across upstreams. The value is forwarded verbatim, so it must be the full
header value (e.g. ``Bearer <upstream-token>``)."""
if isinstance(mcp_auth_header, str):
return mcp_auth_header or None
if isinstance(mcp_auth_header, dict):
return next((v for k, v in mcp_auth_header.items() if k.lower() == "authorization"), None)
return None
def _consumes_caller_authorization(server: MCPServer) -> bool:
"""True when this server's egress forwards the caller's request-wide ``Authorization`` upstream:
the client-forwarded token modes, legacy OAuth pass-through, and legacy upstream-delegated
interactive oauth2. An unstamped oauth2 row (flow column not yet backfilled) reads as a consumer,
which errs toward suppression — the fail-safe direction."""
if server.is_true_passthrough or server.is_oauth_delegate or server.is_oauth_passthrough:
return True
return (
server.auth_type == MCPAuth.oauth2
and getattr(server, "delegate_auth_to_upstream", False) is True
and not server.has_client_credentials
)
def _caller_authorization_fans_out(
server: MCPServer,
scope_servers: Optional[list[MCPServer]],
) -> bool:
"""True when forwarding the caller's request-wide ``Authorization`` to ``server`` inside a
listing fan-out would replay one credential against multiple upstreams: another server in the
scope also consumes it (RFC 9700 cross-resource replay). ``scope_servers`` is None for
explicitly-addressed operations (tool call, get_prompt, read_resource, single-server routes),
where the client named the one target and the gateway is not choosing recipients."""
if scope_servers is None:
return False
return any(
other is not None and other.server_id != server.server_id and _consumes_caller_authorization(other)
for other in scope_servers
)
def _extract_upstream_auth_failure(
exc: BaseException,
) -> Optional[tuple[int, Optional[str]]]:
@ -511,6 +677,21 @@ def _create_elicitation_callback():
class MCPServerManager:
_STDIO_ENV_TEMPLATE_PATTERN = re.compile(r"^\$\{(X-[^}]+)\}$")
@staticmethod
def _explicit_oauth2_flow(
oauth2_flow: Optional[str],
) -> Optional[Literal["client_credentials", "authorization_code"]]:
"""DB rows persist their flow (write-time stamps plus the startup backfill) and
config servers must declare it (validated at load), so both builds read the
value verbatim: unknown or null resolves to None, which
``needs_user_oauth_token`` already treats as interactive. Field-shape inference
survives only in the request-time security helpers (``effective_oauth2_flow`` /
``resolve_oauth2_flow_for_request``).
"""
if oauth2_flow in ("client_credentials", "authorization_code"):
return cast(Literal["client_credentials", "authorization_code"], oauth2_flow)
return None
@staticmethod
def _resolve_oauth2_flow(
*,
@ -521,11 +702,18 @@ class MCPServerManager:
client_id: Optional[str],
client_secret: Optional[str],
) -> Optional[Literal["client_credentials", "authorization_code"]]:
"""Infer oauth2_flow for legacy records that omit the field.
"""Infer oauth2_flow from field shape when the value is omitted.
DB rows created before oauth2_flow support may have OAuth2 client
credentials + token_url but a null oauth2_flow. Treat these as M2M,
unless authorization_url is present (interactive OAuth).
SECURITY-SENSITIVE: this is the shape-inference engine both request-time security
helpers delegate to, so it is what decides M2M-vs-interactive for an unstamped row.
Always access it through ``effective_oauth2_flow`` (boolean/enum decisions) or
``resolve_oauth2_flow_for_request`` (the egress object backstop), which are the single
choke points for request-time resolution; do not call it directly from security sites
and do not weaken its M2M-shape branch without accounting for those callers. DB rows
are stamped at write time and by the startup backfill, config servers must declare
oauth2_flow (validated at load), and both builds read the value verbatim via
``_explicit_oauth2_flow``. Delete this whole request-time layer only once the backstop
warning stays silent in production.
"""
if oauth2_flow in ("client_credentials", "authorization_code"):
return cast(Literal["client_credentials", "authorization_code"], oauth2_flow)
@ -540,6 +728,51 @@ class MCPServerManager:
return "client_credentials"
return None
@staticmethod
def effective_oauth2_flow(server: "MCPServer") -> Optional[Literal["client_credentials", "authorization_code"]]:
"""The oauth2_flow a security decision must use for ``server`` this request.
Column-first, shape-fallback: a stamped row returns its explicit value; an
unstamped (null) row whose fields carry the M2M shape resolves to
``client_credentials`` so it is treated as M2M and fails closed. Every
security-sensitive reader (anonymous-delegate allowlist and gate, egress flow
resolution) goes through this one helper rather than reading the bare
``has_client_credentials`` column, which is unreliable for null rows.
"""
return MCPServerManager._resolve_oauth2_flow(
auth_type=server.auth_type,
oauth2_flow=server.oauth2_flow,
token_url=server.token_url,
authorization_url=server.authorization_url,
client_id=server.client_id,
client_secret=server.client_secret,
)
@staticmethod
def resolve_oauth2_flow_for_request(server: "MCPServer") -> "MCPServer":
"""Return ``server`` with its effective oauth2_flow applied, for egress paths.
A stamped row is returned unchanged (its effective flow equals the stored value).
An unstamped M2M-shape row is returned as a per-request copy carrying
``oauth2_flow=client_credentials`` so downstream ``has_client_credentials`` /
``needs_user_oauth_token`` compute correctly and the stored client credentials are
used instead of forwarding the caller's Authorization. Use this at every point that
resolves an allowed server id into an ``MCPServer`` for a tool call or listing.
"""
effective = MCPServerManager.effective_oauth2_flow(server)
if effective is None or effective == server.oauth2_flow:
return server
verbose_logger.warning(
"MCP server %s has no persisted oauth2_flow but matches the %s shape; using the "
"inferred flow for this request. The startup backfill leaves this ambiguous M2M "
"shape unstamped on purpose, so it will NOT self-heal: set oauth2_flow explicitly "
"in the dashboard or via PUT /v1/mcp/server (client_credentials for M2M, or "
"authorization_code after an interactive sign-in).",
server.server_id,
effective,
)
return server.model_copy(update={"oauth2_flow": effective})
@staticmethod
def _obo_needs_endpoint_discovery(
auth_type: Optional[MCPAuthType],
@ -552,9 +785,16 @@ class MCPServerManager:
"""
return auth_type == MCPAuth.oauth2_token_exchange and not (token_exchange_endpoint or token_url)
def __init__(self, cred_provider: Optional[UpstreamCredentialProvider] = None):
def __init__(
self,
cred_provider: Optional[UpstreamCredentialProvider] = None,
per_user_oauth_token_store: Optional[InvalidatableOAuthTokenStore] = None,
):
self._per_user_oauth_token_store = per_user_oauth_token_store or LazyPerUserOAuthTokenStore(
self.get_mcp_server_by_id
)
self._cred_provider = cred_provider or UpstreamCredentialProvider(
oauth_token_store=LazyPerUserOAuthTokenStore(self.get_mcp_server_by_id),
oauth_token_store=self._per_user_oauth_token_store,
token_exchanger=build_token_exchanger(),
)
self.registry: dict[str, MCPServer] = {}
@ -578,8 +818,10 @@ class MCPServerManager:
# Per-server outbound tool-call concurrency limiters, lazily created from
# each server's max_concurrent_requests. Keyed by server_id so the cap
# survives the registry atomic-swap on config reload; a missing key means
# the server has no configured limit.
self._server_call_semaphores: dict[str, asyncio.Semaphore] = {}
# the server has no configured limit. The limit is cached alongside the
# semaphore so an edited limit rebuilds it instead of keeping the old cap
# until restart.
self._server_call_semaphores: dict[str, tuple[int, asyncio.Semaphore]] = {}
self.tool_name_to_mcp_server_name_mapping: dict[str, str] = {}
"""
{
@ -772,6 +1014,20 @@ class MCPServerManager:
mcp_oauth_metadata.registration_url if mcp_oauth_metadata else None
)
config_oauth2_flow = server_config.get("oauth2_flow", None)
if auth_type == MCPAuth.oauth2 and config_oauth2_flow not in (
"client_credentials",
"authorization_code",
):
raise ValueError(
f"Invalid config for MCP server '{server_name or server_id}': auth_type oauth2 "
f"requires an explicit oauth2_flow (got {config_oauth2_flow!r}). Set "
"oauth2_flow: client_credentials for machine-to-machine servers (the proxy mints "
"a shared token at token_url using client_id/client_secret, no user interaction) "
"or oauth2_flow: authorization_code for interactive servers (per-user tokens via "
"browser sign-in, including delegate_auth_to_upstream)."
)
new_server = MCPServer(
server_id=server_id,
name=name_for_prefix,
@ -785,14 +1041,7 @@ class MCPServerManager:
# oauth specific fields
client_id=server_config.get("client_id", None),
client_secret=server_config.get("client_secret", None),
oauth2_flow=self._resolve_oauth2_flow(
auth_type=auth_type,
oauth2_flow=server_config.get("oauth2_flow", None),
token_url=resolved_token_url,
authorization_url=resolved_authorization_url,
client_id=server_config.get("client_id", None),
client_secret=server_config.get("client_secret", None),
),
oauth2_flow=self._explicit_oauth2_flow(config_oauth2_flow),
scopes=resolved_scopes,
authorization_url=resolved_authorization_url,
token_url=resolved_token_url,
@ -828,7 +1077,7 @@ class MCPServerManager:
audience=server_config.get("audience", None),
subject_token_type=server_config.get(
"subject_token_type",
"urn:ietf:params:oauth:token-type:access_token",
DEFAULT_SUBJECT_TOKEN_TYPE,
),
token_exchange_profile=server_config.get("token_exchange_profile", "rfc8693"),
allow_sampling=bool(server_config.get("allow_sampling", False)),
@ -1139,7 +1388,8 @@ class MCPServerManager:
(auth_type == MCPAuth.oauth2 and not mcp_server.authorization_url)
or self._obo_needs_endpoint_discovery(
auth_type,
credentials_dict.get("token_exchange_endpoint") if credentials_dict else None,
mcp_server.token_exchange_endpoint
or (credentials_dict.get("token_exchange_endpoint") if credentials_dict else None),
mcp_server.token_url,
)
)
@ -1170,15 +1420,7 @@ class MCPServerManager:
env_vars=env_vars_list,
client_id=client_id_value or getattr(mcp_server, "client_id", None),
client_secret=client_secret_value or getattr(mcp_server, "client_secret", None),
oauth2_flow=self._resolve_oauth2_flow(
auth_type=auth_type,
oauth2_flow=getattr(mcp_server, "oauth2_flow", None),
token_url=mcp_server.token_url or getattr(mcp_oauth_metadata, "token_url", None),
authorization_url=mcp_server.authorization_url
or getattr(mcp_oauth_metadata, "authorization_url", None),
client_id=client_id_value or getattr(mcp_server, "client_id", None),
client_secret=client_secret_value or getattr(mcp_server, "client_secret", None),
),
oauth2_flow=self._explicit_oauth2_flow(getattr(mcp_server, "oauth2_flow", None)),
scopes=resolved_scopes,
authorization_url=mcp_server.authorization_url or getattr(mcp_oauth_metadata, "authorization_url", None),
token_url=mcp_server.token_url or getattr(mcp_oauth_metadata, "token_url", None),
@ -1213,12 +1455,16 @@ class MCPServerManager:
aws_role_name=aws_creds.get("aws_role_name"),
aws_session_name=aws_creds.get("aws_session_name"),
instructions=mcp_server.instructions,
# Token Exchange (OBO) fields — read from credentials JSON blob
token_exchange_endpoint=(credentials_dict.get("token_exchange_endpoint") if credentials_dict else None),
audience=(credentials_dict.get("audience") if credentials_dict else None),
subject_token_type=(credentials_dict.get("subject_token_type") if credentials_dict else None)
or "urn:ietf:params:oauth:token-type:access_token",
token_exchange_profile=(credentials_dict.get("token_exchange_profile") if credentials_dict else None)
# Token exchange (OBO) fields: dedicated columns, with the credentials blob as a
# back-compat fallback for servers persisted before the columns existed.
token_exchange_endpoint=mcp_server.token_exchange_endpoint
or (credentials_dict.get("token_exchange_endpoint") if credentials_dict else None),
audience=mcp_server.audience or (credentials_dict.get("audience") if credentials_dict else None),
subject_token_type=mcp_server.subject_token_type
or (credentials_dict.get("subject_token_type") if credentials_dict else None)
or DEFAULT_SUBJECT_TOKEN_TYPE,
token_exchange_profile=mcp_server.token_exchange_profile
or (credentials_dict.get("token_exchange_profile") if credentials_dict else None)
or "rfc8693",
timeout=getattr(mcp_server, "timeout", None),
max_concurrent_requests=getattr(mcp_server, "max_concurrent_requests", None),
@ -1482,12 +1728,18 @@ class MCPServerManager:
delegate_server_ids = [
server.server_id
for server in self.get_registry().values()
if getattr(server, "auth_type", None) == MCPAuth.oauth2
and getattr(server, "delegate_auth_to_upstream", False) is True
# M2M servers must not be exposed anonymously: an
# unauthenticated caller would get LiteLLM to proxy tool
# calls using its stored client_credentials.
and not server.has_client_credentials
if (
getattr(server, "auth_type", None) == MCPAuth.oauth2
and getattr(server, "delegate_auth_to_upstream", False) is True
# M2M servers must not be exposed anonymously: an
# unauthenticated caller would get LiteLLM to proxy tool
# calls using its stored client_credentials. Resolve the flow
# rather than reading has_client_credentials so an unstamped
# M2M-shape row (null column, verbatim-read as non-M2M) still
# fails closed here, matching the anonymous-delegate auth gate.
and MCPServerManager.effective_oauth2_flow(server) != "client_credentials"
)
or getattr(server, "auth_type", None) == MCPAuth.true_passthrough
]
combined_servers.update(delegate_server_ids)
@ -2085,16 +2337,17 @@ class MCPServerManager:
spec = None if transport == MCPTransport.stdio else to_server_spec(server)
provider = cred_provider or self._cred_provider
# A caller-supplied per-request override (mcp_auth_header / x-mcp-*) defers to the v1 path
# so it wins - except for the per-user modes the v2 resolver owns (authorization_code's
# stored token and token_exchange's RFC 8693 minted token). A caller must not be able to
# substitute another user's stored credential, nor silently disable the OBO exchange and
# forward an arbitrary bearer upstream, so we keep the v2 spec and ignore the override for
# both; the REST tools preview supplies its not-yet-persisted token through the resolver
# (cred_provider), never this path.
# so it wins - except for the modes the v2 resolver owns per-caller (authorization_code's
# stored token, token_exchange's RFC 8693 minted token, and the passthrough modes'
# forwarded caller token). A caller must not be able to substitute another user's stored
# credential, nor silently disable the OBO exchange and forward an arbitrary bearer
# upstream, so we keep the v2 spec and ignore the override for these; the REST tools
# preview supplies its not-yet-persisted token through the resolver (cred_provider),
# never this path.
if (
spec is not None
and mcp_auth_header
and not isinstance(spec.config, (AuthorizationCodeConfig, TokenExchangeConfig))
and not isinstance(spec.config, (AuthorizationCodeConfig, PassthroughConfig, TokenExchangeConfig))
):
spec = None
auth_value = (
@ -2161,11 +2414,17 @@ class MCPServerManager:
server_url = server.url or ""
if spec is not None:
inbound_token = subject_token
if isinstance(spec.config, PassthroughConfig):
inbound_token, extra_headers = _take_forwarded_authorization(extra_headers)
per_server_token = _passthrough_token_from_mcp_auth_header(mcp_auth_header)
if per_server_token is not None:
inbound_token = per_server_token
resolved_auth, extra_headers = await self._resolve_v2_auth(
server=server,
spec=spec,
provider=provider,
subject_token=subject_token,
subject_token=inbound_token,
user_api_key_auth=user_api_key_auth,
extra_headers=extra_headers,
)
@ -3450,10 +3709,11 @@ class MCPServerManager:
limit = mcp_server.max_concurrent_requests
if limit is None or limit <= 0:
return None
semaphore = self._server_call_semaphores.get(mcp_server.server_id)
if semaphore is None:
semaphore = asyncio.Semaphore(limit)
self._server_call_semaphores[mcp_server.server_id] = semaphore
cached = self._server_call_semaphores.get(mcp_server.server_id)
if cached is not None and cached[0] == limit:
return cached[1]
semaphore = asyncio.Semaphore(limit)
self._server_call_semaphores[mcp_server.server_id] = (limit, semaphore)
return semaphore
@asynccontextmanager
@ -3586,6 +3846,13 @@ class MCPServerManager:
user_api_key_auth=user_api_key_auth,
):
extra_headers = _without_authorization(extra_headers)
elif mcp_server.is_true_passthrough or mcp_server.is_oauth_delegate:
extra_headers = _client_forwarded_authorization_headers(
mcp_server=mcp_server,
oauth2_headers=oauth2_headers,
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
)
if mcp_server.extra_headers and raw_headers:
if extra_headers is None:
@ -3667,17 +3934,21 @@ class MCPServerManager:
# OBO: the exchanged token may have been revoked/rotated upstream since it was cached, so
# an upstream 401 gets one re-mint + retry. Gated to this mode; all others keep the plain
# single call below.
tool_call_coro = self._obo_call_tool_with_retry(
client=client,
call_tool_params=call_tool_params,
host_progress_callback=host_progress_callback,
mcp_server=mcp_server,
server_auth_header=server_auth_header,
extra_headers=extra_headers,
stdio_env=stdio_env,
subject_token=subject_token,
user_api_key_auth=user_api_key_auth,
)
async def _obo_call_tool_limited():
async with self._limit_outbound_concurrency(mcp_server):
return await self._obo_call_tool_with_retry(
client=client,
call_tool_params=call_tool_params,
host_progress_callback=host_progress_callback,
mcp_server=mcp_server,
server_auth_header=server_auth_header,
extra_headers=extra_headers,
stdio_env=stdio_env,
subject_token=subject_token,
user_api_key_auth=user_api_key_auth,
)
tool_call_coro = _obo_call_tool_limited()
else:
async def _call_tool_via_client(client, params):
@ -3771,6 +4042,19 @@ class MCPServerManager:
return False
return await self._cred_provider.has_user_token(to_subject(user_api_key_auth, None), spec)
async def invalidate_user_oauth_token_cache(self, user_id: str, server_id: str) -> None:
"""Drop the v2 chain's cached token for ``(user_id, server_id)`` after the credential row
changes (re-auth, revoke), so the next resolve reads the new row instead of serving the
replaced token until its cache TTL. Best-effort: a cache-drop failure is logged, never
raised, because the DB write already succeeded and the TTL remains the backstop.
"""
try:
await self._per_user_oauth_token_store.invalidate(user_id, server_id)
except Exception as exc: # noqa: BLE001 - cache drop is best-effort; TTL is the backstop
verbose_logger.warning(
"Failed to invalidate cached MCP OAuth token for user=%s server=%s: %s", user_id, server_id, exc
)
async def _resolve_oauth2_headers_for_tool_call(
self,
mcp_server: MCPServer,
@ -3861,6 +4145,15 @@ class MCPServerManager:
start_time = datetime.datetime.now()
mcp_server = self._resolve_mcp_server_for_tool_call(server_name, name)
# Resolved before any hook runs so a missing BYOK credential (401) never
# leaves during-hook side effects (audit logging, rate-limit bookkeeping)
# recorded against a call that ultimately fails.
mcp_auth_header = await _resolve_byok_mcp_auth_header(
mcp_server,
user_api_key_auth,
mcp_auth_header,
)
#########################################################
# Pre MCP Tool Call Hook
# Allow validation and modification of tool calls before execution
@ -3907,9 +4200,25 @@ class MCPServerManager:
server_name,
)
auth_header_value = (
_format_byok_openapi_auth_header(mcp_server, mcp_auth_header) if mcp_auth_header else None
)
forwarded_headers = _openapi_forwarded_extra_headers(mcp_server, raw_headers, user_api_key_auth)
async def _call_openapi_via_handler():
async with self._limit_outbound_concurrency(mcp_server):
return await self._call_openapi_tool_handler(mcp_server, name, arguments)
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
_request_auth_header,
_request_extra_headers,
)
auth_token = _request_auth_header.set(auth_header_value)
extra_token = _request_extra_headers.set(forwarded_headers)
try:
async with self._limit_outbound_concurrency(mcp_server):
return await self._call_openapi_tool_handler(mcp_server, name, arguments)
finally:
_request_auth_header.reset(auth_token)
_request_extra_headers.reset(extra_token)
tasks.append(asyncio.create_task(_call_openapi_via_handler()))
else:
@ -4465,6 +4774,11 @@ class MCPServerManager:
authorization_url=server.authorization_url,
token_url=server.token_url,
registration_url=server.registration_url,
oauth2_flow=server.oauth2_flow,
token_exchange_endpoint=server.token_exchange_endpoint,
audience=server.audience,
subject_token_type=server.subject_token_type,
token_exchange_profile=server.token_exchange_profile,
allow_all_keys=server.allow_all_keys,
instructions=server.instructions,
timeout=server.timeout,
@ -4553,6 +4867,8 @@ class MCPServerManager:
teams=[],
mcp_access_groups=server.access_groups or [],
allowed_tools=server.allowed_tools or [],
tool_name_to_display_name=server.tool_name_to_display_name,
tool_name_to_description=server.tool_name_to_description,
extra_headers=server.extra_headers or [],
mcp_info=server.mcp_info,
static_headers=server.static_headers,
@ -4566,6 +4882,11 @@ class MCPServerManager:
authorization_url=server.authorization_url,
token_url=server.token_url,
registration_url=server.registration_url,
oauth2_flow=server.oauth2_flow,
token_exchange_endpoint=server.token_exchange_endpoint,
audience=server.audience,
subject_token_type=server.subject_token_type,
token_exchange_profile=server.token_exchange_profile,
allow_all_keys=server.allow_all_keys,
available_on_public_internet=server.available_on_public_internet,
delegate_auth_to_upstream=server.delegate_auth_to_upstream,

View file

@ -23,12 +23,13 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
AuthorizationCodeConfig,
CredError,
NoneConfig,
PassthroughConfig,
ServerSpec,
SharedKey,
Subject,
TokenExchangeConfig,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE, MCPAuth
if TYPE_CHECKING:
from litellm.proxy._types import UserAPIKeyAuth
@ -62,9 +63,10 @@ def to_server_spec(server: MCPServer) -> Optional[ServerSpec]:
an ``assert_never`` tail, so a newly added auth mode fails the type gate here until it is
explicitly mapped or explicitly deferred, rather than silently falling through to v1. Live
modes: ``none``, the static-header family (``api_key`` plus the Authorization schemes,
all shared-key), ``oauth2`` per-user tokens (``authorization_code``), and
``oauth2_token_exchange`` (OBO); client_credentials (M2M), delegated/passthrough
oauth2, and SigV4 return None and stay on v1.
all shared-key), ``oauth2`` per-user tokens (``authorization_code``), ``oauth2_token_exchange``
(OBO), and the client-forwarded token modes ``true_passthrough`` / ``oauth_delegate``
(``PassthroughConfig``); client_credentials (M2M), delegated/passthrough oauth2, and SigV4
return None and stay on v1.
"""
if server.is_byok:
return None # per-user BYOK source not migrated yet -> defer to v1 (any auth_type)
@ -94,6 +96,8 @@ def to_server_spec(server: MCPServer) -> Optional[ServerSpec]:
)
# client_credentials (M2M) and delegate/passthrough oauth2 stay on v1
return None
case MCPAuth.true_passthrough | MCPAuth.oauth_delegate:
return ServerSpec(server_id=server.server_id, resource=resource, config=PassthroughConfig())
case MCPAuth.oauth2_token_exchange:
return _token_exchange_spec(server, resource)
case MCPAuth.aws_sigv4:
@ -124,7 +128,7 @@ def _token_exchange_spec(server: MCPServer, resource: str) -> Optional[ServerSpe
resource=resource,
config=TokenExchangeConfig(
profile=profile,
subject_token_type=server.subject_token_type or "urn:ietf:params:oauth:token-type:access_token",
subject_token_type=server.subject_token_type or DEFAULT_SUBJECT_TOKEN_TYPE,
token_exchange_endpoint=endpoint,
audience=server.audience,
client_id=server.client_id,

View file

@ -69,6 +69,17 @@ class OAuthTokenStore(Protocol):
async def fetch(self, user_id: str, server_id: str) -> OAuthToken | None: ...
class InvalidatableOAuthTokenStore(OAuthTokenStore, Protocol):
"""An ``OAuthTokenStore`` whose cached entry for a ``(user, server)`` pair can be dropped.
The write side calls ``invalidate`` after a (re)authorization or revocation changes the
credential row, so reads stop serving the replaced token immediately instead of until its
cache TTL. ``CachedOAuthTokenStore`` (the top of the per-user chain) satisfies this.
"""
async def invalidate(self, user_id: str, server_id: str) -> None: ...
class TokenRefresher(Protocol):
"""Mints a fresh token from an expired one and persists it, returning the new token.

View file

@ -24,8 +24,8 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.dual_cache_toke
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import (
CachedOAuthTokenStore,
InvalidatableOAuthTokenStore,
OAuthToken,
OAuthTokenStore,
RefreshCoordinator,
RefreshingTokenStore,
TokenCacheBackend,
@ -51,7 +51,7 @@ if TYPE_CHECKING:
_DEFAULT_TTL_SECONDS = 300.0
ServerLookup = Callable[[str], "MCPServer | None"]
StoreBuilder = Callable[[ServerLookup], tuple[OAuthTokenStore, bool]]
StoreBuilder = Callable[[ServerLookup], tuple[InvalidatableOAuthTokenStore, bool]]
async def _read_credential(user_id: str, server_id: str) -> dict[str, object] | None:
@ -185,7 +185,7 @@ class LazyPerUserOAuthTokenStore:
self._server_lookup = server_lookup
self._store_builder = store_builder
self._redis_available = redis_available
self._store: OAuthTokenStore | None = None
self._store: InvalidatableOAuthTokenStore | None = None
self._uses_redis = False
self._fetch_lock = asyncio.Condition()
self._local_fetches = 0
@ -203,7 +203,26 @@ class LazyPerUserOAuthTokenStore:
if not uses_redis:
await self._finish_local_fetch()
async def _store_for_fetch(self) -> tuple[OAuthTokenStore, bool]:
async def invalidate(self, user_id: str, server_id: str) -> None:
"""Drop the chain's cached entry for ``(user_id, server_id)`` after the credential row
changes (re-auth, revoke). Builds the chain if no fetch has run yet, so a shared (Redis)
cache entry written by another worker is dropped too; the in-process case is then a no-op
on an empty cache.
"""
if self._uses_redis:
store = self._store
if store is not None:
await store.invalidate(user_id, server_id)
return
store, uses_redis = await self._store_for_fetch()
try:
await store.invalidate(user_id, server_id)
finally:
if not uses_redis:
await self._finish_local_fetch()
async def _store_for_fetch(self) -> tuple[InvalidatableOAuthTokenStore, bool]:
async with self._fetch_lock:
while (
self._store is not None and not self._uses_redis and self._redis_available() and self._local_fetches > 0

View file

@ -7,10 +7,11 @@ no precedence cascade. It is wildcard-free with an `assert_never` tail, so addin
an arm fails the type gate (basedpyright `reportMatchNotExhaustive`); a bypassed gate fails loudly
at runtime instead of returning `None`.
`none` and `api_key` (shared-key source) are live, as is `authorization_code`, which reads the
user's token from the injected `OAuthTokenStore`, and `token_exchange`, which swaps the caller's
inbound token through the injected `TokenExchanger`. The remaining arms are `not_implemented` stubs
that each land in a follow-up PR with their seam. Pure v2: no imports from v1.
`none`, `api_key` (shared-key source), and `passthrough` (forwards the caller's own inbound token)
are live, as is `authorization_code`, which reads the user's token from the injected
`OAuthTokenStore`, and `token_exchange`, which swaps the caller's inbound token through the
injected `TokenExchanger`. The remaining arms are `not_implemented` stubs that each land in a
follow-up PR with their seam. Pure v2: no imports from v1.
"""
from __future__ import annotations
@ -97,7 +98,7 @@ class UpstreamCredentialProvider:
case ApiKeyConfig() as config:
return self._api_key(config)
case PassthroughConfig():
return _not_implemented(AuthSpecKind.passthrough)
return self._passthrough(subject)
case ClientCredentialsConfig():
return _not_implemented(AuthSpecKind.client_credentials)
case TokenExchangeConfig() as config:
@ -118,6 +119,18 @@ class UpstreamCredentialProvider:
"""
return await self._authz_token(subject, server) is not None
def _passthrough(self, subject: Subject) -> Result[httpx.Auth, CredError]:
"""Forward the caller's own upstream credential verbatim; the gateway mints nothing.
The inbound token is the caller's already-disambiguated ``Authorization`` (never the LiteLLM
admission credential; the edge adapter drops that before building the ``Subject``). When it is
absent the request is sent unauthenticated so the upstream's own 401 surfaces, rather than the
gateway challenging on the upstream's behalf.
"""
if subject.inbound_token is None:
return Ok(NoOpAuth())
return Ok(StaticHeaderAuth(subject.inbound_token.get_secret_value(), header_name="Authorization"))
def _api_key(self, config: ApiKeyConfig) -> Result[httpx.Auth, CredError]:
match config.key_source:
case SharedKey() as source:

View file

@ -39,6 +39,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.result import (
Ok,
Result,
)
from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE
class AuthSpecKind(str, Enum):
@ -215,7 +216,7 @@ class TokenExchangeConfig(BaseModel):
model_config = ConfigDict(frozen=True)
kind: Literal[AuthSpecKind.token_exchange] = AuthSpecKind.token_exchange
profile: Literal["rfc8693", "entra_obo"] = "rfc8693"
subject_token_type: str = "urn:ietf:params:oauth:token-type:access_token"
subject_token_type: str = DEFAULT_SUBJECT_TOKEN_TYPE
token_exchange_endpoint: str | None = None
audience: str | None = None
client_id: str | None = None

View file

@ -8,6 +8,7 @@ from typing import (
Dict,
List,
Literal,
Mapping,
Optional,
Set,
Tuple,
@ -77,7 +78,8 @@ if MCP_AVAILABLE:
ListMCPToolsRestAPIResponseObject,
MCPInfo,
MCPServer,
_fire_mcp_success_logging,
_apply_toolset_scope,
_fire_mcp_tool_call_logging,
_tool_name_matches,
execute_mcp_tool,
filter_tools_by_allowed_tools,
@ -85,23 +87,32 @@ if MCP_AVAILABLE:
########################################################
############ MCP Server REST API Routes #################
async def _safe_fire_mcp_success_logging(
async def _safe_fire_mcp_tool_call_logging(
logging_obj: Optional[Any],
result: Any,
start_time: datetime,
end_time: datetime,
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
request_data: Optional[Mapping[str, object]] = None,
) -> None:
if logging_obj is None:
return
logging_results = await asyncio.gather(
_fire_mcp_success_logging(logging_obj, result, start_time, end_time),
_fire_mcp_tool_call_logging(
logging_obj,
result,
start_time,
end_time,
user_api_key_auth=user_api_key_auth,
request_data=request_data,
),
return_exceptions=True,
)
logging_error = logging_results[0]
if isinstance(logging_error, asyncio.CancelledError):
raise logging_error
if isinstance(logging_error, BaseException):
verbose_logger.warning("MCP tool success logging failed (continuing): %s", logging_error)
verbose_logger.warning("MCP tool call logging failed (continuing): %s", logging_error)
def _get_server_auth_header(
server,
@ -541,10 +552,37 @@ if MCP_AVAILABLE:
"message": "Successfully retrieved tools",
}
def _as_query_str(value: Any) -> Optional[str]:
"""Coerce an Optional[str] Query param to str|None, dropping unresolved FastAPI defaults."""
return value if isinstance(value, str) else None
async def _resolve_toolset_scope(
toolset_name: Optional[str],
user_api_key_dict: UserAPIKeyAuth,
) -> UserAPIKeyAuth:
"""Resolve ``toolset_name`` to its scoped ``UserAPIKeyAuth``, or return unchanged."""
if not toolset_name:
return user_api_key_dict
from litellm.proxy.utils import get_prisma_client_or_throw
prisma_client = get_prisma_client_or_throw("Database not available. Connect a database to your proxy")
toolset = await global_mcp_server_manager.get_toolset_by_name_cached(prisma_client, toolset_name)
if toolset is None:
raise HTTPException(
status_code=404,
detail=f"Toolset '{toolset_name}' not found",
)
return await _apply_toolset_scope(user_api_key_dict, toolset.toolset_id)
@router.get("/tools/list", dependencies=[Depends(user_api_key_auth)])
async def list_tool_rest_api(
request: Request,
server_id: Optional[str] = Query(None, description="The server id to list tools for"),
mcp_server_name: Optional[str] = Query(
None, description="Filter tools to a single MCP server by name or alias"
),
toolset_name: Optional[str] = Query(None, description="Filter tools to a single toolset by name"),
include_disabled_tools: bool = Query(
False,
description=(
@ -582,16 +620,29 @@ if MCP_AVAILABLE:
)
try:
mcp_server_name = _as_query_str(mcp_server_name)
toolset_name = _as_query_str(toolset_name)
# The full catalog (allowlist filter skipped) is admin-only so the
# REST endpoint can't be used to enumerate deliberately-disabled tools.
apply_tool_filters = not (
include_disabled_tools and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
)
if apply_tool_filters and getattr(
getattr(user_api_key_dict, "object_permission", None),
"mcp_tool_search_enabled",
False,
user_api_key_dict = await _resolve_toolset_scope(toolset_name, user_api_key_dict)
if server_id is None:
server_id = mcp_server_name
if (
apply_tool_filters
and server_id is None
and toolset_name is None
and getattr(
getattr(user_api_key_dict, "object_permission", None),
"mcp_tool_search_enabled",
False,
)
):
from litellm.proxy._experimental.mcp_server.tool_search import (
get_virtual_tool_definitions,
@ -719,6 +770,8 @@ if MCP_AVAILABLE:
request_path=request.scope.get("_original_path") or request.url.path,
)
except HTTPException as http_exc:
if http_exc.status_code == status.HTTP_404_NOT_FOUND:
raise
# Internal access/IP 403s keep the legacy error-dict response shape
# so the existing contract stays intact.
verbose_logger.exception("HTTPException in list_tool_rest_api: %s", str(http_exc))
@ -829,7 +882,14 @@ if MCP_AVAILABLE:
raw_headers=virtual_raw_headers,
litellm_logging_obj=virtual_logging_obj,
)
await _safe_fire_mcp_success_logging(virtual_logging_obj, result, _tool_start_time, datetime.now())
await _safe_fire_mcp_tool_call_logging(
virtual_logging_obj,
result,
_tool_start_time,
datetime.now(),
user_api_key_auth=user_api_key_dict,
request_data=data,
)
return result
# Validate required parameters early
@ -912,7 +972,14 @@ if MCP_AVAILABLE:
litellm_logging_obj=data.get("litellm_logging_obj"),
requested_server_id=canonical_server_id,
)
await _safe_fire_mcp_success_logging(logging_obj, result, _tool_start_time, datetime.now())
await _safe_fire_mcp_tool_call_logging(
logging_obj,
result,
_tool_start_time,
datetime.now(),
user_api_key_auth=user_api_key_dict,
request_data=data,
)
return result
except MCPMissingUserEnvVarsError as e:
verbose_logger.info(

View file

@ -20,6 +20,7 @@ from typing import (
Callable,
Dict,
List,
Mapping,
Optional,
Set,
Tuple,
@ -47,7 +48,10 @@ from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
get_request_base_url,
)
from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthError
from litellm.proxy._experimental.mcp_server.exceptions import (
MCPToolResultError,
MCPUpstreamAuthError,
)
from litellm.proxy._experimental.mcp_server.mcp_context import (
_mcp_active_toolset_id,
_mcp_gateway_initialize_instructions,
@ -60,6 +64,7 @@ from litellm.proxy._experimental.mcp_server.utils import (
LITELLM_MCP_SERVER_VERSION,
MCPMissingUserEnvVarsError,
add_server_prefix_to_name,
extract_mcp_tool_result_error_message,
get_server_prefix,
iter_known_server_prefixes,
)
@ -331,6 +336,8 @@ if MCP_AVAILABLE:
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
MCPServerManager,
_caller_authorization_fans_out,
_client_forwarded_authorization_headers,
_should_strip_caller_authorization,
_without_authorization,
global_mcp_server_manager,
@ -716,7 +723,7 @@ if MCP_AVAILABLE:
if not (host_ctx and hasattr(host_ctx, "meta") and host_ctx.meta):
return None
host_token = getattr(host_ctx.meta, "progressToken", None)
if not (host_token and hasattr(host_ctx, "session") and host_ctx.session):
if host_token is None or not (hasattr(host_ctx, "session") and host_ctx.session):
return None
host_session = host_ctx.session
@ -732,7 +739,7 @@ if MCP_AVAILABLE:
except Exception as e:
verbose_logger.error(f"Failed to forward progress to Host: {e}")
verbose_logger.debug(f"Host progressToken captured: {host_token[:8]}...")
verbose_logger.debug(f"Host progressToken captured: {str(host_token)[:8]}...")
return forward_progress
async def _build_virtual_call_logging_obj(
@ -1427,18 +1434,8 @@ if MCP_AVAILABLE:
for allowed_mcp_server_id in allowed_mcp_server_ids:
mcp_server = global_mcp_server_manager.get_mcp_server_by_id(allowed_mcp_server_id)
if mcp_server is not None:
# Apply oauth2_flow resolution for legacy DB rows where it may be NULL
resolved_flow = MCPServerManager._resolve_oauth2_flow(
auth_type=mcp_server.auth_type,
oauth2_flow=mcp_server.oauth2_flow,
token_url=mcp_server.token_url,
authorization_url=mcp_server.authorization_url,
client_id=mcp_server.client_id,
client_secret=mcp_server.client_secret,
)
if resolved_flow and resolved_flow != mcp_server.oauth2_flow:
# Create a new instance with the resolved flow for this request
mcp_server = mcp_server.model_copy(update={"oauth2_flow": resolved_flow})
# Apply the request-time oauth2_flow backstop for legacy null rows.
mcp_server = MCPServerManager.resolve_oauth2_flow_for_request(mcp_server)
allowed_mcp_servers.append(mcp_server)
if mcp_servers is not None:
@ -1449,6 +1446,35 @@ if MCP_AVAILABLE:
return allowed_mcp_servers
def _client_has_per_server_auth_header(
server: MCPServer,
mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]],
) -> bool:
"""True if the request carries a per-server ``x-mcp-{alias}-authorization``
header for this server. This is the multi-server binding: it names one
upstream, so it is unambiguously the caller's upstream token regardless of
auth mode (never the LiteLLM admission credential).
Resolves through the same ``lookup_mcp_server_auth_in_headers`` egress uses, so
the connect gate and egress agree on which per-server header names match: a
dashboard client sends ``x-mcp-{sanitize_mcp_alias_for_header(alias)}-authorization``,
and matching only the raw alias here would 401 a token egress would forward.
"""
if not mcp_server_auth_headers:
return False
from litellm.proxy._experimental.mcp_server.utils import (
lookup_mcp_server_auth_in_headers,
)
server_headers = lookup_mcp_server_auth_in_headers(
mcp_server_auth_headers, alias=server.alias, server_name=server.server_name
)
if isinstance(server_headers, str):
return bool(server_headers.strip())
if isinstance(server_headers, dict):
return any(isinstance(hk, str) and hk.lower() == "authorization" for hk in server_headers)
return False
def _client_has_passthrough_authorization(
server: MCPServer,
oauth2_headers: Optional[Dict[str, str]],
@ -1466,24 +1492,7 @@ if MCP_AVAILABLE:
for k in oauth2_headers.keys():
if k.lower() == "authorization":
return True
if mcp_server_auth_headers:
for key in (server.alias, server.server_name, server.name):
if not key:
continue
server_headers = None
for k, v in mcp_server_auth_headers.items():
if k.lower() == key.lower():
server_headers = v
break
if server_headers is None:
continue
if isinstance(server_headers, str) and server_headers.strip():
return True
if isinstance(server_headers, dict):
for hk in server_headers.keys():
if hk.lower() == "authorization":
return True
return False
return _client_has_per_server_auth_header(server, mcp_server_auth_headers)
async def _get_user_oauth_extra_headers_from_db(
server: MCPServer,
@ -1538,8 +1547,16 @@ if MCP_AVAILABLE:
oauth2_headers: Optional[Dict[str, str]],
raw_headers: Optional[Dict[str, str]],
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
scope_servers: Optional[list[MCPServer]] = None,
) -> Tuple[Optional[Union[Dict[str, str], str]], Optional[Dict[str, str]]]:
"""Build auth and extra headers for a server."""
"""Build auth and extra headers for a server.
``scope_servers`` is the full server list a fan-out handler iterates. Passing it lets the
client-forwarded token modes withhold the caller's request-wide ``Authorization`` when
another server in the scope would also receive it (``_caller_authorization_fans_out``);
explicitly-addressed operations leave it None. Per-server ``x-mcp-{alias}-authorization``
headers are unaffected — they bind one token to one server and are the multi-server shape.
"""
server_auth_header: Optional[Union[Dict[str, str], str]] = None
if mcp_server_auth_headers:
from litellm.proxy._experimental.mcp_server.utils import (
@ -1553,6 +1570,16 @@ if MCP_AVAILABLE:
)
extra_headers: Optional[Dict[str, str]] = None
is_client_forwarded_mode = server.is_true_passthrough or server.is_oauth_delegate
# In a multi-server listing scope the request-wide Authorization can only carry one token,
# so it is withheld from a client-forwarded server when another server in scope also consumes
# it (RFC 9700 cross-resource replay); such scopes must bind per-server via
# x-mcp-{alias}-authorization. The decision is computed once so BOTH the forwarding branch and
# the extra_headers copy loop below honor it — otherwise a server that lists Authorization in
# extra_headers would re-copy the withheld bearer from raw_headers and replay it anyway.
withhold_forwarded_authorization = is_client_forwarded_mode and _caller_authorization_fans_out(
server, scope_servers
)
if server.auth_type == MCPAuth.oauth2:
# For OAuth2 M2M servers, upstream Authorization must come from
# client_credentials token fetch, never from caller headers.
@ -1571,6 +1598,14 @@ if MCP_AVAILABLE:
user_api_key_auth=user_api_key_auth,
):
extra_headers = _without_authorization(extra_headers)
elif is_client_forwarded_mode:
if not withhold_forwarded_authorization:
extra_headers = _client_forwarded_authorization_headers(
mcp_server=server,
oauth2_headers=oauth2_headers,
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
)
if server.extra_headers and raw_headers:
if extra_headers is None:
@ -1591,7 +1626,9 @@ if MCP_AVAILABLE:
for header in server.extra_headers:
if not isinstance(header, str):
continue
if header.lower() == "authorization" and strip_caller_authorization:
if header.lower() == "authorization" and (
strip_caller_authorization or withhold_forwarded_authorization
):
continue
header_value = normalized_raw_headers.get(header.lower())
if header_value is None:
@ -1795,6 +1832,7 @@ if MCP_AVAILABLE:
oauth2_headers=oauth2_headers,
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
scope_servers=allowed_mcp_servers,
)
# Prefer server-stored per-user OAuth when configured, so a stale
@ -1981,6 +2019,7 @@ if MCP_AVAILABLE:
oauth2_headers=oauth2_headers,
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
scope_servers=allowed_mcp_servers,
)
try:
@ -2033,6 +2072,7 @@ if MCP_AVAILABLE:
oauth2_headers=oauth2_headers,
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
scope_servers=allowed_mcp_servers,
)
try:
@ -2083,6 +2123,7 @@ if MCP_AVAILABLE:
oauth2_headers=oauth2_headers,
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
scope_servers=allowed_mcp_servers,
)
try:
@ -2753,12 +2794,40 @@ if MCP_AVAILABLE:
return response
async def _fire_mcp_success_logging(
_MCP_CREDENTIAL_REQUEST_FIELDS = frozenset(
{
"raw_headers",
"mcp_auth_header",
"mcp_server_auth_headers",
"oauth2_headers",
"user_api_key_auth",
}
)
async def _fire_mcp_tool_call_logging(
logging_obj: LiteLLMLoggingObj,
result: Any,
start_time: datetime,
end_time: datetime,
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
request_data: Optional[Mapping[str, object]] = None,
) -> None:
"""Fire post-call logging for an executed MCP tool call.
A result with ``isError=True`` is logged as a failure (``status="failure"``
payload, so OTel marks the span ERROR) while the HTTP wire behavior stays
200 + ``isError: true`` per the MCP spec. The error check runs after
``async_post_mcp_tool_call_hook`` because guardrails may flip the result
to ``isError=True`` in that hook. Raised exceptions never reach here (the
``@client`` wrapper and ``call_mcp_tool``'s except path log those), so
this cannot double-log a failure.
``request_data`` may carry credential-bearing fields (the REST path puts
``raw_headers``, ``mcp_auth_header``, ``mcp_server_auth_headers``, and
``oauth2_headers`` at the top level of its data dict), so those are
stripped before the dict is handed to ``post_call_failure_hook``
callbacks.
"""
logging_obj.post_call(original_response=result)
await logging_obj.async_post_mcp_tool_call_hook(
kwargs=logging_obj.model_call_details,
@ -2767,7 +2836,31 @@ if MCP_AVAILABLE:
end_time=end_time,
)
logging_obj.call_type = CallTypes.call_mcp_tool.value
await logging_obj.async_success_handler(result=result, start_time=start_time, end_time=end_time)
error_message = extract_mcp_tool_result_error_message(result)
if error_message is None:
await logging_obj.async_success_handler(result=result, start_time=start_time, end_time=end_time)
return
logging_obj.has_run_logging(event_type="sync_success")
logging_obj.has_run_logging(event_type="async_success")
tool_error = MCPToolResultError(error_message)
logging_obj.failure_handler(tool_error, "", start_time, end_time)
await logging_obj.async_failure_handler(tool_error, "", start_time, end_time)
if user_api_key_auth is None:
return
from litellm.proxy.proxy_server import proxy_logging_obj
if proxy_logging_obj:
sanitized_request_data = {
key: value for key, value in (request_data or {}).items() if key not in _MCP_CREDENTIAL_REQUEST_FIELDS
}
await proxy_logging_obj.post_call_failure_hook(
request_data=sanitized_request_data,
original_exception=tool_error,
user_api_key_dict=user_api_key_auth,
route="/mcp/call_tool",
)
@client
async def call_mcp_tool(
@ -2800,6 +2893,9 @@ if MCP_AVAILABLE:
for allowed_mcp_server_id in allowed_mcp_server_ids:
allowed_server = global_mcp_server_manager.get_mcp_server_by_id(allowed_mcp_server_id)
if allowed_server is not None:
# Same request-time oauth2_flow backstop the listing path applies,
# so a null-flow M2M-shape row is treated as M2M on tool calls too.
allowed_server = MCPServerManager.resolve_oauth2_flow_for_request(allowed_server)
allowed_mcp_servers.append(allowed_server)
allowed_mcp_servers = await _get_allowed_mcp_servers_from_mcp_server_names(
@ -2840,7 +2936,14 @@ if MCP_AVAILABLE:
raise
if litellm_logging_obj:
await _fire_mcp_success_logging(litellm_logging_obj, response, start_time, datetime.now())
await _fire_mcp_tool_call_logging(
logging_obj=litellm_logging_obj,
result=response,
start_time=start_time,
end_time=datetime.now(),
user_api_key_auth=user_api_key_auth,
request_data=kwargs,
)
return response
async def mcp_get_prompt(
@ -3525,6 +3628,41 @@ if MCP_AVAILABLE:
headers={"www-authenticate": www_authenticate},
)
if (
server
and server.is_oauth_delegate
and len(mcp_servers or []) == 1
and _get_forwarded_auth_from_scope(scope) is None
and not _client_has_per_server_auth_header(server, mcp_server_auth_headers)
):
www_authenticate = _get_passthrough_www_authenticate(
scope=scope,
server_name=server_name,
)
raise HTTPException(
status_code=401,
detail="Unauthorized",
headers={"www-authenticate": www_authenticate},
)
if (
server
and server.is_true_passthrough
and len(mcp_servers or []) == 1
and not _scope_has_authorization_header(scope)
and not _client_has_per_server_auth_header(server, mcp_server_auth_headers)
):
upstream_status, upstream_www_authenticate = await _probe_upstream_auth(server.url or "", "")
if upstream_status == 401 and upstream_www_authenticate:
raise HTTPException(
status_code=401,
detail="Unauthorized",
headers={"www-authenticate": upstream_www_authenticate},
)
def _scope_has_authorization_header(scope: Scope) -> bool:
return any(key.lower() == b"authorization" for key, _ in scope.get("headers", []))
def _get_forwarded_auth_from_scope(scope: Scope) -> Optional[str]:
"""Return the upstream-bound ``Authorization`` header value, or None.
@ -3552,7 +3690,7 @@ if MCP_AVAILABLE:
url: str,
auth_header: str,
timeout: float = 5.0,
) -> tuple:
) -> tuple[int, Optional[str]]:
"""JSON-RPC initialize-probe the upstream URL to check whether the token is accepted.
Uses POST so StreamableHTTP MCP servers run the same auth path as a
@ -3582,8 +3720,8 @@ if MCP_AVAILABLE:
},
}
probe_headers = {
"Authorization": auth_header,
"Accept": "application/json, text/event-stream",
**({"Authorization": auth_header} if auth_header else {}),
}
try:
resp = await client.post(

View file

@ -214,12 +214,14 @@ def server_applies_tool_allowlist(mcp_server: Any) -> bool:
def validate_and_normalize_mcp_server_payload(payload: Any) -> None:
"""
Validate and normalize MCP server payload fields (server_name and alias).
Validate and normalize MCP server payload fields (server_name, alias, and
tool_name_to_display_name).
This function:
1. Validates that server_name and alias don't contain the MCP_TOOL_PREFIX_SEPARATOR
2. Normalizes alias by replacing spaces with underscores
3. Sets default alias if not provided (using server_name as base)
2. Validates that tool_name_to_display_name values satisfy Bedrock's tool-name pattern
3. Normalizes alias by replacing spaces with underscores
4. Sets default alias if not provided (using server_name as base)
Args:
payload: The payload object containing server_name and alias fields
@ -235,6 +237,10 @@ def validate_and_normalize_mcp_server_payload(payload: Any) -> None:
if hasattr(payload, "alias") and payload.alias:
validate_mcp_server_name(payload.alias, raise_http_exception=True)
# Tool display name validation: must satisfy Bedrock's tool-name pattern
if hasattr(payload, "tool_name_to_display_name") and payload.tool_name_to_display_name:
validate_tool_display_names(payload.tool_name_to_display_name)
# Alias normalization and defaulting
alias = getattr(payload, "alias", None)
server_name = getattr(payload, "server_name", None)
@ -409,6 +415,61 @@ def validate_mcp_server_name(server_name: str, raise_http_exception: bool = Fals
raise Exception(error_message)
def extract_mcp_tool_result_error_message(result: object) -> Optional[str]:
"""The first text content of an ``isError=True`` tool result, or ``None``
when the result is not an error.
Accepts both ``mcp.types.CallToolResult`` objects and their dict
equivalents, duck-typed so the ``mcp`` package is not required.
"""
is_error: object = result.get("isError") if isinstance(result, Mapping) else getattr(result, "isError", None)
if is_error is not True:
return None
content: object = result.get("content") if isinstance(result, Mapping) else getattr(result, "content", None)
if isinstance(content, (list, tuple)):
for item in content:
text: object = item.get("text") if isinstance(item, Mapping) else getattr(item, "text", None)
if isinstance(text, str) and text:
return text
return "MCP tool call returned isError=true"
TOOL_DISPLAY_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
def validate_tool_display_names(tool_name_to_display_name: Optional[Mapping[str, str]]) -> None:
"""
Validate tool display name overrides against Bedrock's tool-name constraint.
A display name replaces the tool name sent to the LLM provider, so it must
satisfy the strictest provider requirement in use (Bedrock's
``[a-zA-Z0-9_-]+``); a name with spaces or other characters saves
successfully but fails every subsequent Bedrock tool call.
Raises:
HTTPException: If any display name fails the pattern.
"""
if not tool_name_to_display_name:
return
for original_name, display_name in tool_name_to_display_name.items():
if display_name and not TOOL_DISPLAY_NAME_PATTERN.match(display_name):
from fastapi import HTTPException
from starlette import status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": (
f"Invalid display name '{display_name}' for tool '{original_name}'. "
"Display names may only contain letters, digits, underscores, and "
"hyphens (no spaces or other special characters), since they replace "
"the tool name sent to the LLM provider."
)
},
)
class MCPMissingUserEnvVarsError(Exception):
"""Raised when an MCP request can't be built because the calling user has
not supplied one or more required per-user environment variables.

View file

@ -1045,6 +1045,7 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase):
model_rpm_limit: Optional[dict] = None
model_tpm_limit: Optional[dict] = None
mcp_rpm_limit: Optional[Dict[str, int]] = None
tag_rpm_limit: Optional[dict[str, int]] = None
guardrails: Optional[List[str]] = None
policies: Optional[List[str]] = None
prompts: Optional[List[str]] = None
@ -1070,6 +1071,7 @@ class KeyRequestBase(GenerateRequestBase):
budget_id: Optional[str] = None
tags: Optional[List[str]] = None
disable_global_guardrails: Optional[bool] = None
throttle_on_budget_exceeded: Optional[bool] = None
enforced_params: Optional[List[str]] = None
allowed_routes: Optional[list] = []
allowed_passthrough_routes: Optional[list] = None
@ -1254,6 +1256,14 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
token_url: Optional[str] = None
registration_url: Optional[str] = None
oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = None
# Token Exchange (OBO) fields — RFC 8693. These top-level fields are the
# canonical shape; the same keys inside ``credentials`` are the legacy
# pre-column REST shape and are lifted into these columns on write (an
# explicit top-level value wins) and stripped from the stored blob.
token_exchange_endpoint: Optional[str] = None
audience: Optional[str] = None
subject_token_type: Optional[str] = None
token_exchange_profile: Optional[str] = None
allow_all_keys: bool = False
available_on_public_internet: bool = True
delegate_auth_to_upstream: bool = False
@ -1340,6 +1350,14 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
token_url: Optional[str] = None
registration_url: Optional[str] = None
oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = None
# Token Exchange (OBO) fields — RFC 8693. These top-level fields are the
# canonical shape; the same keys inside ``credentials`` are the legacy
# pre-column REST shape and are lifted into these columns on write (an
# explicit top-level value wins) and stripped from the stored blob.
token_exchange_endpoint: Optional[str] = None
audience: Optional[str] = None
subject_token_type: Optional[str] = None
token_exchange_profile: Optional[str] = None
allow_all_keys: bool = False
available_on_public_internet: bool = True
delegate_auth_to_upstream: bool = False
@ -2322,6 +2340,28 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
"is active as a reminder that hard enforcement is relaxed."
),
)
user_url_validation: Optional[bool] = Field(
None,
description=(
"Master switch for the SSRF guard applied to user-supplied URLs "
"(image_url, file_url, MCP/OpenAPI spec URLs, etc). Defaults to True. "
"Set to False to disable DNS/IP validation entirely (not recommended)."
),
)
user_url_allowed_hosts: Optional[list[str]] = Field(
None,
description=(
"SSRF allowlist for user-supplied URLs. Entries are `hostname` or "
"`hostname:port` (bracketed for IPv6, e.g. `[::1]:8080`). Allowlisted "
"hosts skip the blocked-network check in validate_url() but still "
"resolve DNS. Use this to permit legitimate internal targets, e.g. "
"an internal OpenAPI/MCP server."
),
)
provider_url_destination_allowed_hosts: Optional[list[str]] = Field(
None,
description="Allowlist of hosts a request may redirect a provider call's destination URL to.",
)
class ConfigYAML(LiteLLMPydanticObjectBase):
@ -2447,6 +2487,7 @@ class UserAPIKeyAuth(LiteLLM_VerificationTokenView): # the expected response ob
request_route: Optional[str] = None
is_session_token: bool = False
budget_reservation: Optional[Dict[str, Any]] = Field(default=None, exclude=True)
budget_throttle_pct: Optional[float] = Field(default=None, exclude=True)
user: Optional[Any] = None # Expanded user object when expand=user is used
created_by_user: Optional[Any] = None # Expanded created_by user when expand=user is used
end_user_object_permission: Optional[LiteLLM_ObjectPermissionTable] = None
@ -3829,6 +3870,7 @@ LiteLLM_ManagementEndpoint_MetadataFields = [
"model_rpm_limit",
"model_tpm_limit",
"mcp_rpm_limit",
"tag_rpm_limit",
"rpm_limit_type",
"tpm_limit_type",
"enforced_params",
@ -3837,6 +3879,7 @@ LiteLLM_ManagementEndpoint_MetadataFields = [
"allowed_vector_store_indexes",
"enforced_batch_output_expires_after",
"enforced_file_expires_after",
"throttle_on_budget_exceeded",
]
LiteLLM_ManagementEndpoint_MetadataFields_Premium = [

View file

@ -60,6 +60,10 @@ from litellm.proxy._types import (
UserAPIKeyAuth,
)
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.auth.budget_throttle import (
budget_throttle_percentage,
should_throttle_budget_exceeded,
)
from litellm.proxy.spend_tracking.budget_reservation import get_budget_window_start
from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec
from litellm.proxy.common_utils.http_parsing_utils import (
@ -1470,7 +1474,7 @@ def _should_check_db(key: str, last_db_access_time: LimitedSizeOrderedDict, db_c
elif last_db_access_time[key][0] is not None: # check db for non-null values (for refresh operations)
return True
elif last_db_access_time[key][0] is None:
if current_time - last_db_access_time[key] >= db_cache_expiry:
if current_time - last_db_access_time[key][1] >= db_cache_expiry:
return True
return False
@ -1645,6 +1649,12 @@ async def get_user_object(
include={"organization_memberships": True},
)
else:
if should_check_db:
_update_last_db_access_time(
key=db_access_time_key,
value=None,
last_db_access_time=last_db_access_time,
)
raise Exception
if response.organization_memberships is not None and len(response.organization_memberships) > 0:
@ -2496,6 +2506,7 @@ def _copy_user_api_key_auth_for_cache(
) -> UserAPIKeyAuth:
copied_key_obj = user_api_key_obj.model_copy()
copied_key_obj.budget_reservation = None
copied_key_obj.budget_throttle_pct = None
copied_key_obj.parent_otel_span = None
copied_key_obj.request_route = None
return copied_key_obj
@ -3428,6 +3439,24 @@ async def is_valid_fallback_model(
return True
def _apply_budget_exceeded_throttle(valid_token: UserAPIKeyAuth) -> bool:
"""
Throttle an over-budget key instead of blocking it, when the key opted in
via `throttle_on_budget_exceeded` and a global percentage is configured.
Records the percentage on the request-scoped `budget_throttle_pct` so the
rate limiter scales the key's TPM/RPM down to it; the persistent limits are
left untouched so the throttle never compounds across requests. Returns True
when the key was throttled (caller skips raising), False when it should still
be hard-blocked.
"""
pct = budget_throttle_percentage()
if pct is None or not should_throttle_budget_exceeded(valid_token):
return False
valid_token.budget_throttle_pct = pct
return True
async def _virtual_key_max_budget_check(
valid_token: UserAPIKeyAuth,
proxy_logging_obj: ProxyLogging,
@ -3488,6 +3517,8 @@ async def _virtual_key_max_budget_check(
# so a NaN max_budget would silently disable enforcement. Treat a
# non-finite max_budget as "no configured limit" rather than as a bypass.
if math.isfinite(valid_token.max_budget) and spend >= valid_token.max_budget:
if _apply_budget_exceeded_throttle(valid_token):
return
# name the key in the error so operators don't have to reverse-map
# spend back to a key; key_name is the masked form (last 4 chars)
key_label = valid_token.key_alias or "key"

View file

@ -975,6 +975,20 @@ def get_team_mcp_rpm_limit(
return None
def get_key_tag_rpm_limit(
user_api_key_dict: UserAPIKeyAuth,
) -> Optional[dict[str, int]]:
"""
Get the per-request-tag rpm limit configured on a given api key.
The returned dict is keyed by request tag, so each tag/group tracked on
the key gets its own independent RPM counter.
"""
if user_api_key_dict.metadata:
return user_api_key_dict.metadata.get("tag_rpm_limit")
return None
def get_project_model_rpm_limit(
user_api_key_dict: UserAPIKeyAuth,
) -> Optional[Dict[str, int]]:

View file

@ -0,0 +1,56 @@
"""
Throttle a key after it exceeds its own ``max_budget`` instead of blocking it.
When a key opts in via ``throttle_on_budget_exceeded`` and a global
``budget_exceeded_throttle_percentage`` is configured, an over-budget key keeps
serving requests but at a reduced TPM/RPM (the configured percentage of its
configured limits). The decision (over budget + opted in) is made once during
auth; the scaling is recomputed from the key's original limits on every request
so it never compounds across requests.
"""
import math
from typing import Optional
import litellm
from litellm.proxy._types import UserAPIKeyAuth
def budget_throttle_percentage() -> Optional[float]:
"""
The global throttle percentage, or None when throttling is disabled /
misconfigured (in which case an over-budget key is hard-blocked, the safe
default).
"""
pct = litellm.budget_exceeded_throttle_percentage
if not isinstance(pct, (int, float)) or isinstance(pct, bool):
return None
if not 0 < pct <= 1:
return None
return float(pct)
def should_throttle_budget_exceeded(valid_token: UserAPIKeyAuth) -> bool:
"""
True when a key that exceeded its own ``max_budget`` should be throttled
rather than blocked: it opted in, a valid global percentage is set, and the
key has a TPM or RPM limit to scale down. A key with neither limit has
nothing to throttle, so it stays hard-blocked (the safe default) rather than
serving unlimited requests past its budget.
"""
if (valid_token.metadata or {}).get("throttle_on_budget_exceeded") is not True:
return False
if valid_token.tpm_limit is None and valid_token.rpm_limit is None:
return False
return budget_throttle_percentage() is not None
def throttled_limit(limit: Optional[int], pct: Optional[float]) -> Optional[int]:
"""
Scale a TPM/RPM limit to ``pct`` of its value, keeping a trickle of at least
1 so a throttled key is slowed rather than fully locked out. An unset limit
or unset percentage leaves the limit unchanged.
"""
if limit is None or pct is None:
return limit
return max(1, math.floor(limit * pct))

View file

@ -2004,6 +2004,11 @@ async def _user_api_key_auth_builder(
valid_token_dict = valid_token.model_dump(exclude_none=True)
valid_token_dict.pop("token", None)
# budget_throttle_pct is excluded from model_dump (it must not leak
# into serialized responses), so carry the request-scoped decision
# forward by hand to the auth object the rate limiter receives.
if valid_token.budget_throttle_pct is not None:
valid_token_dict["budget_throttle_pct"] = valid_token.budget_throttle_pct
if _end_user_object is not None:
valid_token_dict.update(end_user_params)

View file

@ -137,8 +137,17 @@ class PrismaWrapper:
self.on_engine_replaced: Callable[[], None] | None = None
def _get_engine_pid(self) -> int:
"""Get the PID of the current Prisma engine subprocess, or 0 if unavailable."""
"""Get the PID of the current Prisma engine subprocess, or 0 if unavailable.
Must never raise: it runs inside the reconnect path, where the client
may be in any broken state. Prisma's ``_engine`` is a property that
raises ``ClientNotConnectedError`` on a disconnected client; if that
escaped here, ``recreate_prisma_client`` would fail before it could
build a replacement client and the reconnect loop could never recover.
"""
try:
if self._original_prisma.is_connected() is not True:
return 0
engine = self._original_prisma._engine
process = getattr(engine, "process", None) if engine is not None else None
if process is not None:

View file

@ -32,6 +32,9 @@ def is_text_content_call_type(call_type: str) -> bool:
return call_type in TEXT_CONTENT_CALL_TYPES
TEXT_PART_TYPES: FrozenSet[str] = frozenset({"text", "input_text", "output_text"})
def _iter_text_parts_in_content(content: Any) -> Iterator[str]:
"""Yield text fragments from a ``message.content`` value (string or
multimodal list). Non-text parts (images, audio, …) are skipped."""
@ -48,7 +51,7 @@ def _iter_text_parts_in_content(content: Any) -> Iterator[str]:
continue
if not isinstance(part, dict):
continue
if part.get("type") == "text":
if part.get("type") in TEXT_PART_TYPES:
text = part.get("text")
if isinstance(text, str) and text:
yield text
@ -58,14 +61,20 @@ def _coerce_input_to_messages(input_value: Any) -> List[Dict[str, Any]]:
"""Coerce a Responses-API ``data["input"]`` value into chat-style messages."""
if isinstance(input_value, str):
return [{"role": "user", "content": input_value}]
if isinstance(input_value, list):
if input_value and all(isinstance(item, dict) and "role" in item for item in input_value):
return list(input_value)
# Mixed lists (content-part dicts + bare strings) and pure
# string/dict lists all become a single user message; the content
# iterator below handles each element type uniformly.
return [{"role": "user", "content": input_value}]
return []
if not isinstance(input_value, list):
return []
messages: List[Dict[str, Any]] = []
for item in input_value:
if isinstance(item, str):
messages.append({"role": "user", "content": item})
elif isinstance(item, dict):
if item.get("type") in TEXT_PART_TYPES:
messages.append({"role": item.get("role") or "user", "content": [item]})
elif "content" in item:
messages.append({"role": item.get("role") or "user", "content": item["content"]})
elif item.get("type") == "function_call_output" and "output" in item:
messages.append({"role": item.get("role") or "tool", "content": item["output"]})
return messages
def _iter_inspection_messages(data: Dict[str, Any]) -> Iterator[Dict[str, Any]]:
@ -112,7 +121,7 @@ def walk_user_text(data: Dict[str, Any], visit: Callable[[str], str]) -> int:
new_parts.append(visit(part))
elif (
isinstance(part, dict)
and part.get("type") == "text"
and part.get("type") in TEXT_PART_TYPES
and isinstance(part.get("text"), str)
and part["text"]
):
@ -136,25 +145,20 @@ def walk_user_text(data: Dict[str, Any], visit: Callable[[str], str]) -> int:
data["input"] = visit(input_value)
return visited
if isinstance(input_value, list):
# List of full messages: rewrite each message's content.
if input_value and all(isinstance(item, dict) and "role" in item for item in input_value):
for item in input_value:
if "content" in item:
item["content"] = _rewrite_content(item["content"])
return visited
# List of content parts and/or bare strings: rewrite in place.
for idx, item in enumerate(input_value):
if isinstance(item, str) and item:
visited += 1
input_value[idx] = visit(item)
elif (
isinstance(item, dict)
and item.get("type") == "text"
and isinstance(item.get("text"), str)
and item["text"]
):
visited += 1
input_value[idx] = {**item, "text": visit(item["text"])}
if isinstance(item, str):
if item:
visited += 1
input_value[idx] = visit(item)
elif isinstance(item, dict):
if item.get("type") in TEXT_PART_TYPES:
if isinstance(item.get("text"), str) and item["text"]:
visited += 1
input_value[idx] = {**item, "text": visit(item["text"])}
elif "content" in item:
item["content"] = _rewrite_content(item["content"])
elif item.get("type") == "function_call_output" and "output" in item:
item["output"] = _rewrite_content(item["output"])
return visited
return visited

View file

@ -93,11 +93,10 @@ class AimGuardrail(CustomGuardrail):
user_email=user_email,
litellm_call_id=call_id,
)
# Covers multimodal list content + Responses-API input.
response = await self.async_handler.post(
f"{self.api_base}/fw/v1/analyze",
headers=headers,
json={"messages": build_inspection_messages(data)},
json={"messages": self._build_aim_inspection_messages(data)},
)
response.raise_for_status()
res = response.json()
@ -116,6 +115,15 @@ class AimGuardrail(CustomGuardrail):
verbose_proxy_logger.error(f"Aim: {action_type} action")
return data
@staticmethod
def _build_aim_inspection_messages(data: dict) -> list[dict[str, str]]:
"""AIM validates against the OpenAI chat schema. Bare ``role: "tool"``
without ``tool_call_id`` and bare ``role: "function"`` without ``name``
are rejected; the flatten drops those fields, so any role outside
``{system, user, assistant}`` collapses to ``user`` for the AIM POST."""
safe_roles = {"system", "user", "assistant"}
return [{**m, "role": "user"} if m["role"] not in safe_roles else m for m in build_inspection_messages(data)]
@staticmethod
def _rejection(message: str, *, openai_code: str | None = None) -> ProxyException:
return ProxyException(
@ -177,7 +185,10 @@ class AimGuardrail(CustomGuardrail):
user_email=user_email,
litellm_call_id=call_id,
),
json={"messages": build_inspection_messages(request_data) + [{"role": "assistant", "content": output}]},
json={
"messages": self._build_aim_inspection_messages(request_data)
+ [{"role": "assistant", "content": output}]
},
)
response.raise_for_status()
res = response.json()

View file

@ -213,7 +213,7 @@ class GraySwanGuardrail(CustomGuardrail):
verbose_proxy_logger.debug("Gray Swan Guardrail: dynamic extra_body=%s", safe_dumps(dynamic_body))
# Prepare and send payload
payload = self._prepare_payload(messages, dynamic_body, request_data)
payload = self._prepare_payload(messages, dynamic_body, request_data, logging_obj)
if payload is None:
return inputs
@ -502,10 +502,38 @@ class GraySwanGuardrail(CustomGuardrail):
"grayswan-api-key": self.api_key,
}
def _extract_inbound_headers(
self,
request_data: dict,
logging_obj: Optional["LiteLLMLoggingObj"] = None,
) -> Optional[dict[str, str]]:
headers = (request_data.get("proxy_server_request") or {}).get("headers")
if not headers:
headers = request_data.get("headers")
if not headers:
headers = (request_data.get("metadata") or {}).get("headers")
if not headers and logging_obj and getattr(logging_obj, "model_call_details", None):
headers = (
(logging_obj.model_call_details or {}).get("litellm_params", {}).get("metadata", {}).get("headers")
)
if not isinstance(headers, dict):
return None
forwarded_header_names = ("shade_scan_id",)
forwarded_headers = {}
for key, value in headers.items():
if str(key).lower() in forwarded_header_names:
forwarded_headers[str(key)] = str(value)
return forwarded_headers or None
def _prepare_payload(
self, messages: List[Dict[str, str]], dynamic_body: dict, request_data: dict
) -> Optional[Dict[str, Any]]:
payload: Dict[str, Any] = {"messages": messages}
self,
messages: list[dict[str, str]],
dynamic_body: dict,
request_data: dict,
logging_obj: Optional["LiteLLMLoggingObj"] = None,
) -> Optional[dict[str, Any]]:
payload: dict[str, Any] = {"messages": messages}
categories = dynamic_body.get("categories") or self.categories
if categories:
@ -523,10 +551,16 @@ class GraySwanGuardrail(CustomGuardrail):
if "metadata" in dynamic_body:
payload["metadata"] = dynamic_body["metadata"]
inbound_headers = self._extract_inbound_headers(request_data, logging_obj)
litellm_metadata = request_data.get("litellm_metadata")
if isinstance(litellm_metadata, dict) and litellm_metadata:
cleaned_litellm_metadata = dict(litellm_metadata)
# cleaned_litellm_metadata.pop("user_api_key_auth", None)
cleaned_litellm_metadata = dict(litellm_metadata) if isinstance(litellm_metadata, dict) else {}
if inbound_headers:
existing_headers = cleaned_litellm_metadata.get("headers")
cleaned_litellm_metadata["headers"] = (
{**existing_headers, **inbound_headers} if isinstance(existing_headers, dict) else inbound_headers
)
if cleaned_litellm_metadata:
sanitized = safe_json_loads(safe_dumps(cleaned_litellm_metadata), default={})
if isinstance(sanitized, dict) and sanitized:
payload["litellm_metadata"] = sanitized

View file

@ -129,6 +129,27 @@ class SemanticToolFilterHook(CustomLogger):
return openai_tools_as_dicts
async def _filter_expanded_tools(
self,
data: dict,
expanded_tools: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""
Apply the semantic filter to expanded MCP tool definitions.
Expanded tools are flat OpenAI function dicts with a top-level
"name" (see transform_mcp_tool_to_openai_responses_api_tool), so
filter_tools can name-match them against the semantic router.
"""
raw_messages = data.get("messages") or data.get("input") or []
messages = [{"role": "user", "content": raw_messages}] if isinstance(raw_messages, str) else raw_messages
user_query = self.filter.extract_user_query(messages)
if not user_query:
verbose_proxy_logger.debug("No user query found, skipping semantic filter on expanded MCP tools")
return expanded_tools
return await self.filter.filter_tools(query=user_query, available_tools=expanded_tools)
def _is_mcp_tool(self, tool: object) -> bool:
"""
Check whether *tool* is registered in the MCP semantic router.
@ -184,6 +205,32 @@ class SemanticToolFilterHook(CustomLogger):
f"Semantic tool filter: all {len(native_tools)} tools are native, no MCP filtering applied"
)
def _emit_filter_metadata_safe(
self,
data: dict,
mcp_tools: list[object],
filtered_mcp_tools: list[object],
native_tools: list[object],
filtered_tools: list[object],
) -> None:
"""
Emit filter metadata without letting an emission failure abort the
already-filtered request.
"""
try:
self._emit_filter_metadata(
data=data,
mcp_tools=mcp_tools,
filtered_mcp_tools=filtered_mcp_tools,
native_tools=native_tools,
filtered_tools=filtered_tools,
)
except Exception as e:
verbose_proxy_logger.warning(
f"Failed to emit semantic filter metadata: {e}",
exc_info=True,
)
async def async_pre_call_hook(
self,
user_api_key_dict: "UserAPIKeyAuth",
@ -206,9 +253,6 @@ class SemanticToolFilterHook(CustomLogger):
verbose_proxy_logger.debug("No tools in request, skipping semantic filter")
return None
# Expanded MCP tools are in OpenAI nested format which
# filter_tools/_extract_tool_info cannot name-match, so we skip
# semantic filtering and return early.
if self._should_expand_mcp_tools(tools):
verbose_proxy_logger.debug("Detected litellm_proxy MCP references, expanding before semantic filtering")
@ -227,11 +271,26 @@ class SemanticToolFilterHook(CustomLogger):
verbose_proxy_logger.warning("No tools expanded from MCP references")
return None
data["tools"] = native_tools_before_expand + expanded_tools
if not self.filter.enabled:
data["tools"] = native_tools_before_expand + expanded_tools
verbose_proxy_logger.debug("Semantic filter disabled, forwarding expanded MCP tools unfiltered")
return data
filtered_expanded_tools = await self._filter_expanded_tools(data=data, expanded_tools=expanded_tools)
combined_tools = native_tools_before_expand + filtered_expanded_tools
data["tools"] = combined_tools
self._emit_filter_metadata_safe(
data=data,
mcp_tools=expanded_tools,
filtered_mcp_tools=filtered_expanded_tools,
native_tools=native_tools_before_expand,
filtered_tools=combined_tools,
)
verbose_proxy_logger.info(
f"Expanded MCP references to {len(expanded_tools)} tools "
f"({len(native_tools_before_expand)} native preserved), "
f"skipping semantic filter (OpenAI nested format)"
f"semantic filter selected {len(filtered_expanded_tools)}"
)
return data
@ -297,19 +356,13 @@ class SemanticToolFilterHook(CustomLogger):
data["tools"] = filtered_tools
try:
self._emit_filter_metadata(
data=data,
mcp_tools=mcp_tools,
filtered_mcp_tools=filtered_mcp_tools,
native_tools=native_tools,
filtered_tools=filtered_tools,
)
except Exception as e:
verbose_proxy_logger.warning(
f"Failed to emit semantic filter metadata: {e}",
exc_info=True,
)
self._emit_filter_metadata_safe(
data=data,
mcp_tools=mcp_tools,
filtered_mcp_tools=filtered_mcp_tools,
native_tools=native_tools,
filtered_tools=filtered_tools,
)
return data

View file

@ -17,6 +17,7 @@ from litellm.proxy.auth.auth_utils import (
get_key_model_rpm_limit,
get_key_model_tpm_limit,
)
from litellm.proxy.auth.budget_throttle import throttled_limit
from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
from litellm.proxy.hooks.rate_limiter_utils import resolve_llm_provider_for_rate_limit
@ -248,10 +249,11 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger):
if data is None:
data = {}
global_max_parallel_requests = data.get("metadata", {}).get("global_max_parallel_requests", None)
tpm_limit = getattr(user_api_key_dict, "tpm_limit", sys.maxsize)
throttle_pct = getattr(user_api_key_dict, "budget_throttle_pct", None)
tpm_limit = throttled_limit(getattr(user_api_key_dict, "tpm_limit", sys.maxsize), throttle_pct)
if tpm_limit is None:
tpm_limit = sys.maxsize
rpm_limit = getattr(user_api_key_dict, "rpm_limit", sys.maxsize)
rpm_limit = throttled_limit(getattr(user_api_key_dict, "rpm_limit", sys.maxsize), throttle_pct)
if rpm_limit is None:
rpm_limit = sys.maxsize

View file

@ -31,7 +31,12 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_str_from_messages,
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.auth_utils import get_model_rate_limit_from_metadata
from litellm.proxy.auth.auth_utils import (
get_key_tag_rpm_limit,
get_model_rate_limit_from_metadata,
)
from litellm.proxy.auth.budget_throttle import throttled_limit
from litellm.proxy.common_utils.http_parsing_utils import get_tags_from_request_body
from litellm.proxy.common_utils.proxy_rate_limit_error import (
ProxyRateLimitError,
map_v3_rate_limit_type,
@ -1299,6 +1304,43 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
)
)
def _add_tag_per_key_rate_limit_descriptor(
self,
user_api_key_dict: UserAPIKeyAuth,
data: dict,
descriptors: list[RateLimitDescriptor],
) -> None:
"""
Add per-request-tag rpm limit descriptors for the API key.
Each tag carried on the request that has a configured limit gets its own
``{api_key}:{tag}`` counter, so a burst on one tag/group never consumes
another's budget. Tags without a configured limit fall through to the
key-level descriptor.
"""
if not user_api_key_dict.api_key:
return
tag_rpm_limit = get_key_tag_rpm_limit(user_api_key_dict) or {}
if not tag_rpm_limit:
return
for tag in dict.fromkeys(get_tags_from_request_body(data)):
rpm_limit = tag_rpm_limit.get(tag)
if rpm_limit is None:
continue
descriptors.append(
RateLimitDescriptor(
key="tag_per_key",
value=f"{user_api_key_dict.api_key}:{tag}",
rate_limit={
"requests_per_unit": rpm_limit,
"tokens_per_unit": None,
"window_size": self.window_size,
},
)
)
def _add_mcp_per_key_rate_limit_descriptor(
self,
user_api_key_dict: UserAPIKeyAuth,
@ -1549,18 +1591,19 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
or user_api_key_dict.tpm_limit is not None
or user_api_key_dict.max_parallel_requests is not None
):
throttle_pct = user_api_key_dict.budget_throttle_pct
descriptors.append(
RateLimitDescriptor(
key="api_key",
value=user_api_key_dict.api_key,
rate_limit={
"requests_per_unit": self._get_enforced_limit(
limit_value=user_api_key_dict.rpm_limit,
limit_value=throttled_limit(user_api_key_dict.rpm_limit, throttle_pct),
limit_type=rpm_limit_type,
model_has_failures=model_has_failures,
),
"tokens_per_unit": self._get_enforced_limit(
limit_value=user_api_key_dict.tpm_limit,
limit_value=throttled_limit(user_api_key_dict.tpm_limit, throttle_pct),
limit_type=tpm_limit_type,
model_has_failures=model_has_failures,
),
@ -1643,6 +1686,13 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
descriptors=descriptors,
)
# Per-request-tag rate limits scoped to this key
self._add_tag_per_key_rate_limit_descriptor(
user_api_key_dict=user_api_key_dict,
data=data,
descriptors=descriptors,
)
# REST MCP calls pass the raw body through this hook before server
# resolution; only the later synthetic hook payload may carry this key.
if call_type == CallTypes.call_mcp_tool.value and "server_id" not in data:
@ -1959,6 +2009,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
# Org Level Rate Limits
descriptors.extend(self.create_organization_rate_limit_descriptor(user_api_key_dict, requested_model))
# Only check rate limits if we have descriptors with actual limits
if descriptors:
# First pass: RPM and max_parallel_requests sliding-window check.

View file

@ -38,13 +38,35 @@ from litellm.types.management_endpoints import (
router = APIRouter()
# Cache fields holding credentials. Masked on read so plaintext Redis /
# Sentinel passwords never leave the server in a GET response.
_CACHE_SENSITIVE_FIELDS: set = {"password", "sentinel_password"}
# Sentinel passwords never leave the server in a GET response. `url` is here
# because a Redis/Valkey URL can embed a password inline
# (e.g. redis://:secret@host:6379/1).
_CACHE_SENSITIVE_FIELDS: set = {"password", "sentinel_password", "url"}
_REDACTED_VALUE = "***REDACTED***"
_URL_OVERRIDDEN_CONNECTION_FIELDS: frozenset = frozenset({"host", "port", "db", "password", "username"})
def _resolve_cache_url_precedence(settings: Mapping[str, Any]) -> dict[str, Any]:
"""Return cache settings with the url-vs-discrete-fields ambiguity resolved.
When a full ``url`` is supplied it wins: the discrete
host/port/db/password/username fields are dropped so the persisted config
is unambiguous and matches runtime resolution in ``litellm._redis``
(``redis.Redis.from_url`` ignores them). Cluster mode
(``redis_startup_nodes``) is exempt because it authenticates via the
discrete fields rather than a url.
"""
url = settings.get("url")
has_url = isinstance(url, str) and url.strip() != ""
if not has_url or settings.get("redis_startup_nodes"):
return dict(settings)
return {k: v for k, v in settings.items() if k not in _URL_OVERRIDDEN_CONNECTION_FIELDS}
def _redact_settings(settings: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
"""Replace every value in a settings map with a fixed marker.
@ -311,7 +333,7 @@ async def test_cache_connection(
from litellm import Cache
try:
cache_settings = request.cache_settings.copy()
cache_settings = _resolve_cache_url_precedence(request.cache_settings)
verbose_proxy_logger.debug("Testing cache connection with settings: %s", cache_settings)
# Only support Redis for now
@ -378,7 +400,7 @@ async def update_cache_settings(
)
try:
cache_settings = request.cache_settings.copy()
cache_settings = _resolve_cache_url_precedence(request.cache_settings)
# Snapshot the prior settings (key set only — values get redacted in
# the audit row) so the audit-log entry shows which fields changed.

View file

@ -377,6 +377,7 @@ async def new_user(
- budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}.
- model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)
- mcp_rpm_limit: Optional[dict] - Per-MCP-server rpm limit, keyed by MCP server name {"github": 100, "slack": 200}. Enforced for keys and teams only; values set on a user are stored but not enforced per user.
- tag_rpm_limit: Optional[dict] - Per-request-tag rpm limit, keyed by request tag {"cell-1": 1000, "cell-2": 500}. Enforced for keys only; values set on a user are stored but not enforced per user.
- model_tpm_limit: Optional[float] - Model-specific tpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)
- spend: Optional[float] - Amount spent by user. Default is 0. Will be updated by proxy whenever user is used. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d"), months ("1mo").
- agent_id: Optional[str] - The agent id associated with the user.
@ -1379,6 +1380,7 @@ async def user_update(
- budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}.
- model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)
- mcp_rpm_limit: Optional[dict] - Per-MCP-server rpm limit, keyed by MCP server name {"github": 100, "slack": 200}. Enforced for keys and teams only; values set on a user are stored but not enforced per user.
- tag_rpm_limit: Optional[dict] - Per-request-tag rpm limit, keyed by request tag {"cell-1": 1000, "cell-2": 500}. Enforced for keys only; values set on a user are stored but not enforced per user.
- model_tpm_limit: Optional[float] - Model-specific tpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)
- spend: Optional[float] - Amount spent by user. Default is 0. Will be updated by proxy whenever user is used. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d"), months ("1mo").
- agent_id: Optional[str] - The agent id associated with the user.

View file

@ -111,6 +111,7 @@ from litellm.repositories.verification_token_repository import (
VerificationTokenRepository,
)
from litellm.router import Router
from litellm.secret_managers.base_secret_manager import raise_if_unsafe_secret_name
from litellm.secret_managers.main import get_secret
from litellm.types.proxy.management_endpoints.key_management_endpoints import (
BulkUpdateKeyRequest,
@ -758,6 +759,12 @@ async def _common_key_generation_helper(
premium_user=premium_user,
)
if data.throttle_on_budget_exceeded is True and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value:
raise HTTPException(
status_code=403,
detail={"error": "Only proxy admins can enable throttle_on_budget_exceeded on a key."},
)
if data.metadata is not None and data.metadata.get("service_account_id") is not None and data.team_id is None:
await validate_team_id_used_in_service_account_request(
team_id=data.team_id,
@ -1483,12 +1490,14 @@ async def generate_key_fn(
- guardrails: Optional[List[str]] - List of active guardrails for the key
- policies: Optional[List[str]] - List of policy names to apply to the key. Policies define guardrails, conditions, and inheritance rules.
- disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key.
- throttle_on_budget_exceeded: Optional[bool] - When the key exceeds its max_budget, throttle its tpm/rpm to the global budget_exceeded_throttle_percentage instead of blocking the key entirely.
- permissions: Optional[dict] - key-specific permissions. Currently just used for turning off pii masking (if connected). Example - {"pii": false}
- model_max_budget: Optional[Dict[str, BudgetConfig]] - Model-specific budgets {"gpt-4": {"budget_limit": 0.0005, "time_period": "30d"}}}. IF null or {} then no model specific budget.
- budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}.
- model_rpm_limit: Optional[dict] - key-specific model rpm limit. Example - {"text-davinci-002": 1000, "gpt-3.5-turbo": 1000}. IF null or {} then no model specific rpm limit.
- model_tpm_limit: Optional[dict] - key-specific model tpm limit. Example - {"text-davinci-002": 1000, "gpt-3.5-turbo": 1000}. IF null or {} then no model specific tpm limit.
- mcp_rpm_limit: Optional[dict] - key-specific per-MCP-server rpm limit, keyed by MCP server name (alias if set, else the configured name). Example - {"github": 100, "slack": 200}. IF null or {} then no MCP-specific rpm limit.
- tag_rpm_limit: Optional[dict] - key-specific per-request-tag rpm limit, keyed by request tag. Example - {"cell-1": 1000, "cell-2": 500}. Each tag gets an independent counter; requests whose tag is absent fall back to the key-level rpm limit.
- tpm_limit_type: Optional[str] - Type of tpm limit. Options: "best_effort_throughput" (no error if we're overallocating tpm), "guaranteed_throughput" (raise an error if we're overallocating tpm), "dynamic" (dynamically exceed limit when no 429 errors). Defaults to "best_effort_throughput".
- rpm_limit_type: Optional[str] - Type of rpm limit. Options: "best_effort_throughput" (no error if we're overallocating rpm), "guaranteed_throughput" (raise an error if we're overallocating rpm), "dynamic" (dynamically exceed limit when no 429 errors). Defaults to "best_effort_throughput".
- allowed_cache_controls: Optional[list] - List of allowed cache control values. Example - ["no-cache", "no-store"]. See all values - https://docs.litellm.ai/docs/proxy/caching#turn-on--off-caching-per-request
@ -2317,6 +2326,16 @@ async def _validate_update_key_data(
or "budget_limits" in data.model_fields_set
)
_existing_metadata = getattr(existing_key_row, "metadata", None)
_existing_throttle = (
_existing_metadata.get("throttle_on_budget_exceeded") if isinstance(_existing_metadata, dict) else None
)
if data.throttle_on_budget_exceeded is True and _existing_throttle is not True and not _is_proxy_admin:
raise HTTPException(
status_code=403,
detail={"error": "Only proxy admins can enable throttle_on_budget_exceeded on a key."},
)
# Personal-key bypass: the caller both created the key AND still owns it
# (user_id == caller). Checking only created_by would let a demoted admin
# who originally created a key for another user continue editing it without
@ -2497,6 +2516,7 @@ async def update_key_fn(
- rpm_limit: Optional[int] - Requests per minute limit
- model_rpm_limit: Optional[dict] - Model-specific RPM limits {"gpt-4": 100, "claude-v1": 200}
- mcp_rpm_limit: Optional[dict] - Per-MCP-server RPM limits, keyed by MCP server name {"github": 100, "slack": 200}
- tag_rpm_limit: Optional[dict] - Per-request-tag RPM limits, keyed by request tag {"cell-1": 1000, "cell-2": 500}. Each tag gets an independent counter; absent tags fall back to the key-level rpm limit.
- model_tpm_limit: Optional[dict] - Model-specific TPM limits {"gpt-4": 100000, "claude-v1": 200000}
- tpm_limit_type: Optional[str] - TPM rate limit type - "best_effort_throughput", "guaranteed_throughput", or "dynamic"
- rpm_limit_type: Optional[str] - RPM rate limit type - "best_effort_throughput", "guaranteed_throughput", or "dynamic"
@ -2507,6 +2527,7 @@ async def update_key_fn(
- guardrails: Optional[List[str]] - List of active guardrails for the key
- policies: Optional[List[str]] - List of policy names to apply to the key. Policies define guardrails, conditions, and inheritance rules.
- disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key.
- throttle_on_budget_exceeded: Optional[bool] - When the key exceeds its max_budget, throttle its tpm/rpm to the global budget_exceeded_throttle_percentage instead of blocking the key entirely.
- prompts: Optional[List[str]] - List of prompts that the key is allowed to use.
- blocked: Optional[bool] - Whether the key is blocked
- aliases: Optional[dict] - Model aliases for the key - [Docs](https://litellm.vercel.app/docs/proxy/virtual_keys#model-aliases)
@ -3533,6 +3554,7 @@ async def generate_key_helper_fn(
model_rpm_limit: Optional[dict] = None,
model_tpm_limit: Optional[dict] = None,
mcp_rpm_limit: Optional[dict] = None,
tag_rpm_limit: Optional[dict] = None,
guardrails: Optional[list] = None,
policies: Optional[list] = None,
prompts: Optional[list] = None,
@ -3606,6 +3628,9 @@ async def generate_key_helper_fn(
if mcp_rpm_limit is not None:
metadata = metadata or {}
metadata["mcp_rpm_limit"] = mcp_rpm_limit
if tag_rpm_limit is not None:
metadata = metadata or {}
metadata["tag_rpm_limit"] = tag_rpm_limit
if guardrails is not None:
metadata = metadata or {}
metadata["guardrails"] = guardrails
@ -6267,8 +6292,13 @@ def _validate_key_alias_format(key_alias: Optional[str]) -> None:
"""
Validate the format of the key_alias.
Gated behind ``litellm.enable_key_alias_format_validation`` (default **False**).
When disabled, no validation is performed so existing workflows are not broken.
A baseline validation always runs, regardless of
``litellm.enable_key_alias_format_validation``.
The remaining charset/length rules are gated behind
``litellm.enable_key_alias_format_validation`` (default **False**). When disabled,
only the baseline validation above is performed, so existing workflows are not
broken.
Rules (when enabled):
- None is OK (no alias).
@ -6276,10 +6306,20 @@ def _validate_key_alias_format(key_alias: Optional[str]) -> None:
- start/end with alphanumeric
- only allow a-zA-Z0-9_-/.@
"""
if not litellm.enable_key_alias_format_validation:
if key_alias is None:
return
if key_alias is None:
try:
raise_if_unsafe_secret_name(key_alias)
except ValueError:
raise ProxyException(
message="Invalid key_alias",
type=ProxyErrorTypes.bad_request_error,
param="key_alias",
code=400,
)
if not litellm.enable_key_alias_format_validation:
return
if not _KEY_ALIAS_PATTERN.match(key_alias):

View file

@ -537,6 +537,10 @@ if MCP_AVAILABLE:
sanitized.authorization_url = None
sanitized.token_url = None
sanitized.registration_url = None
sanitized.token_exchange_endpoint = None
sanitized.audience = None
sanitized.subject_token_type = None
sanitized.token_exchange_profile = None
# Drop env vars entirely rather than only blanking global values: the
# names alone (DB_PASSWORD, GITHUB_API_KEY, ...) leak what secrets the
# admin configured. Non-admins get the per-user vars they must fill in
@ -578,6 +582,10 @@ if MCP_AVAILABLE:
sanitized.authorization_url = None
sanitized.token_url = None
sanitized.registration_url = None
sanitized.token_exchange_endpoint = None
sanitized.audience = None
sanitized.subject_token_type = None
sanitized.token_exchange_profile = None
sanitized.health_check_error = None
sanitized.last_health_check = None
@ -1905,6 +1913,11 @@ if MCP_AVAILABLE:
expires_in=payload.expires_in,
scopes=payload.scopes,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415
global_mcp_server_manager,
)
await global_mcp_server_manager.invalidate_user_oauth_token_cache(user_id, server_id)
# Read back the persisted record so the response reflects the stored
# expires_at rather than recomputing it here (which could diverge by
# milliseconds or if the storage logic ever adds a grace period).
@ -1945,6 +1958,11 @@ if MCP_AVAILABLE:
await delete_user_credential(prisma_client, user_id, server_id)
except RecordNotFoundError:
pass # Already gone — treat as a successful delete
from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415
global_mcp_server_manager,
)
await global_mcp_server_manager.invalidate_user_oauth_token_cache(user_id, server_id)
return MCPOAuthUserCredentialStatus(
server_id=server_id,
has_credential=False,

View file

@ -26,6 +26,7 @@ from litellm._uuid import uuid
from litellm.integrations.prometheus import PrometheusLogger
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.proxy._types import (
UI_TEAM_ID,
BlockTeamRequest,
CommonProxyErrors,
DeleteTeamRequest,
@ -1046,6 +1047,13 @@ async def new_team(
if data.team_id is None:
data.team_id = str(uuid.uuid4())
else:
if data.team_id == UI_TEAM_ID:
raise HTTPException(
status_code=400,
detail={
"error": f"team_id '{UI_TEAM_ID}' is reserved for LiteLLM UI dashboard sessions and cannot be used for a real team. Please use a different team id."
},
)
# Check if team_id exists already
_existing_team_id = await prisma_client.get_data(
team_id=data.team_id, table_name="team", query_type="find_unique"

View file

@ -113,7 +113,7 @@ from litellm.proxy.utils import (
from litellm.repositories.table_repositories import SSOConfigRepository
from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.user_repository import UserRepository
from litellm.secret_managers.main import get_secret_bool, str_to_bool
from litellm.secret_managers.main import get_secret_bool, get_secret_str, str_to_bool
from litellm.types.proxy.management_endpoints.ui_sso import * # noqa: F403
from litellm.types.proxy.management_endpoints.ui_sso import (
DefaultTeamSSOParams,
@ -2134,12 +2134,16 @@ async def cli_poll_key(
models=session_data.get("models", []),
)
user_db_obj = await get_user_object(
user_id=user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
)
try:
user_db_obj = await get_user_object(
user_id=user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
)
except ValueError as e:
verbose_proxy_logger.debug(f"CLI poll: user lookup failed, proceeding without user budget: {e}")
user_db_obj = None
user_budget = user_db_obj.max_budget if user_db_obj is not None else None
team_budget: Optional[float] = None
@ -3733,8 +3737,7 @@ class MicrosoftSSOHandler:
Handles Microsoft SSO callback response and returns a CustomOpenID object
"""
graph_api_base_url = "https://graph.microsoft.com/v1.0"
graph_api_user_groups_endpoint = f"{graph_api_base_url}/me/memberOf"
DEFAULT_GRAPH_API_BASE_URL = "https://graph.microsoft.com/v1.0"
"""
Constants
@ -3744,6 +3747,19 @@ class MicrosoftSSOHandler:
# used for debugging to show the user groups litellm found from Graph API
GRAPH_API_RESPONSE_KEY = "graph_api_user_groups"
@staticmethod
def get_graph_api_base_url() -> str:
"""
Returns the Microsoft Graph API base URL, configurable via the
`MICROSOFT_GRAPH_ENDPOINT` env var so non-default clouds such as Azure
Government (GCC High) can point at `https://graph.microsoft.us/v1.0`
"""
return get_secret_str("MICROSOFT_GRAPH_ENDPOINT") or MicrosoftSSOHandler.DEFAULT_GRAPH_API_BASE_URL
@staticmethod
def get_graph_api_user_groups_endpoint() -> str:
return f"{MicrosoftSSOHandler.get_graph_api_base_url()}/me/memberOf"
@staticmethod
async def get_microsoft_callback_response(
request: Request,
@ -3920,7 +3936,7 @@ class MicrosoftSSOHandler:
# Fetch user membership from Microsoft Graph API
all_group_ids = []
next_link: Optional[str] = MicrosoftSSOHandler.graph_api_user_groups_endpoint
next_link: Optional[str] = MicrosoftSSOHandler.get_graph_api_user_groups_endpoint()
auth_headers = {"Authorization": f"Bearer {access_token}"}
page_count = 0
@ -4003,7 +4019,7 @@ class MicrosoftSSOHandler:
Users use Enterprise Applications to manage Groups and Users on Microsoft Entra ID
"""
base_url = "https://graph.microsoft.com/v1.0"
base_url = MicrosoftSSOHandler.get_graph_api_base_url()
# Endpoint to get app role assignments for the given service principal
endpoint = f"/servicePrincipals/{service_principal_id}/appRoleAssignedTo"
url = base_url + endpoint

View file

@ -7,7 +7,7 @@ import traceback
from base64 import b64encode
from datetime import datetime
from itertools import groupby
from typing import Any, Dict, List, Mapping, Optional, Tuple, Union, cast
from typing import Any, AsyncGenerator, Dict, List, Mapping, Optional, Tuple, Union, cast
from urllib.parse import urlencode, urlparse
import httpx
@ -389,18 +389,24 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
forward_multipart: bool = False,
) -> httpx.Response:
"""
Handle non-streaming HTTP requests
Handle non-SSE HTTP requests
Handles special cases when GET requests, multipart/form-data requests, and generic httpx requests
Handles special cases when GET requests, multipart/form-data requests, and generic httpx requests.
GET and generic requests are sent with httpx stream semantics so the caller can
decide from the response headers whether to buffer the body (JSON, inspected for
logging/guardrails) or relay it to the client without materializing it in memory
(LIT-4009: large batch results files must not be buffered in proxy RSS).
"""
if request.method == "GET":
response = await async_client.request(
method=request.method,
url=url,
get_request = async_client.build_request(
request.method,
url,
headers=headers,
params=requested_query_params,
)
elif HttpPassThroughEndpointHelpers.is_multipart(request) is True and forward_multipart:
return await async_client.send(get_request, stream=True)
if HttpPassThroughEndpointHelpers.is_multipart(request) is True and forward_multipart:
# Forward multipart via make_multipart_http_request even when _parsed_body is
# non-empty (pass_through_request always injects litellm_logging_obj, etc.).
# forward_multipart is False when custom_body was supplied (JSON body despite
@ -412,16 +418,14 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
headers=headers,
requested_query_params=requested_query_params,
)
else:
# Generic httpx method
response = await async_client.request(
method=request.method,
url=url,
headers=headers,
params=requested_query_params,
json=_parsed_body,
)
return response
generic_request = async_client.build_request(
request.method,
url,
headers=headers,
params=requested_query_params,
json=_parsed_body,
)
return await async_client.send(generic_request, stream=True)
@staticmethod
def is_multipart(request: Request) -> bool:
@ -820,21 +824,7 @@ async def pass_through_request(
forward_headers=forward_headers,
)
# Apply default query parameters if provided, regardless of merge_query_params setting
if default_query_params or merge_query_params:
# Determine what to merge based on settings
request_params = dict(request.query_params) if merge_query_params else {}
# Create a new URL with the merged query params
url = url.copy_with(
query=urlencode(
HttpPassThroughEndpointHelpers.get_merged_query_parameters(
existing_url=url,
request_query_params=request_params,
default_query_params=default_query_params,
)
).encode("ascii")
)
requested_query_params: Optional[dict] = query_params or dict(request.query_params)
endpoint_type: EndpointType = HttpPassThroughEndpointHelpers.get_endpoint_type(str(url))
@ -952,9 +942,6 @@ async def pass_through_request(
)
logging_obj.model_call_details["litellm_call_id"] = litellm_call_id
# combine url with query params for logging
requested_query_params: Optional[dict] = query_params or dict(request.query_params)
## PASSTHROUGH MANAGED ID RESOLUTION (INPUT) ##
# Resolve managed IDs in path, query params, and body back to raw
# provider IDs before forwarding upstream. Gated by feature flag and
@ -1024,6 +1011,20 @@ async def pass_through_request(
request.method,
)
# Apply default query parameters if provided, regardless of merge_query_params setting
if default_query_params or merge_query_params:
# Create a new URL with the merged query params
url = url.copy_with(
query=urlencode(
HttpPassThroughEndpointHelpers.get_merged_query_parameters(
existing_url=url,
request_query_params=requested_query_params,
default_query_params=default_query_params,
)
).encode("ascii")
)
requested_query_params = None
## PASSTHROUGH MANAGED LIST (DB-only response) ##
# For GET /v1/files and GET /v1/batches passthrough routes, serve the
# listing entirely from our DB so each caller only sees their own IDs.
@ -1164,13 +1165,14 @@ async def pass_through_request(
if state_raw_body is not None:
# SigV4-signed callers (Bedrock) require the exact pre-signed bytes
# to be forwarded so the signature/Content-Length stay valid.
response = await async_client.request(
method=request.method,
url=url,
raw_body_request = async_client.build_request(
request.method,
url,
headers=headers,
params=requested_query_params,
content=state_raw_body,
)
response = await async_client.send(raw_body_request, stream=True)
else:
response = await HttpPassThroughEndpointHelpers.non_streaming_http_request_handler(
request=request,
@ -1226,6 +1228,40 @@ async def pass_through_request(
status_code=response.status_code,
)
if not _should_buffer_passthrough_response(response):
relay_custom_headers = ProxyBaseLLMRequestProcessing.get_custom_headers(
user_api_key_dict=user_api_key_dict,
call_id=litellm_call_id,
model_id=None,
cache_key=None,
api_base=str(url._uri_reference),
)
relay_callback_headers = await proxy_logging_obj.post_call_response_headers_hook(
data=_parsed_body or {},
user_api_key_dict=user_api_key_dict,
response=response,
request_headers=dict(request.headers),
)
if relay_callback_headers:
relay_custom_headers.update(relay_callback_headers)
return StreamingResponse(
_relay_passthrough_response_bytes(
response=response,
request_body=_parsed_body or {},
url_route=str(url),
start_time=start_time,
logging_obj=logging_obj,
custom_llm_provider=custom_llm_provider,
success_handler_kwargs=kwargs,
),
status_code=response.status_code,
headers=HttpPassThroughEndpointHelpers.get_response_headers(
headers=response.headers,
custom_headers=relay_custom_headers,
),
)
content = await response.aread()
## POST-CALL GUARDRAILS ##
@ -2214,6 +2250,70 @@ def _is_streaming_response(response: httpx.Response) -> bool:
return False
def _should_buffer_passthrough_response(response: httpx.Response) -> bool:
"""
Decide from the response headers whether the body must be read into memory.
JSON bodies (and upstream errors) stay buffered: spend logging, guardrails and
managed-id rewriting inspect them, and they are small in practice. Everything
else (jsonl batch results, octet-stream files, ...) is relayed to the client
chunk by chunk so a large body is never resident in full (LIT-4009). A missing
content-type is buffered because the body cannot be classified.
"""
if response.status_code >= 400:
return True
media_type = response.headers.get("content-type", "").split(";")[0].strip().lower()
return media_type in ("", "application/json") or media_type.endswith("+json")
async def _relay_passthrough_response_bytes(
response: httpx.Response,
request_body: dict,
url_route: str,
start_time: datetime,
logging_obj: LiteLLMLoggingObj,
custom_llm_provider: Optional[str],
success_handler_kwargs: dict,
) -> AsyncGenerator[bytes, None]:
"""
Yield upstream bytes to the client without accumulating them, then fire the
passthrough success handler with response_body=None (uninspected body). The
finally block also runs on client disconnect (GeneratorExit) so partial
downloads still produce a spend-log row, mirroring chunk_processor; a
disconnect additionally logs a warning with the number of bytes relayed so
partial deliveries are distinguishable from complete ones in proxy logs.
"""
bytes_relayed = 0
upstream_fully_relayed = False
try:
async for chunk in response.aiter_bytes():
bytes_relayed += len(chunk)
yield chunk
upstream_fully_relayed = True
finally:
if not upstream_fully_relayed:
verbose_proxy_logger.warning(
f"Passthrough stream for {url_route} ended before upstream body was fully relayed; "
f"{bytes_relayed} bytes were sent to the client"
)
await response.aclose()
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(
async_coroutine=pass_through_endpoint_logging.pass_through_async_success_handler(
httpx_response=response,
response_body=None,
url_route=url_route,
result="",
start_time=start_time,
end_time=datetime.now(),
logging_obj=logging_obj,
cache_hit=False,
request_body=request_body,
custom_llm_provider=custom_llm_provider,
**success_handler_kwargs,
)
)
def _extract_model_from_vertex_ai_setup(setup_response: dict) -> Optional[str]:
"""
Extract the model name from Vertex AI Live setup response.

View file

@ -34,6 +34,18 @@ from .llm_provider_handlers.vertex_passthrough_logging_handler import (
cohere_passthrough_logging_handler = CoherePassthroughLoggingHandler()
def _safe_response_text(httpx_response: httpx.Response) -> str:
"""
Streamed passthrough responses are relayed to the client without being read
into memory, so accessing .text on them raises ResponseNotRead. Their body is
intentionally uninspected; log an empty string instead of failing the row.
"""
try:
return httpx_response.text
except httpx.ResponseNotRead:
return ""
class PassThroughEndpointLogging:
def __init__(self):
self.TRACKED_VERTEX_ROUTES = [
@ -306,7 +318,9 @@ class PassThroughEndpointLogging:
]
kwargs = normalized_llm_passthrough_logging_payload["kwargs"]
if standard_logging_response_object is None:
standard_logging_response_object = StandardPassThroughResponseObject(response=httpx_response.text)
standard_logging_response_object = StandardPassThroughResponseObject(
response=_safe_response_text(httpx_response)
)
kwargs = self._set_cost_per_request(
logging_obj=logging_obj,

View file

@ -1127,45 +1127,6 @@ _OPENAPI_HTTP_METHODS = {
# `_SSO_SENSITIVE_FIELDS` / `_CACHE_SENSITIVE_FIELDS` constants in the SSO
# and cache endpoint files.
_ALERTING_SENSITIVE_VARS: Set[str] = {"SLACK_WEBHOOK_URL", "SMTP_PASSWORD"}
_DB_LITELLM_PARAM_ENV_REF_KEYS = frozenset(
{
"api_key",
"client_secret",
"vertex_credentials",
"vertex_ai_credentials",
"aws_access_key_id",
"aws_secret_access_key",
"aws_session_token",
"aws_region_name",
"aws_session_name",
"aws_profile_name",
"aws_role_name",
"aws_web_identity_token",
"aws_sts_endpoint",
"aws_external_id",
"aws_bedrock_runtime_endpoint",
"aws_bedrock_project_id",
"aws_batch_role_arn",
"aws_workspace_id",
}
)
def _db_model_is_team_scoped(model: object) -> bool:
model_info = getattr(model, "model_info", None)
if isinstance(model_info, BaseModel):
return getattr(model_info, "team_id", None) is not None
if isinstance(model_info, str):
try:
model_info = json.loads(model_info)
except (TypeError, ValueError):
model_info = None
if isinstance(model_info, dict) and model_info.get("team_id") is not None:
return True
if getattr(model_info, "team_id", None) is not None:
return True
model_name = getattr(model, "model_name", None)
return isinstance(model_name, str) and model_name.startswith("model_name_")
def _strip_operation_id_method_suffix(operation_id: str) -> str:
@ -3596,6 +3557,28 @@ def _scrub_db_overlay_remote_module_loads(section: str, db_value: Any) -> Any:
return sanitized
def _normalize_user_url_validation(value: object) -> Optional[bool]:
if value is None:
return None
if isinstance(value, str):
return str_to_bool(value)
return bool(value)
def _apply_ssrf_general_settings(settings: Mapping[str, object]) -> None:
if "user_url_allowed_hosts" in settings:
litellm.user_url_allowed_hosts = cast(list[str], settings["user_url_allowed_hosts"])
user_url_validation = _normalize_user_url_validation(settings.get("user_url_validation"))
if user_url_validation is not None:
litellm.user_url_validation = user_url_validation
if "provider_url_destination_allowed_hosts" in settings:
litellm.provider_url_destination_allowed_hosts = cast(
list[str], settings["provider_url_destination_allowed_hosts"]
)
class ProxyConfig:
"""
Abstraction class on top of config loading/updating logic. Gives us one place to control all config updating logic.
@ -4583,6 +4566,9 @@ class ProxyConfig:
RoleBasedPermissions(**role_permission) for role_permission in rbac_role_permissions
]
### SSRF URL VALIDATION SETTINGS ###
_apply_ssrf_general_settings(general_settings)
## check if user has set a premium feature in general_settings
if general_settings.get("enforced_params") is not None and premium_user is not True:
raise ValueError("Trying to use `enforced_params`" + CommonProxyErrors.not_premium_user.value)
@ -5014,17 +5000,12 @@ class ProxyConfig:
deleted_deployments += 1
return deleted_deployments
def _resolve_db_litellm_param(self, key: str, value: object, resolve_env_refs: bool = True) -> object:
def _resolve_db_litellm_param(self, key: str, value: object) -> object:
if not isinstance(value, str):
return value
decrypted_value = decrypt_value_helper(value=value, key=key, return_original_value=True)
if (
resolve_env_refs
and key in _DB_LITELLM_PARAM_ENV_REF_KEYS
and isinstance(decrypted_value, str)
and decrypted_value.startswith("os.environ/")
):
if isinstance(decrypted_value, str) and decrypted_value.startswith("os.environ/"):
return get_secret(decrypted_value)
return decrypted_value
@ -5045,13 +5026,10 @@ class ProxyConfig:
## ADD MODEL LOGIC
for m in db_models:
_litellm_params = m.litellm_params
resolve_env_refs = not _db_model_is_team_scoped(m)
if isinstance(_litellm_params, dict):
# decrypt values
for k, v in _litellm_params.items():
_litellm_params[k] = self._resolve_db_litellm_param(
key=k, value=v, resolve_env_refs=resolve_env_refs
)
_litellm_params[k] = self._resolve_db_litellm_param(key=k, value=v)
_litellm_params = LiteLLM_Params(**_litellm_params)
else:
@ -5077,15 +5055,12 @@ class ProxyConfig:
_model_list: list = []
for m in new_models:
_litellm_params = m.litellm_params
resolve_env_refs = not _db_model_is_team_scoped(m)
if isinstance(_litellm_params, BaseModel):
_litellm_params = _litellm_params.model_dump()
if isinstance(_litellm_params, dict):
# decrypt values
for k, v in _litellm_params.items():
_litellm_params[k] = self._resolve_db_litellm_param(
key=k, value=v, resolve_env_refs=resolve_env_refs
)
_litellm_params[k] = self._resolve_db_litellm_param(key=k, value=v)
_litellm_params = LiteLLM_Params(**_litellm_params)
else:
verbose_proxy_logger.error(
@ -5620,6 +5595,15 @@ class ProxyConfig:
if old_value != new_value:
await self._reschedule_spend_log_cleanup_job()
for key in (
"user_url_allowed_hosts",
"user_url_validation",
"provider_url_destination_allowed_hosts",
):
if key in _general_settings:
general_settings[key] = _general_settings[key]
_apply_ssrf_general_settings(_general_settings)
def _update_config_fields(
self,
current_config: dict,
@ -14281,6 +14265,9 @@ async def update_config_general_settings(
detail={"error": CommonProxyErrors.not_allowed_access.value},
)
if data.field_name in _GENERAL_SETTINGS_UI_LITELLM_FIELDS:
return await _persist_general_settings_ui_litellm_field(data.field_name, data.field_value, user_api_key_dict)
if data.field_name not in ConfigGeneralSettings.model_fields:
raise HTTPException(
status_code=400,
@ -14339,6 +14326,7 @@ async def update_config_general_settings(
if data.field_name == "plugins":
register_plugins_from_config(general_settings)
_apply_ssrf_general_settings(general_settings)
return response
@ -14545,6 +14533,55 @@ async def get_config_general_settings(
)
_GENERAL_SETTINGS_UI_LITELLM_FIELDS: dict[str, dict[str, str]] = {
"budget_exceeded_throttle_percentage": {
"type": "Float",
"description": (
"Fraction (0, 1] of a key's configured TPM/RPM that an over-budget key with "
"'Throttle on budget exceeded' enabled keeps serving at. Leave empty to hard-block "
"over-budget keys."
),
},
}
def _validate_general_settings_ui_litellm_value(field_name: str, value: Any) -> Optional[float]:
if value is None or value == "":
return None
if isinstance(value, bool) or not isinstance(value, (int, float)) or not (0 < float(value) <= 1):
raise HTTPException(
status_code=400,
detail={"error": f"{field_name} must be a number in (0, 1] or empty"},
)
return float(value)
async def _persist_general_settings_ui_litellm_field(
field_name: str, value: Any, user_api_key_dict: UserAPIKeyAuth
) -> dict:
validated = _validate_general_settings_ui_litellm_value(field_name, value)
config = await proxy_config.get_config()
before_value = config.get("litellm_settings", {}).get(field_name)
setattr(litellm, field_name, validated)
if "litellm_settings" not in config:
config["litellm_settings"] = {}
config["litellm_settings"][field_name] = validated
await proxy_config.save_config(new_config=config)
asyncio.create_task(create_config_audit_log(field_name, "updated", before_value, validated, user_api_key_dict))
return {"message": f"Field {field_name} updated", "status": "success"}
async def _reset_general_settings_ui_litellm_field(field_name: str, user_api_key_dict: UserAPIKeyAuth) -> dict:
config = await proxy_config.get_config()
before_value = config.get("litellm_settings", {}).get(field_name)
setattr(litellm, field_name, None)
if "litellm_settings" in config:
config["litellm_settings"].pop(field_name, None)
await proxy_config.save_config(new_config=config)
asyncio.create_task(create_config_audit_log(field_name, "deleted", before_value, None, user_api_key_dict))
return {"message": f"Field {field_name} reset", "status": "success"}
@router.get(
"/config/list",
tags=["config.yaml"],
@ -14698,6 +14735,35 @@ async def get_config_list(
)
return_val.append(_response_obj)
db_litellm_settings_row = await ConfigRepository(prisma_client).table.find_first(
where={"param_name": "litellm_settings"}
)
db_litellm_settings: dict = (
dict(db_litellm_settings_row.param_value)
if db_litellm_settings_row is not None and db_litellm_settings_row.param_value is not None
else {}
)
for litellm_field_name, spec in _GENERAL_SETTINGS_UI_LITELLM_FIELDS.items():
current_value: Optional[float] = getattr(litellm, litellm_field_name, None)
stored_in_db_litellm: Optional[bool]
if litellm_field_name in db_litellm_settings:
stored_in_db_litellm = True
elif current_value is not None:
stored_in_db_litellm = False
else:
stored_in_db_litellm = None
return_val.append(
ConfigList(
field_name=litellm_field_name,
field_type=spec["type"],
field_description=spec["description"],
field_value=current_value,
stored_in_db=stored_in_db_litellm,
field_default_value=None,
nested_fields=None,
)
)
return return_val
@ -14738,6 +14804,9 @@ async def delete_config_general_settings(
},
)
if data.field_name in _GENERAL_SETTINGS_UI_LITELLM_FIELDS:
return await _reset_general_settings_ui_litellm_field(data.field_name, user_api_key_dict)
if data.field_name not in ConfigGeneralSettings.model_fields:
raise HTTPException(
status_code=400,

View file

@ -329,6 +329,12 @@ model LiteLLM_MCPServerTable {
token_url String?
registration_url String?
oauth2_flow String?
token_exchange_endpoint String?
// Named for the RFC 8693 "audience" token-exchange request parameter (that flow only).
// RFC 8707 resource indicators are a separate concept, named "resource" in the v2 egress types.
audience String?
subject_token_type String?
token_exchange_profile String?
allow_all_keys Boolean @default(false)
available_on_public_internet Boolean @default(true)
delegate_auth_to_upstream Boolean @default(false)

View file

@ -17,6 +17,7 @@ from litellm.proxy._types import (
UserAPIKeyAuth,
)
from litellm.proxy.auth.auth_utils import get_model_from_request
from litellm.proxy.auth.budget_throttle import should_throttle_budget_exceeded
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.router import Router
@ -54,6 +55,61 @@ def get_reserved_counter_keys(budget_reservation: Optional[dict]) -> set:
}
def _key_reservation_should_release_for_throttle(counter_key: str, valid_token: Optional[UserAPIKeyAuth]) -> bool:
"""
Whether an over-budget key's own ``max_budget`` reservation should be
released rather than blocked, because the key opted into throttling: the
rate limiter slows it instead. Only the key's own ``max_budget`` counter is
exempt; team/user/window counters still enforce normally, and under-budget
requests never reach this branch so their concurrent-overspend protection is
untouched.
"""
if valid_token is None:
return False
return counter_key == f"spend:key:{valid_token.token}" and should_throttle_budget_exceeded(valid_token)
async def _apply_over_budget_reservation_policy(
counter: _BudgetCounter,
valid_token: Optional[UserAPIKeyAuth],
entry: dict[str, Any],
applied_entries: list[dict[str, Any]],
reservation_cost: float,
current_spend: float,
) -> float:
"""
Decide what to do when a counter is over budget, and return the reservation
cost to carry into the next counter. Three outcomes: an over-budget key that
opted into throttling releases its own reservation (the rate limiter slows
it) and keeps the cost; a partially-remaining budget resizes the reservation
down to what is left; anything else hard-blocks by raising.
"""
if _key_reservation_should_release_for_throttle(counter.counter_key, valid_token):
await _release_applied_entries_best_effort(entries=[entry], default_reserved_cost=reservation_cost)
applied_entries.remove(entry)
return reservation_cost
remaining_before_reservation = counter.max_budget - (current_spend - reservation_cost)
if remaining_before_reservation > 1e-12:
await _resize_applied_reservation(
entries=applied_entries,
current_reserved_cost=reservation_cost,
new_reserved_cost=remaining_before_reservation,
)
return remaining_before_reservation
raise litellm.BudgetExceededError(
current_cost=current_spend,
max_budget=counter.max_budget,
message=(
"Budget has been exceeded! "
f"{counter.entity_type}={counter.entity_id} "
f"Current cost: {current_spend}, "
f"Max budget: {counter.max_budget}"
),
)
async def reserve_budget_for_request(
request_body: dict,
route: str,
@ -130,25 +186,15 @@ async def reserve_budget_for_request(
cached_spend = await _get_current_counter_value(counter=counter)
current_spend = cached_spend + reservation_cost
if current_spend > counter.max_budget:
remaining_before_reservation = counter.max_budget - (current_spend - reservation_cost)
if remaining_before_reservation > 1e-12:
await _resize_applied_reservation(
entries=applied_entries,
current_reserved_cost=reservation_cost,
new_reserved_cost=remaining_before_reservation,
)
reservation_cost = remaining_before_reservation
continue
raise litellm.BudgetExceededError(
current_cost=current_spend,
max_budget=counter.max_budget,
message=(
"Budget has been exceeded! "
f"{counter.entity_type}={counter.entity_id} "
f"Current cost: {current_spend}, "
f"Max budget: {counter.max_budget}"
),
reservation_cost = await _apply_over_budget_reservation_policy(
counter=counter,
valid_token=valid_token,
entry=entry,
applied_entries=applied_entries,
reservation_cost=reservation_cost,
current_spend=current_spend,
)
continue
except Exception:
await _release_applied_entries_best_effort(
entries=applied_entries,

View file

@ -45,6 +45,8 @@ else:
router = APIRouter()
SPEND_LOGS_PAGINATION_COUNT_CAP = 10000
@router.get(
"/spend/keys",
@ -1958,6 +1960,20 @@ async def ui_view_spend_logs(
else:
_order_expr = order_column
count_query = f"""
SELECT COUNT(*) AS total_count
FROM (
SELECT 1
FROM "LiteLLM_SpendLogs"
WHERE {" AND ".join(sql_conditions)}
LIMIT ${p}
) AS bounded_matches
"""
count_rows = await prisma_client.db.query_raw(count_query, *sql_params, SPEND_LOGS_PAGINATION_COUNT_CAP + 1)
raw_total = int(count_rows[0]["total_count"]) if count_rows else 0
total_is_capped = raw_total > SPEND_LOGS_PAGINATION_COUNT_CAP
total_records = SPEND_LOGS_PAGINATION_COUNT_CAP if total_is_capped else raw_total
sql_query = f"""
SELECT
request_id, call_type, api_key, spend, total_tokens,
@ -1967,8 +1983,7 @@ async def ui_view_spend_logs(
cache_hit, cache_key, request_tags, team_id,
organization_id, end_user, requester_ip_address,
session_id, status, mcp_namespaced_tool_name, agent_id,
COALESCE(request_duration_ms, (EXTRACT(EPOCH FROM ("endTime" - "startTime")) * 1000)::INTEGER) AS request_duration_ms,
COUNT(*) OVER () AS total_count
COALESCE(request_duration_ms, (EXTRACT(EPOCH FROM ("endTime" - "startTime")) * 1000)::INTEGER) AS request_duration_ms
FROM "LiteLLM_SpendLogs"
WHERE {" AND ".join(sql_conditions)}
ORDER BY {_order_expr} {_sql_dir}{_nulls_clause}
@ -1978,34 +1993,13 @@ async def ui_view_spend_logs(
data = await prisma_client.db.query_raw(sql_query, *sql_params)
# `COUNT(*) OVER ()` folds the total-match count into the same scan as the
# page data; a standalone `COUNT(*)` is a distributed RPC on sharded
# engines like YugabyteDB that contacts every tablet and times out
# regardless of row count (LIT-4027). The hot path (page 1 and in-range
# pages) always carries the count on its rows, so the count round trip is
# gone there. Only an out-of-range page overshoots the last row and comes
# back empty; fall back to a direct count there so total/total_pages stay
# accurate rather than collapsing to zero.
if data:
total_records = int(data[0]["total_count"])
elif page > 1:
total_records = int(
await SpendLogsRepository(prisma_client).table.count(
where=where_conditions,
)
)
else:
total_records = 0
# query_raw returns the JSONB `metadata` column as a string (the Prisma
# serialiser bypasses the model-layer JSON hydration we get on the ORM
# path). The UI reads `metadata.status` / `metadata.error_information`
# as object fields, so failure rows looked like successes (#29674).
# Re-hydrate to dict here. Also drop the window-function `total_count`
# helper column so it does not leak into the serialised rows.
# Re-hydrate to dict here.
for row in data:
if isinstance(row, dict):
row.pop("total_count", None)
md = row.get("metadata")
if isinstance(md, str):
try:
@ -2026,6 +2020,7 @@ async def ui_view_spend_logs(
page_size,
total_pages,
enrich_session_counts=not is_v2,
total_is_capped=total_is_capped,
)
except Exception as e:
verbose_proxy_logger.exception(f"Error in ui_view_spend_logs: {e}")
@ -3334,6 +3329,7 @@ async def _build_ui_spend_logs_response(
page_size: int,
total_pages: int,
enrich_session_counts: bool = True,
total_is_capped: bool = False,
) -> dict:
"""
Build the paginated response for the UI spend-logs endpoint.
@ -3358,10 +3354,12 @@ async def _build_ui_spend_logs_response(
total_pages: Total number of pages.
enrich_session_counts: Whether to add ``session_total_count`` to each
row. Defaults to ``True``.
total_is_capped: Whether ``total_records`` was clamped to the
pagination count cap (there are more matching rows than the cap).
Returns:
A dict with ``data`` (enriched rows), ``total``, ``page``,
``page_size``, and ``total_pages``.
``page_size``, ``total_pages``, and ``total_is_capped``.
"""
count_map: dict[str, int] = {}
if enrich_session_counts:
@ -3451,6 +3449,7 @@ async def _build_ui_spend_logs_response(
"page": page,
"page_size": page_size,
"total_pages": total_pages,
"total_is_capped": total_is_capped,
}

View file

@ -4097,11 +4097,22 @@ class PrismaClient:
raise e
def _get_engine_pid(self) -> int:
"""Get the PID of the writer's engine subprocess, or 0 if unavailable.
Must never raise: prisma's ``_engine`` property raises
``ClientNotConnectedError`` on a disconnected client, and an exception
escaping from the reconnect path would leave it unable to recover.
"""
try:
engine = self.db._original_prisma._engine # type: ignore[attr-defined]
prisma_obj = self.writer_db._original_prisma
if prisma_obj.is_connected() is not True:
return 0
engine = prisma_obj._engine
process = getattr(engine, "process", None) if engine is not None else None
if process is not None:
return process.pid
pid = process.pid
if isinstance(pid, int):
return pid
except (AttributeError, TypeError):
pass
return 0

View file

@ -515,6 +515,7 @@ async def _realtime_health_check(
api_base: Optional[str] = None,
api_version: Optional[str] = None,
realtime_protocol: Optional[str] = None,
model_params: Optional[dict] = None,
):
"""
Health check for realtime API - tries connection to the realtime API websocket
@ -550,14 +551,17 @@ async def _realtime_health_check(
elif custom_llm_provider == "xai":
url = xai_realtime._construct_url(api_base=api_base or "https://api.x.ai/v1", query_params={"model": model})
elif custom_llm_provider == "vertex_ai":
vertex_location = litellm.vertex_location or get_secret_str("VERTEXAI_LOCATION")
resolved_location = vertex_llm_base.get_vertex_region(vertex_region=vertex_location, model=model)
vertex_model_params = model_params or {}
resolved_location = vertex_llm_base.get_vertex_region(
vertex_region=VertexBase.safe_get_vertex_ai_location(vertex_model_params),
model=model,
)
(
access_token,
resolved_project,
) = await vertex_llm_base._ensure_access_token_async(
credentials=None,
project_id=litellm.vertex_project or get_secret_str("VERTEXAI_PROJECT"),
credentials=VertexBase.safe_get_vertex_ai_credentials(vertex_model_params),
project_id=VertexBase.safe_get_vertex_ai_project(vertex_model_params),
custom_llm_provider="vertex_ai",
)
vertex_realtime_config = VertexAIRealtimeConfig(

View file

@ -158,7 +158,7 @@ def rerank(
instruction=instruction,
non_default_params=kwargs,
)
verbose_logger.info(f"optional_rerank_params: {optional_rerank_params}")
verbose_logger.debug(f"optional_rerank_params: {optional_rerank_params}")
if isinstance(optional_params.timeout, str):
optional_params.timeout = float(optional_params.timeout)

View file

@ -16,8 +16,10 @@ from typing import (
from litellm._logging import verbose_logger
from litellm.constants import MAXIMUM_TRACEBACK_LINES_TO_LOG
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.proxy._experimental.mcp_server.utils import split_server_prefix_from_name
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.proxy._experimental.mcp_server.utils import (
split_server_prefix_from_name,
strip_known_server_prefix,
)
from litellm.responses.main import aresponses
from litellm.responses.streaming_iterator import BaseResponsesAPIStreamingIterator
from litellm.types.llms.openai import ResponsesAPIResponse
@ -628,6 +630,9 @@ class LiteLLM_Proxy_MCP_Handler:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._experimental.mcp_server.server import (
_resolve_display_name_to_original,
)
from litellm.proxy.proxy_server import proxy_logging_obj
tool_results = []
@ -654,11 +659,13 @@ class LiteLLM_Proxy_MCP_Handler:
server_name = tool_server_map[tool_name]
# Remove the server name prefix if the tool name includes it.
sanitized_tool_name = tool_name
unprefixed_name, prefixed_server_name = split_server_prefix_from_name(tool_name)
if prefixed_server_name and prefixed_server_name == server_name and unprefixed_name:
sanitized_tool_name = unprefixed_name
mcp_server = global_mcp_server_manager.get_mcp_server_by_name(
server_name
) or global_mcp_server_manager._get_mcp_server_from_tool_name(tool_name)
resolved_tool_name = (
_resolve_display_name_to_original(tool_name, [mcp_server]) if mcp_server else tool_name
)
sanitized_tool_name = strip_known_server_prefix(resolved_tool_name, mcp_server)
start_time = datetime.now()
logging_input = [
@ -697,6 +704,10 @@ class LiteLLM_Proxy_MCP_Handler:
if request_tags:
logging_request_data["metadata"]["tags"] = request_tags
if user_api_key_auth is not None:
from litellm.proxy.litellm_pre_call_utils import (
LiteLLMProxyRequestSetup,
)
LiteLLMProxyRequestSetup.add_user_api_key_auth_to_request_metadata(
data=logging_request_data,
user_api_key_dict=user_api_key_auth,
@ -741,7 +752,6 @@ class LiteLLM_Proxy_MCP_Handler:
"arguments": parsed_arguments,
"namespaced_tool_name": tool_name,
}
mcp_server = global_mcp_server_manager._get_mcp_server_from_tool_name(tool_name)
if mcp_server:
mcp_info = mcp_server.mcp_info or {}
standard_logging_mcp_tool_call["mcp_server_name"] = (

View file

@ -287,11 +287,12 @@ class BaseResponsesAPIStreamingIterator:
end_time = datetime.now()
if is_async:
asyncio.create_task(
self.logging_obj.async_success_handler(
result=logging_response,
self.logging_obj.dispatch_success_handlers(
logging_response,
start_time=self.start_time,
end_time=end_time,
cache_hit=self._completed_response_cache_hit,
prefer_async_handlers=True,
)
)
else:
@ -302,14 +303,13 @@ class BaseResponsesAPIStreamingIterator:
end_time=end_time,
cache_hit=self._completed_response_cache_hit,
)
executor.submit(
self.logging_obj.success_handler,
result=logging_response,
cache_hit=self._completed_response_cache_hit,
start_time=self.start_time,
end_time=end_time,
)
executor.submit(
self.logging_obj.success_handler,
result=logging_response,
cache_hit=self._completed_response_cache_hit,
start_time=self.start_time,
end_time=end_time,
)
self._run_post_success_hooks(end_time=end_time)
def _handle_logging_completed_response(self):
@ -1136,7 +1136,6 @@ def _build_synthetic_response_events(
# ---------------------------------------------------------------------------
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.thread_pool_executor import executor as _ws_executor
RESPONSES_WS_LOGGED_EVENT_TYPES = [
"response.created",
@ -1251,8 +1250,7 @@ class ResponsesWebSocketStreaming:
if self.input_messages:
self.logging_obj.model_call_details["messages"] = self.input_messages
if self.messages:
asyncio.create_task(self.logging_obj.async_success_handler(self.messages))
_ws_executor.submit(self.logging_obj.success_handler, self.messages)
asyncio.create_task(self.logging_obj.dispatch_success_handlers(self.messages, prefer_async_handlers=True))
async def backend_to_client(self) -> None:
"""Forward events from backend WebSocket to the client."""

View file

@ -204,6 +204,9 @@ class ResponsesAPIRequestUtils:
if response_id is None:
return responses_api_response
if ResponsesAPIRequestUtils._is_litellm_encoded_response_id(response_id):
return responses_api_response
updated_id = ResponsesAPIRequestUtils._build_responses_api_response_id(
model_id=model_id,
custom_llm_provider=custom_llm_provider,
@ -470,6 +473,14 @@ class ResponsesAPIRequestUtils:
response_id=response_id,
)
@staticmethod
def _is_litellm_encoded_response_id(response_id: str) -> bool:
decoded_response_id = ResponsesAPIRequestUtils._decode_responses_api_response_id(response_id)
return (
decoded_response_id.get("model_id") is not None
or decoded_response_id.get("custom_llm_provider") is not None
)
@staticmethod
def get_model_id_from_response_id(response_id: Optional[str]) -> Optional[str]:
"""Get the model_id from the response_id"""

View file

@ -7394,8 +7394,7 @@ class Router:
# deployment sharing the same backend model name.
# Each deployment's full pricing is already stored under its
# unique model_id above.
_custom_pricing_fields = CustomPricingLiteLLMParams.model_fields.keys()
_shared_model_info = {k: v for k, v in _model_info.items() if k not in _custom_pricing_fields}
_shared_model_info = CustomPricingLiteLLMParams.strip_custom_pricing_fields(_model_info)
_existing_shared_mode = (cast(Optional[dict], litellm.model_cost.get(_model_name, {})) or {}).get("mode")
_deployment_mode = _shared_model_info.get("mode")
# Keep the built-in bridge mode stable for shared backend keys.
@ -8059,8 +8058,7 @@ class Router:
# deployment sharing the same backend model name.
# Each deployment's full pricing is already stored under its
# unique model_id above (when present).
_custom_pricing_fields = CustomPricingLiteLLMParams.model_fields.keys()
_shared_model_info = {k: v for k, v in _model_info_dict.items() if k not in _custom_pricing_fields}
_shared_model_info = CustomPricingLiteLLMParams.strip_custom_pricing_fields(_model_info_dict)
_backend_alias_cost = {_model_name: _shared_model_info}
if "responses/" in _model_name:
_stripped_model_name = _model_name.replace("responses/", "")

View file

@ -1,3 +1,4 @@
import re
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, Union
@ -5,6 +6,20 @@ import httpx
from litellm import verbose_logger
_UNSAFE_SECRET_NAME_PATTERN = re.compile(r"(^|/)\.\.(/|$)|[\x00-\x1f\x7f-\x9f…

]")
def raise_if_unsafe_secret_name(secret_name: str) -> None:
"""
Validate a secret name before it is used by a secret manager integration.
Rejects ".." only as a path segment (bounded by "/" or the start/end of the
string, e.g. "../x", "x/..", or exactly ".."), not as a plain substring, so
names like "release-1.0..2" are not rejected.
"""
if _UNSAFE_SECRET_NAME_PATTERN.search(secret_name):
raise ValueError(f"Invalid secret_name {secret_name!r}")
class BaseSecretManager(ABC):
"""

View file

@ -4,6 +4,7 @@ from typing import Any, Dict, Optional, Union
from urllib.parse import quote
import httpx
import yaml
import litellm
from litellm._logging import verbose_logger
@ -15,7 +16,7 @@ from litellm.llms.custom_httpx.http_handler import (
)
from litellm.proxy._types import KeyManagementSystem
from .base_secret_manager import BaseSecretManager
from .base_secret_manager import BaseSecretManager, raise_if_unsafe_secret_name
from .main import str_to_bool
@ -125,8 +126,11 @@ class CyberArkSecretManager(BaseSecretManager):
"""
# In production, we'd check if the variable exists first
# For now, we'll attempt to create it and ignore if it already exists
raise_if_unsafe_secret_name(secret_name)
policy_url = f"{self.conjur_addr}/policies/{self.conjur_account}/policy/root"
policy_yaml = f"- !variable {secret_name}\n"
# Use a real YAML serializer to build the scalar safely.
quoted_name = yaml.safe_dump(secret_name, default_style='"').strip()
policy_yaml = f"- !variable {quoted_name}\n"
try:
client = _get_httpx_client(params={"ssl_verify": self.ssl_verify})

View file

@ -14,7 +14,7 @@ from litellm.llms.custom_httpx.http_handler import (
)
from litellm.proxy._types import KeyManagementSystem
from .base_secret_manager import BaseSecretManager
from .base_secret_manager import BaseSecretManager, raise_if_unsafe_secret_name
class HashicorpSecretManager(BaseSecretManager):
@ -220,6 +220,7 @@ class HashicorpSecretManager(BaseSecretManager):
- With custom mount: http://127.0.0.1:8200/v1/kv/data/mykey
- With path prefix: http://127.0.0.1:8200/v1/secret/data/myapp/mykey
"""
raise_if_unsafe_secret_name(secret_name)
resolved_namespace = self._sanitize_path_component(namespace if namespace is not None else self.vault_namespace)
resolved_mount = self._sanitize_path_component(mount_name if mount_name is not None else self.vault_mount_name)
if resolved_mount is None:

View file

@ -40,6 +40,15 @@ CACHE_SETTINGS_FIELDS: List[CacheSettingsField] = [
redis_type=None,
),
# Common fields for all Redis types
CacheSettingsField(
field_name="url",
field_type="String",
field_value=None,
field_description="Full Redis/Valkey connection URL (e.g. redis://:password@host:6379/1). When set, it takes precedence over Host, Port, Username, Password, and Database Index.",
field_default=None,
ui_field_name="Redis URL",
redis_type=None,
),
CacheSettingsField(
field_name="host",
field_type="String",
@ -58,6 +67,15 @@ CACHE_SETTINGS_FIELDS: List[CacheSettingsField] = [
ui_field_name="Port",
redis_type=None,
),
CacheSettingsField(
field_name="db",
field_type="Integer",
field_value=None,
field_description="Logical database index to isolate the cache (e.g. 1 for redis://host:6379/1)",
field_default=None,
ui_field_name="Database Index",
redis_type=None,
),
CacheSettingsField(
field_name="password",
field_type="String",

View file

@ -38,8 +38,16 @@ class MCPAuth(str, enum.Enum):
aws_sigv4 = "aws_sigv4"
token = "token"
oauth2_token_exchange = "oauth2_token_exchange"
true_passthrough = "true_passthrough"
oauth_delegate = "oauth_delegate"
# RFC 8693 default subject_token_type. A NULL column / omitted config key means
# "use this default"; it is applied at every egress build site via this single
# constant rather than a DB-level DEFAULT (Prisma writes explicit values on
# insert, so a column default would rarely apply anyway).
DEFAULT_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
# MCP Literals
MCPTransportType = Literal[MCPTransport.sse, MCPTransport.http, MCPTransport.stdio]
MCPSpecVersionType = Literal[MCPSpecVersion.nov_2024, MCPSpecVersion.mar_2025, MCPSpecVersion.jun_2025]
@ -54,6 +62,8 @@ MCPAuthType = Optional[
MCPAuth.aws_sigv4,
MCPAuth.token,
MCPAuth.oauth2_token_exchange,
MCPAuth.true_passthrough,
MCPAuth.oauth_delegate,
]
]
@ -122,18 +132,31 @@ class MCPCredentials(TypedDict, total=False):
audience: Optional[str]
"""
Target audience for OAuth 2.0 Token Exchange (RFC 8693)
Target audience for OAuth 2.0 Token Exchange (RFC 8693).
Legacy input shape: this setting has a dedicated ``audience`` column, which is
authoritative. A value sent here is accepted for back-compat (the pre-column
REST shape, released since 2026-05), lifted into the column on write, and
stripped from the stored blob. Prefer the top-level request field.
"""
token_exchange_endpoint: Optional[str]
"""
IDP token endpoint for OAuth 2.0 Token Exchange (RFC 8693)
IDP token endpoint for OAuth 2.0 Token Exchange (RFC 8693).
Legacy input shape: lifted into the dedicated ``token_exchange_endpoint``
column on write and stripped from the stored blob; the column is
authoritative. Prefer the top-level request field.
"""
subject_token_type: Optional[str]
"""
Subject token type for OAuth 2.0 Token Exchange (RFC 8693).
Default: urn:ietf:params:oauth:token-type:access_token
Default: DEFAULT_SUBJECT_TOKEN_TYPE (urn:ietf:params:oauth:token-type:access_token).
Legacy input shape: lifted into the dedicated ``subject_token_type`` column on
write and stripped from the stored blob; the column is authoritative. Prefer
the top-level request field.
"""
token_endpoint_auth_method: Optional[MCPTokenEndpointAuthMethod]
@ -147,6 +170,10 @@ class MCPCredentials(TypedDict, total=False):
Token exchange wire dialect: "rfc8693" (default, the standard token-exchange grant) or
"entra_obo" (Microsoft Entra On-Behalf-Of, the RFC 7523 jwt-bearer grant + requested_token_use
extension). Not a secret; stored unencrypted.
Legacy input shape: lifted into the dedicated ``token_exchange_profile`` column on
write and stripped from the stored blob; the column is authoritative. Prefer the
top-level request field.
"""

View file

@ -4,6 +4,7 @@ from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, ConfigDict
from litellm.types.mcp import (
DEFAULT_SUBJECT_TOKEN_TYPE,
MCPAuth,
MCPAuthType,
MCPTokenEndpointAuthMethod,
@ -68,7 +69,7 @@ class MCPServer(BaseModel):
# Token Exchange (OBO) fields
token_exchange_endpoint: Optional[str] = None
audience: Optional[str] = None
subject_token_type: str = "urn:ietf:params:oauth:token-type:access_token"
subject_token_type: str = DEFAULT_SUBJECT_TOKEN_TYPE
# Wire dialect: "rfc8693" (standard token-exchange grant) or "entra_obo" (Microsoft Entra
# On-Behalf-Of, the RFC 7523 jwt-bearer grant + requested_token_use extension)
token_exchange_profile: str = "rfc8693"
@ -151,6 +152,18 @@ class MCPServer(BaseModel):
"""True if this is an OAuth2 server that relies on per-user tokens (no client_credentials)."""
return self.auth_type == MCPAuth.oauth2 and not self.has_client_credentials
@property
def is_true_passthrough(self) -> bool:
"""True for the transparent-proxy mode: LiteLLM performs no admission auth and forwards the
client's ``Authorization`` to the upstream unchanged."""
return self.auth_type == MCPAuth.true_passthrough
@property
def is_oauth_delegate(self) -> bool:
"""True for the delegated-upstream-OAuth mode: LiteLLM still admits the caller (API key / SSO /
JWT) but forwards the caller's separate upstream ``Authorization`` unchanged, minting nothing."""
return self.auth_type == MCPAuth.oauth_delegate
@property
def requires_per_user_auth(self) -> bool:
"""
@ -166,6 +179,9 @@ class MCPServer(BaseModel):
if self.needs_user_oauth_token:
return True
if self.is_true_passthrough or self.is_oauth_delegate:
return True
# PAT passthrough: auth_type is none but extra_headers includes auth headers
if self.auth_type == MCPAuth.none and self.extra_headers:
auth_header_names = {"authorization", "x-api-key", "api-key", "apikey"}

View file

@ -3048,6 +3048,17 @@ class CustomPricingLiteLLMParams(BaseModel):
regional_processing_uplift_multiplier_eu: Optional[float] = None
regional_processing_uplift_multiplier_us: Optional[float] = None
@classmethod
def strip_custom_pricing_fields(cls, model_info: Dict[str, Any]) -> Dict[str, Any]:
"""Return a copy of ``model_info`` without per-deployment custom pricing fields.
Used when registering a deployment's info under the shared
``{provider}/{model}`` key in ``litellm.model_cost``, so one deployment's
pricing overrides don't pollute sibling deployments that share the same
backend model. Full pricing stays under the deployment's unique model id.
"""
return {k: v for k, v in model_info.items() if k not in cls.model_fields}
# Server-controlled fields that bound or drive an interceptor's agentic loop
# (depth, cycle fingerprints, ceiling, code-interpreter sandbox state). Listed

View file

@ -2619,8 +2619,9 @@ _CACHE_PRICING_FIELDS = (
def _resolve_builtin_model_cost_entry(key: str, provider: str) -> Optional[Dict[str, Any]]:
"""Best-effort lookup of a built-in ``model_cost`` entry for a custom key
whose shape ``get_model_info`` cannot resolve (double provider prefixes
like ``bedrock/bedrock/us.anthropic.claude-sonnet-4-6`` or region aliases).
whose shape ``get_model_info`` cannot resolve (repeated provider prefixes
like ``bedrock/bedrock/bedrock/us.anthropic.claude-sonnet-4-6`` or region
aliases).
Returns a copy of the matching entry so the caller can inherit its defaults
(most importantly cache pricing) without mutating the shared built-in.
@ -5052,9 +5053,9 @@ def _get_model_info_from_generalization(
candidates = [
potential_model_names["combined_model_name"],
model,
potential_model_names["split_model"],
potential_model_names["combined_stripped_model_name"],
potential_model_names["stripped_model_name"],
potential_model_names["split_model"],
]
for candidate in candidates:
generalized_info = match_fallback_generalization(candidate)
@ -5094,6 +5095,11 @@ def _get_potential_model_names(
stripped_model_name,
)
if custom_llm_provider in ("bedrock", "bedrock_converse"):
from litellm.llms.bedrock.common_utils import strip_bedrock_routing_prefix
split_model = strip_bedrock_routing_prefix(split_model)
return PotentialModelNamesAndCustomLLMProvider(
split_model=split_model,
combined_model_name=combined_model_name,
@ -5261,9 +5267,9 @@ def _get_model_info_helper(
Check if: (in order of specificity)
1. 'custom_llm_provider/model' in litellm.model_cost. Checks "groq/llama3-8b-8192" if model="llama3-8b-8192" and custom_llm_provider="groq"
2. 'model' in litellm.model_cost. Checks "gemini-1.5-pro-002" in litellm.model_cost if model="gemini-1.5-pro-002" and custom_llm_provider=None
3. 'combined_stripped_model_name' in litellm.model_cost. Checks if 'gemini/gemini-1.5-flash' in model map, if 'gemini/gemini-1.5-flash-001' given.
4. 'stripped_model_name' in litellm.model_cost. Checks if 'ft:gpt-3.5-turbo' in model map, if 'ft:gpt-3.5-turbo:my-org:custom_suffix:id' given.
5. 'split_model' in litellm.model_cost. Checks "llama3-8b-8192" in litellm.model_cost if model="groq/llama3-8b-8192"
3. 'split_model' in litellm.model_cost. Checks "au.anthropic.claude-opus-4-8" in litellm.model_cost if model="bedrock/au.anthropic.claude-opus-4-8"
4. 'combined_stripped_model_name' in litellm.model_cost. Checks if 'gemini/gemini-1.5-flash' in model map, if 'gemini/gemini-1.5-flash-001' given.
5. 'stripped_model_name' in litellm.model_cost. Checks if 'ft:gpt-3.5-turbo' in model map, if 'ft:gpt-3.5-turbo:my-org:custom_suffix:id' given.
"""
_model_info: Optional[Dict[str, Any]] = None
@ -5289,6 +5295,16 @@ def _get_model_info_helper(
custom_llm_provider=model_cost_custom_llm_provider,
):
_model_info = None
if _model_info is None:
_matched_key = _get_model_cost_key(split_model)
if _matched_key is not None:
key = _matched_key
_model_info = _get_model_info_from_model_cost(key=cast(str, key))
if not _check_provider_match(
model_info=_model_info,
custom_llm_provider=model_cost_custom_llm_provider,
):
_model_info = None
if _model_info is None:
_matched_key = _get_model_cost_key(combined_stripped_model_name)
if _matched_key is not None:
@ -5309,16 +5325,6 @@ def _get_model_info_helper(
custom_llm_provider=model_cost_custom_llm_provider,
):
_model_info = None
if _model_info is None:
_matched_key = _get_model_cost_key(split_model)
if _matched_key is not None:
key = _matched_key
_model_info = _get_model_info_from_model_cost(key=cast(str, key))
if not _check_provider_match(
model_info=_model_info,
custom_llm_provider=model_cost_custom_llm_provider,
):
_model_info = None
if _model_info is None:
generalization = _get_model_info_from_generalization(

View file

@ -23661,6 +23661,76 @@
"supports_system_messages": true,
"supports_tool_choice": true
},
"gpt-realtime-2.1": {
"cache_creation_input_audio_token_cost": 4e-07,
"cache_read_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": 128000,
"max_output_tokens": 32000,
"max_tokens": 32000,
"mode": "chat",
"output_cost_per_audio_token": 6.4e-05,
"output_cost_per_token": 2.4e-05,
"regional_processing_uplift_multiplier_eu": 1.1,
"regional_processing_uplift_multiplier_us": 1.1,
"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-2.1-mini": {
"cache_creation_input_audio_token_cost": 3e-07,
"cache_read_input_audio_token_cost": 3e-07,
"cache_read_input_token_cost": 6e-08,
"input_cost_per_audio_token": 1e-05,
"input_cost_per_image": 8e-07,
"input_cost_per_token": 6e-07,
"litellm_provider": "openai",
"max_input_tokens": 128000,
"max_output_tokens": 4096,
"max_tokens": 4096,
"mode": "chat",
"output_cost_per_audio_token": 2e-05,
"output_cost_per_token": 2.4e-06,
"regional_processing_uplift_multiplier_eu": 1.1,
"regional_processing_uplift_multiplier_us": 1.1,
"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,
@ -38233,6 +38303,48 @@
"supports_vision": true,
"supports_web_search": true
},
"xai/grok-4.5": {
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_above_200k_tokens": 1e-06,
"input_cost_per_token": 2e-06,
"input_cost_per_token_above_200k_tokens": 4e-06,
"litellm_provider": "xai",
"max_input_tokens": 500000,
"max_output_tokens": 500000,
"max_tokens": 500000,
"mode": "chat",
"output_cost_per_token": 6e-06,
"output_cost_per_token_above_200k_tokens": 1.2e-05,
"source": "https://docs.x.ai/docs/models",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true
},
"xai/grok-4.5-latest": {
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_above_200k_tokens": 1e-06,
"input_cost_per_token": 2e-06,
"input_cost_per_token_above_200k_tokens": 4e-06,
"litellm_provider": "xai",
"max_input_tokens": 500000,
"max_output_tokens": 500000,
"max_tokens": 500000,
"mode": "chat",
"output_cost_per_token": 6e-06,
"output_cost_per_token_above_200k_tokens": 1.2e-05,
"source": "https://docs.x.ai/docs/models",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true
},
"xai/grok-beta": {
"input_cost_per_token": 5e-06,
"litellm_provider": "xai",

View file

@ -1,6 +1,6 @@
[project]
name = "litellm"
version = "1.92.0"
version = "1.93.0"
description = "Library to easily interface with LLM API providers"
readme = "README.md"
requires-python = ">=3.10, <3.14"
@ -63,7 +63,7 @@ proxy = [
"azure-storage-blob>=12.28.0,<13.0",
"mcp>=1.26.0,<2.0",
"litellm-proxy-extras==0.4.75",
"litellm-enterprise==0.1.47",
"litellm-enterprise==0.1.48",
"RestrictedPython>=8.1,<9.0",
"rich>=13.9.4,<14.0",
"polars>=1.38.1,<2.0",
@ -279,7 +279,7 @@ members = ["enterprise", "litellm-proxy-extras"]
profile = "black"
[tool.commitizen]
version = "1.92.0"
version = "1.93.0"
version_files = [
"pyproject.toml:^version",
]

View file

@ -329,6 +329,12 @@ model LiteLLM_MCPServerTable {
token_url String?
registration_url String?
oauth2_flow String?
token_exchange_endpoint String?
// Named for the RFC 8693 "audience" token-exchange request parameter (that flow only).
// RFC 8707 resource indicators are a separate concept, named "resource" in the v2 egress types.
audience String?
subject_token_type String?
token_exchange_profile String?
allow_all_keys Boolean @default(false)
available_on_public_internet Boolean @default(true)
delegate_auth_to_upstream Boolean @default(false)

71
terraform/provider/.gitignore vendored Normal file
View file

@ -0,0 +1,71 @@
# Local .terraform directories
**/.terraform/*
test_litellm/*
# .tfstate files
*.tfstate
*.tfstate.*
# Crash log files
crash.log
crash.*.log
# Exclude all .tfvars files, which are likely to contain sensitive data
*.tfvars
!*.tfvars.example
# Ignore override files as they are usually used to override resources locally
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Ignore CLI configuration files
.terraformrc
terraform.rc
# Binary files
terraform-provider-litellm
# IDE and editor files
.idea/
*.swp
*.swo
.vscode/
*.sublime-workspace
*.sublime-project
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Go specific
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
go.work
# Dependency directories (remove the comment below to include it)
# vendor/
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
# Log files
*.log
# Environment files
.env

View file

@ -0,0 +1,81 @@
# Visit https://goreleaser.com for documentation on how to customize this
# behavior.
version: 2
before:
hooks:
# this is just an example and not a requirement for provider building/publishing
- go mod tidy
builds:
- env:
# goreleaser does not work with CGO, it could also complicate
# usage by users in CI/CD systems like HCP Terraform where
# they are unable to install libraries.
- CGO_ENABLED=0
mod_timestamp: '{{ .CommitTimestamp }}'
flags:
- -trimpath
ldflags:
- '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}'
goos:
- freebsd
- windows
- linux
- darwin
goarch:
- amd64
- '386'
- arm
- arm64
ignore:
# macOS doesn't support 32-bit anymore
- goos: darwin
goarch: '386'
# Windows ARM is uncommon for Terraform usage
- goos: windows
goarch: arm
- goos: windows
goarch: arm64
# FreeBSD ARM is rarely used
- goos: freebsd
goarch: arm
- goos: freebsd
goarch: arm64
# This builds the following key targets for Terraform users:
# - linux/amd64 (most common CI/CD)
# - linux/arm64 (Graviton, ARM-based CI)
# - linux/386 (legacy 32-bit systems)
# - linux/arm (Raspberry Pi, etc.)
# - darwin/amd64 (Intel Macs)
# - darwin/arm64 (Apple Silicon Macs)
# - windows/amd64 (Windows desktops)
# - freebsd/amd64, freebsd/386 (FreeBSD servers)
binary: '{{ .ProjectName }}_v{{ .Version }}'
archives:
- format: zip
name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}'
checksum:
extra_files:
- glob: 'terraform-registry-manifest.json'
name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json'
name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS'
algorithm: sha256
signs:
- artifacts: checksum
args:
# if you are using this in a GitHub action or some other automated pipeline, you
# need to pass the batch flag to indicate its not interactive.
- "--batch"
- "--local-user"
- "{{ .Env.GPG_FINGERPRINT }}" # set this environment variable for your signing key
- "--output"
- "${signature}"
- "--detach-sign"
- "${artifact}"
release:
extra_files:
- glob: 'terraform-registry-manifest.json'
name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json'
# If you want to manually examine the release before its live, uncomment this line:
# draft: true
changelog:
disable: true

View file

@ -0,0 +1,294 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixed
- **organization**: Send `PATCH` instead of `POST` to `/organization/update` and `/organization/member_update`, matching the methods the LiteLLM proxy serves; organization and organization member updates previously failed with a 405
### Changed
- The provider source of truth moved to `terraform/provider/` in [BerriAI/litellm](https://github.com/BerriAI/litellm); this repository is now a release mirror. CI in the monorepo statically audits every endpoint the provider calls against the proxy's OpenAPI schema on every change
## [0.2.2] - 2026-05-13
### Fixed
- **key**: Include `tags` in `UpdateKey` payload so tag changes on an existing `litellm_key` are applied on update instead of being silently dropped (#41)
## [0.2.1] - 2026-04-13
### Fixed
- **team, organization**: Use pointer types for `tpm_limit`, `rpm_limit`, and `max_budget` to prevent zero-value diffs on every `terraform plan` when these fields are not configured (#31)
## [0.2.0] - 2026-04-03
### ⚠️ Breaking Changes
#### `litellm_key`: API keys are no longer stored in Terraform state
**Why this change?** Storing raw API keys in Terraform state is a security risk — state files are often stored in S3, Terraform Cloud, or other backends where the key could be exposed even with encryption at rest. This release eliminates that risk entirely.
**What changed:**
- The `key` attribute is now **write-only** — available during `terraform apply` so you can pipe it to a secrets manager, but never persisted to state
- The resource ID has changed from the raw key value to its **SHA-256 hash (`token_id`)** — safe to store in state, cannot be used to authenticate
- **Requires Terraform 1.11+**
**Migration steps for existing `litellm_key` resources:**
1. Find the `token_id` for each key via the LiteLLM UI or `GET /key/info?key=<your-key>`
2. Remove the old resource from state:
```
terraform state rm litellm_key.example
```
3. Re-import using the token_id:
```
terraform import litellm_key.example <token_id>
```
> ⚠️ After upgrading, you cannot retrieve the raw key from state. Make sure you have the key value stored somewhere safe before migrating, or plan to rotate the key after re-import.
**Security best practice:** Since the key is only available during the initial `terraform apply`, pipe it directly to a secrets manager:
```hcl
resource "aws_ssm_parameter" "litellm_key" {
name = "/myapp/litellm-key"
type = "SecureString"
value = litellm_key.example.key
}
```
### Fixed
- **key**: API key is no longer stored in Terraform state. The `key` attribute is now write-only and `token_id` is used as the resource ID (#27)
- **model**: Handle eventual consistency in model reads post-create (#26)
## [0.1.2] - 2026-02-17
### Added
- **Documentation**: Added RELEASING.md with comprehensive release process documentation
- GPG key setup instructions
- Step-by-step release workflow
- Troubleshooting guide
- Security best practices
## [0.1.1] - 2026-02-11
### Added
- **New Model Modes**: Added support for `audio_speech` and `rerank` model modes
- `audio_speech`: For text-to-speech models (e.g., Gemini TTS, OpenAI TTS)
- `rerank`: For reranking/semantic ranking models (e.g., Cohere Rerank, Vertex AI Semantic Ranker)
### Fixed
- Implemented exponential backoff for credential reads
- Only include cost fields when explicitly set in model resource
- Added litellm_credential_name support
## [0.3.14] - 2025-08-24
### Added
- **Enhanced JSON Parsing**: Added support for JSON string parsing in `additional_litellm_params`
- JSON objects and arrays (starting with `{` or `[`) are now automatically parsed
- Maintains backward compatibility with existing string-to-type conversion
- Enables complex nested parameter configurations
- **Parameter Dropping Feature**: Added `additional_drop_params` special parameter
- Allows removal of unwanted parameters from final `litellm_params` before API submission
- Specified as JSON array string: `"additional_drop_params" = "[\"reasoningEffort\"]"`
- Useful for overriding or removing built-in parameters when needed
- **Enhanced Examples**: Updated `examples/model_additional_params.tf` with comprehensive JSON parsing examples
- Demonstrates all supported value types (boolean, integer, float, string, JSON objects/arrays)
- Includes real-world Azure model configuration with parameter dropping
- Shows both simple and complex use cases
### Changed
- **Documentation Enhancement**: Updated `docs/resources/model.md` with detailed JSON parsing documentation
- Added comprehensive explanation of conversion rules and behavior
- Included special `additional_drop_params` parameter documentation
- Enhanced examples showing all supported parameter types and JSON parsing capabilities
### Technical Details
- Enhanced parameter processing logic in `createOrUpdateModel()` function
- Added JSON detection and parsing for string values starting with `[` or `{`
- Implemented parameter filtering system for `additional_drop_params`
- Maintains full backward compatibility with existing configurations
## [0.3.13] - 2025-08-24
### Changed
- Documentation: Performed a documentation audit and improvements across resources and data-sources. Added missing argument references, clarified types/defaults, documented implementation behaviors (e.g., additional_litellm_params parsing and state-preservation), and added an `examples/` directory with runnable HCL examples (starting with `examples/model_additional_params.tf`).
- Docs: Updated `docs/resources/model.md` with missing fields (`vertex_*`, pixel/second cost fields, and `additional_litellm_params`) and added conversion rules and an example.
- Docs Index: Added references to the new `examples/` directory in `docs/index.md`.
## [0.3.12] - 2025-08-13
### Added
- **New AWS Parameters**: Added `aws_session_name` and `aws_role_name` to model resource for cross-account access scenarios
- Support for AWS session names in cross-account access configurations
- Support for AWS IAM role names for cross-account access
- Enhanced AWS Bedrock integration capabilities
### Changed
- **Documentation Overhaul**: Comprehensive update to all provider documentation
- Updated provider source references from `bitop/litellm` to `registry.terraform.io/ncecere/litellm`
- Consolidated all scattered example files into organized documentation structure
- Enhanced all resource documentation with multiple real-world examples
- Added comprehensive cross-resource integration examples
- **Vector Store Documentation**: Updated to reflect only officially supported LiteLLM providers
- Removed unsupported providers (Pinecone, Weaviate, Chroma, Qdrant, Milvus, FAISS)
- Added accurate examples for supported providers: AWS Bedrock Knowledge Bases, OpenAI Vector Stores, Azure Vector Stores, Vertex AI RAG Engine, PG Vector
- Updated provider-specific parameters with correct configurations
- Added references to official LiteLLM documentation
- **Project Organization**: Cleaned up project structure
- Removed scattered example files from root directory
- Consolidated all examples into comprehensive documentation
- Updated README.md to reflect current capabilities and structure
### Fixed
- Corrected vector store provider documentation to match LiteLLM's official capabilities
- Updated all documentation links and references for accuracy
## [0.3.11] - 2025-08-10
### Added
- **New Resource**: `litellm_credential` - Manage credentials for secure authentication
- Support for storing sensitive credential values (API keys, tokens, etc.)
- Non-sensitive credential information storage
- Model ID association for credentials
- Secure handling of sensitive data with Terraform's sensitive attribute
- **New Resource**: `litellm_vector_store` - Manage vector stores for embeddings and RAG
- Support for multiple vector store providers (Pinecone, Weaviate, Chroma, Qdrant, etc.)
- Integration with credential management for secure authentication
- Configurable metadata and provider-specific parameters
- Full CRUD operations for vector store lifecycle management
- **New Data Source**: `litellm_credential` - Retrieve information about existing credentials
- Read-only access to credential metadata (sensitive values excluded for security)
- Support for model ID filtering
- Cross-stack and cross-configuration referencing capabilities
- **New Data Source**: `litellm_vector_store` - Retrieve information about existing vector stores
- Complete vector store information retrieval
- Support for monitoring, validation, and cross-referencing use cases
- Metadata-based conditional logic support
- Enhanced API response handling for credential and vector store operations
- Comprehensive documentation and examples for new resources and data sources
- Example Terraform configurations for common use cases
### Changed
- Extended `utils.go` with specialized API response handlers for credentials and vector stores
- Updated provider configuration to include new resources and data sources
- Enhanced error handling for credential and vector store not found scenarios
## [0.3.10] - 2025-08-10
### Added
- **New Resource**: `litellm_mcp_server` - Manage MCP (Model Context Protocol) servers
- Support for HTTP, SSE, and stdio transport types
- Configurable authentication types (none, bearer, basic)
- MCP access groups for permission management
- Cost tracking configuration for MCP tools
- Environment variables and command arguments for stdio transport
- Health check status monitoring
- Comprehensive documentation and examples
### Changed
- Updated provider to support MCP server management functionality
- Enhanced API response handling for MCP-specific operations
## [0.3.9] - 2025-08-10
### Fixed
- Fixed issue where omitting `budget_duration` in key resource caused API error "Invalid duration format"
- Added missing `omitempty` JSON tag to `BudgetDuration` field in Key struct to prevent sending empty strings to API
## [0.3.8] - 2025-08-08
### Added
- Added `additional_litellm_params` field to model resource for custom parameters beyond standard ones
- Support for passing custom parameters like `drop_params`, `timeout`, `max_retries`, `organization`, etc.
- Automatic type conversion for string values to appropriate types (boolean, integer, float)
- Full backward compatibility with existing model configurations
- Comprehensive example demonstrating various use cases with different providers
## [0.3.7] - 2025-08-08
### Fixed
- Fixed issue where changing max_budget_in_team didn't update existing team members with new budget
- Added budget change detection using d.HasChange to update ALL existing members when budget changes
- Implemented tracking to avoid duplicate API calls for members already updated
- Enhanced debug logging for budget update operations
## [0.3.6] - 2025-08-08
### Fixed
- Fixed issue where models deleted from LiteLLM proxy caused terraform plan to fail instead of planning recreation
- Enhanced ErrorResponse struct to properly parse LiteLLM proxy error format with Detail field
- Improved isModelNotFoundError function to detect "not found on litellm proxy" messages in Detail.Error field
## [0.3.5] - 2025-08-08
### Fixed
- Fixed team member update behavior to use member_update endpoint instead of delete/re-add
- Restored team_member_permissions functionality to litellm_team resource
- Enhanced team resource with proper permissions management endpoints
## [0.3.0] - 2025-04-23
### Fixed
- Implemented retry mechanism with exponential backoff for model read operations
- Added detailed logging for retry attempts
- Improved error handling for "model not found" errors
## [0.2.9] - 2025-04-23
### Fixed
- Increased delay after model creation from 2 to 5 seconds to fix "model not found" errors
- Added logging to confirm delay is working properly
## [0.2.8] - 2025-04-23
### Fixed
- Added delay after model creation to fix "model not found" errors when the LiteLLM proxy hasn't fully registered the model yet
## [0.2.7] - 2025-04-23
### Fixed
- Fixed issue where `thinking_enabled` and `merge_reasoning_content_in_choices` values were not being preserved in state, causing Terraform to want to modify them on every run
## [0.2.6] - 2025-03-13
### Added
- Added new `merge_reasoning_content_in_choices` option to model resource
## [0.2.5] - 2025-03-13
### Fixed
- Fixed issue where `thinking_budget_tokens` was being added to models that don't have `thinking_enabled = true`
## [0.2.4] - 2025-03-13
### Added
- Added new `thinking` capability to model resource with configurable parameters:
- `thinking_enabled` - Boolean to enable/disable thinking capability (default: false)
- `thinking_budget_tokens` - Integer to set token budget for thinking (default: 1024)
## [0.2.2] - 2025-02-06
### Added
- Added new `reasoning_effort` parameter to model resource with values: "low", "medium", "high"
- Added "chat" mode to model resource
### Changed
- Updated model mode options to: "completion", "embedding", "image_generation", "chat", "moderation", "audio_transcription"
## [1.0.0] - 2024-01-17
### Added
- Initial release of the LiteLLM Terraform Provider
- Support for managing LiteLLM models
- Support for managing teams and team members
- Comprehensive documentation for all resources

View file

@ -0,0 +1,35 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source

View file

@ -0,0 +1,32 @@
HOSTNAME=registry.terraform.io
NAMESPACE=local
NAME=litellm
VERSION=1.0.0
OS_ARCH=darwin_amd64
default: install
build:
go build -o terraform-provider-${NAME}
install: build
mkdir -p ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION}/${OS_ARCH}
mv terraform-provider-${NAME} ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION}/${OS_ARCH}/terraform-provider-${NAME}_v${VERSION}
test:
go test ./...
fmt:
go fmt ./...
vet:
go vet ./...
lint:
golangci-lint run
clean:
rm -f terraform-provider-${NAME}
rm -rf ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION}
.PHONY: build install test fmt vet lint clean

View file

@ -0,0 +1,223 @@
# LiteLLM Terraform Provider
This Terraform provider allows you to manage LiteLLM resources through Infrastructure as Code. It provides support for managing models, teams, team members, and API keys via the LiteLLM REST API.
## Source of truth
This directory (`terraform/provider/` in [BerriAI/litellm](https://github.com/BerriAI/litellm)) is the source of truth for the provider. [BerriAI/terraform-provider-litellm](https://github.com/BerriAI/terraform-provider-litellm) is a thin release mirror that the public Terraform Registry ingests from; do not open PRs there. Changes land here, where CI builds the provider, runs its tests, and statically audits every endpoint the provider calls against the proxy's generated OpenAPI schema (`tools/endpointaudit/`), so the provider cannot drift from the LiteLLM API silently. Releases are published by mirroring this directory into the split repo and tagging it, which triggers the goreleaser workflow there (see `RELEASING.md`)
## Features
- Manage LiteLLM model configurations
- Associate models with specific teams
- Create and manage teams
- Configure team members and their permissions
- Set usage limits and budgets
- Control access to specific models
- Specify model modes (e.g., completion, embedding, image generation)
- Manage API keys with fine-grained controls
- Support for reasoning effort configuration in the model resource
## Requirements
- [Terraform](https://www.terraform.io/downloads.html) >= 0.13.x
- [Go](https://golang.org/doc/install) >= 1.16 (for development)
## Using the Provider
To use the LiteLLM provider in your Terraform configuration, you need to declare it in the <code>terraform</code> block:
```hcl
terraform {
required_providers {
litellm = {
source = "BerriAI/litellm"
version = "~> 0.1.1" #HERE UPDATE VERSION ACCORDINGLY
}
}
}
provider "litellm" {
api_base = var.litellm_api_base
api_key = var.litellm_api_key
}
```
Then, you can use the provider to manage LiteLLM resources. Here's an example of creating a model configuration:
```hcl
resource "litellm_model" "gpt4" {
model_name = "gpt-4-proxy"
custom_llm_provider = "openai"
model_api_key = var.openai_api_key
model_api_base = "https://api.openai.com/v1"
base_model = "gpt-4"
tier = "paid"
mode = "chat"
reasoning_effort = "medium" # Optional: "low", "medium", or "high"
input_cost_per_million_tokens = 30.0
output_cost_per_million_tokens = 60.0
}
```
For full details on the <code>litellm_model</code> resource, see the [model resource documentation](docs/resources/model.md).
Here's an example of creating an API key with various options:
```hcl
resource "litellm_key" "example_key" {
models = ["gpt-4", "claude-3.5-sonnet"]
max_budget = 100.0
user_id = "user123"
team_id = "team456"
max_parallel_requests = 5
tpm_limit = 1000
rpm_limit = 60
budget_duration = "monthly"
key_alias = "prod-key-1"
duration = "30d"
metadata = {
environment = "production"
}
allowed_cache_controls = ["no-cache", "max-age=3600"]
soft_budget = 80.0
aliases = {
"gpt-4" = "gpt4"
}
config = {
default_model = "gpt-4"
}
permissions = {
can_create_keys = "true"
}
model_max_budget = {
"gpt-4" = 50.0
}
model_rpm_limit = {
"claude-3.5-sonnet" = 30
}
model_tpm_limit = {
"gpt-4" = 500
}
guardrails = ["content_filter", "token_limit"]
blocked = false
tags = ["production", "api"]
}
```
The <code>litellm_key</code> resource supports the following options:
- <code>models</code>: List of allowed models for this key
- <code>max_budget</code>: Maximum budget for the key
- <code>user_id</code> and <code>team_id</code>: Associate the key with a user and team
- <code>max_parallel_requests</code>: Limit concurrent requests
- <code>tpm_limit</code> and <code>rpm_limit</code>: Set tokens and requests per minute limits
- <code>budget_duration</code>: Specify budget duration (e.g., "monthly", "weekly")
- <code>key_alias</code>: Set a friendly name for the key
- <code>duration</code>: Set the key's validity period
- <code>metadata</code>: Add custom metadata to the key
- <code>allowed_cache_controls</code>: Specify allowed cache control directives
- <code>soft_budget</code>: Set a soft budget limit
- <code>aliases</code>: Define model aliases
- <code>config</code>: Set configuration options
- <code>permissions</code>: Specify key permissions
- <code>model_max_budget</code>, <code>model_rpm_limit</code>, <code>model_tpm_limit</code>: Set per-model limits
- <code>guardrails</code>: Apply specific guardrails to the key
- <code>blocked</code>: Flag to block/unblock the key
- <code>tags</code>: Add tags for organization and filtering
For full details on the <code>litellm_key</code> resource, see the [key resource documentation](docs/resources/key.md).
### Available Resources
- <code>litellm_model</code>: Manage model configurations. [Documentation](docs/resources/model.md)
- <code>litellm_team</code>: Manage teams. [Documentation](docs/resources/team.md)
- <code>litellm_team_member</code>: Manage team members. [Documentation](docs/resources/team_member.md)
- <code>litellm_team_member_add</code>: Add multiple members to teams. [Documentation](docs/resources/team_member_add.md)
- <code>litellm_key</code>: Manage API keys. [Documentation](docs/resources/key.md)
- <code>litellm_mcp_server</code>: Manage MCP (Model Context Protocol) servers. [Documentation](docs/resources/mcp_server.md)
- <code>litellm_credential</code>: Manage credentials for secure authentication. [Documentation](docs/resources/credential.md)
- <code>litellm_vector_store</code>: Manage vector stores for embeddings and RAG. [Documentation](docs/resources/vector_store.md)
### Available Data Sources
- <code>litellm_credential</code>: Retrieve information about existing credentials. [Documentation](docs/data-sources/credential.md)
- <code>litellm_vector_store</code>: Retrieve information about existing vector stores. [Documentation](docs/data-sources/vector_store.md)
## Development
### Project Structure
The project is organized as follows:
```
terraform-provider-litellm/
├── litellm/
│ ├── provider.go
│ ├── resource_model.go
│ ├── resource_model_crud.go
│ ├── resource_team.go
│ ├── resource_team_member.go
│ ├── resource_key.go
│ ├── resource_key_utils.go
│ ├── types.go
│ └── utils.go
├── main.go
├── go.mod
├── go.sum
├── Makefile
└── ...
```
### Building the Provider
1. Clone the repository:
```sh
git clone https://github.com/your-username/terraform-provider-litellm.git
```
2. Enter the repository directory:
```sh
cd terraform-provider-litellm
```
3. Build and install the provider:
```sh
make install
```
### Development Commands
The Makefile provides several useful commands for development:
- `make build`: Builds the provider
- `make install`: Builds and installs the provider
- `make test`: Runs the test suite
- `make fmt`: Formats the code
- `make vet`: Runs go vet
- `make lint`: Runs golangci-lint
- `make clean`: Removes build artifacts and installed provider
### Testing
To run the tests:
```sh
make test
```
### Contributing
Contributions are welcome! Please read our [contributing guidelines](CONTRIBUTING.md) first.
## License
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
## Notes
- Always use environment variables or secure secret management solutions to handle sensitive information like API keys and AWS credentials.
- Refer to the comprehensive documentation in the `docs/` directory for detailed usage examples and configuration options.
- Make sure to keep your provider version updated for the latest features and bug fixes.
- The provider now supports AWS cross-account access with `aws_session_name` and `aws_role_name` parameters in the model resource.
- All example configurations have been consolidated into the documentation for better organization and maintenance.

View file

@ -0,0 +1,237 @@
# Release Process
This document describes the release process for the LiteLLM Terraform Provider.
## Overview
Releases are automated via GitHub Actions when a version tag is pushed. The workflow builds the provider for multiple platforms, signs the artifacts with GPG, and publishes them to GitHub Releases.
## Prerequisites
### GPG Key Setup (One-Time Setup for Repository Maintainers)
The Terraform Registry requires all providers to be signed with a GPG key. This must be configured before the first release.
#### 1. Generate a GPG Key
If you don't already have a GPG key for provider signing:
```bash
gpg --full-generate-key
```
Configuration:
- Key type: RSA and RSA (default)
- Key size: 4096 bits
- Expiration: No expiration (or set a long expiration period)
- Email: Use an email associated with your GitHub account
- Set a strong passphrase (or leave empty for CI/CD use)
#### 2. Export the GPG Key
```bash
# List your keys to get the key ID
gpg --list-secret-keys --keyid-format=long
# Example output:
# sec rsa4096/ABCD1234EFGH5678 2024-01-01 [SC]
# 1234567890ABCDEF1234567890ABCDEF12345678
# uid [ultimate] Your Name <your.email@example.com>
#
# The key ID is: ABCD1234EFGH5678
# The fingerprint is: 1234567890ABCDEF1234567890ABCDEF12345678
# Export the private key (ASCII-armored format)
gpg --armor --export-secret-keys ABCD1234EFGH5678
# Export the public key
gpg --armor --export ABCD1234EFGH5678
```
#### 3. Configure GitHub Repository Secrets
Add the following secrets to the repository at: **Settings → Secrets and variables → Actions → New repository secret**
| Secret Name | Description | Value |
|-------------|-------------|-------|
| `GPG_PRIVATE_KEY` | The GPG private key for signing releases | Full output from `gpg --armor --export-secret-keys` (including `-----BEGIN PGP PRIVATE KEY BLOCK-----` and `-----END PGP PRIVATE KEY BLOCK-----`) |
| `PASSPHRASE` | The passphrase for the GPG key | Your GPG key passphrase (leave empty if no passphrase was set) |
#### 4. Register Public Key with Terraform Registry
Before publishing to the Terraform Registry:
1. Go to https://registry.terraform.io/settings/gpg-keys
2. Click "Add a key"
3. Paste your public GPG key (output from `gpg --armor --export`)
4. Submit
**Note**: The public key fingerprint must match the key used to sign the provider releases.
## Release Steps
### 1. Prepare the Release
Before creating a release:
1. **Update CHANGELOG.md**
- Move items from `[Unreleased]` section to a new version section
- Follow [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format
- Use [Semantic Versioning](https://semver.org/spec/v2.0.0.html) for version numbers
- Include all notable changes since the last release
Example:
```markdown
## [0.1.2] - 2026-02-20
### Added
- New feature description
### Fixed
- Bug fix description
### Changed
- Changed behavior description
```
2. **Verify tests pass**
```bash
make test
```
3. **Verify the build works locally**
```bash
make build
```
4. **Land the changes in BerriAI/litellm**
Open a PR to `BerriAI/litellm` updating `terraform/provider/CHANGELOG.md` (and any source changes) and merge it. Note the merge commit SHA; the release workflow takes it as `git_ref`
### 2. Mirror and Tag via project-releaser
The provider source lives at `terraform/provider/` in `BerriAI/litellm`; `BerriAI/terraform-provider-litellm` is a thin release mirror. Do not commit or tag the mirror directly
1. Go to `BerriAI/project-releaser` > **Actions** > `Publish Terraform provider`
2. Click **Run workflow**:
- `git_ref`: full 40-char commit SHA from `BerriAI/litellm` to release from
- `provider_version`: the new version without the `v` prefix (e.g. `0.3.0`)
- `dry_run`: optional; validates without pushing
3. The workflow rsyncs `terraform/provider/` into the mirror repo, commits, and pushes tag `v<provider_version>`
4. The tag push triggers the mirror's `Release` workflow (goreleaser), which is gated by the `production-release` environment approval
**Important**:
- Tags must follow the format: `v<MAJOR>.<MINOR>.<PATCH>` (e.g., `v0.1.2`, `v1.0.0`)
- The workflow refuses to overwrite an existing tag; publish a new version instead
### 3. Monitor the Release Workflow
1. Go to: https://github.com/BerriAI/terraform-provider-litellm/actions
2. Find the "Release" workflow run for your tag
3. Monitor the progress and check for any errors
The workflow will:
- Check out the code
- Set up Go
- Import the GPG key
- Run `go mod tidy`
- Build binaries for multiple platforms (Linux, macOS, Windows, FreeBSD)
- Create archives and checksums
- Sign the checksums with GPG
- Create a GitHub release
- Upload all artifacts
### 4. Verify the Release
After the workflow completes successfully:
1. **Check the GitHub Release**
- Go to: https://github.com/BerriAI/terraform-provider-litellm/releases
- Verify the release was created with the correct version
- Confirm all artifacts are present:
- Binary archives for each platform
- SHA256SUMS file
- SHA256SUMS.sig (GPG signature)
- terraform-registry-manifest.json
2. **Verify the signature** (optional)
```bash
# Download the checksums and signature
wget https://github.com/BerriAI/terraform-provider-litellm/releases/download/v0.1.2/terraform-provider-litellm_0.1.2_SHA256SUMS
wget https://github.com/BerriAI/terraform-provider-litellm/releases/download/v0.1.2/terraform-provider-litellm_0.1.2_SHA256SUMS.sig
# Verify the signature
gpg --verify terraform-provider-litellm_0.1.2_SHA256SUMS.sig terraform-provider-litellm_0.1.2_SHA256SUMS
```
### 5. Publish to Terraform Registry (Optional)
If this provider is published to the Terraform Registry:
1. The registry should automatically detect the new release via the GitHub webhook
2. If not, you may need to manually trigger a sync on the Terraform Registry dashboard
3. Verify the new version appears at: https://registry.terraform.io/providers/BerriAI/litellm/latest
## Troubleshooting
### Release Workflow Fails with GPG Error
**Error**: `Input required and not supplied: gpg_private_key`
**Solution**:
- Verify that `GPG_PRIVATE_KEY` and `PASSPHRASE` secrets are configured in the repository
- Ensure the secrets are not expired
- Check that the secret names match exactly (case-sensitive)
### GoReleaser Signing Fails
**Error**: `gpg: signing failed: No secret key`
**Solution**:
- Verify the `GPG_PRIVATE_KEY` secret contains the complete private key block
- Ensure the passphrase is correct
- Check that the key hasn't expired: `gpg --list-keys`
### Build Fails
**Error**: Build errors during compilation
**Solution**:
- Run `make test` and `make build` locally first
- Ensure `go.mod` and `go.sum` are up to date
- Check that all dependencies are available
### Tag Already Exists
**Error**: The publish workflow refuses to push because the tag already exists on the mirror
**Solution**: Tags are immutable by design. Re-run the workflow with a new patch version instead of deleting or moving an existing tag
## Version Numbering
This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html):
- **MAJOR** version (1.0.0): Incompatible API changes
- **MINOR** version (0.1.0): New functionality in a backward-compatible manner
- **PATCH** version (0.0.1): Backward-compatible bug fixes
For pre-1.0 releases:
- Breaking changes may occur in minor versions
- Patch versions should only contain bug fixes
## Security Considerations
1. **Never commit private keys**: The GPG private key should only be stored as a GitHub secret
2. **Protect repository secrets**: Limit who has access to manage repository secrets
3. **Use a dedicated key**: Consider using a separate GPG key specifically for provider signing
4. **Key rotation**: If the GPG key is compromised, generate a new key, update secrets, and register the new public key with the Terraform Registry
5. **Passphrase**: Use a strong passphrase for the GPG key, or use a passphrase-less key specifically for CI/CD
## References
- [GoReleaser Documentation](https://goreleaser.com/)
- [Terraform Provider Publishing](https://www.terraform.io/docs/registry/providers/publishing.html)
- [HashiCorp GPG Signing Requirements](https://www.terraform.io/docs/registry/providers/publishing.html#signing-releases)
- [GitHub Actions Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets)
- [Semantic Versioning](https://semver.org/)
- [Keep a Changelog](https://keepachangelog.com/)

View file

@ -0,0 +1,153 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "litellm_credential Data Source - terraform-provider-litellm"
subcategory: ""
description: |-
Retrieves information about an existing LiteLLM credential.
---
# litellm_credential (Data Source)
Retrieves information about an existing LiteLLM credential. This data source allows you to reference credentials that were created outside of Terraform or in other Terraform configurations.
## Example Usage
```terraform
# Retrieve an existing credential by name
data "litellm_credential" "existing_openai" {
credential_name = "openai-production-key"
}
# Use the credential in a model resource
resource "litellm_model" "gpt4_with_existing_cred" {
model_name = "gpt-4-with-existing-cred"
custom_llm_provider = "openai"
base_model = "gpt-4"
tier = "paid"
mode = "chat"
# Reference the existing credential's info
additional_litellm_params = {
credential_name = data.litellm_credential.existing_openai.credential_name
}
}
```
## Example Usage with Model ID
```terraform
# Retrieve a credential associated with a specific model
data "litellm_credential" "model_specific_cred" {
credential_name = "claude-api-key"
model_id = "claude-3-sonnet"
}
# Use in a vector store
resource "litellm_vector_store" "knowledge_base" {
vector_store_name = "claude-knowledge-base"
custom_llm_provider = "anthropic"
litellm_credential_name = data.litellm_credential.model_specific_cred.credential_name
vector_store_description = "Knowledge base using Claude credentials"
}
```
## Example Usage for Cross-Reference
```terraform
# Get credential info to use in other resources
data "litellm_credential" "shared_cred" {
credential_name = "shared-api-key"
}
# Create multiple resources using the same credential
resource "litellm_vector_store" "store_1" {
vector_store_name = "store-1"
custom_llm_provider = "pinecone"
litellm_credential_name = data.litellm_credential.shared_cred.credential_name
vector_store_description = "First store using shared credential"
}
resource "litellm_vector_store" "store_2" {
vector_store_name = "store-2"
custom_llm_provider = "pinecone"
litellm_credential_name = data.litellm_credential.shared_cred.credential_name
vector_store_description = "Second store using shared credential"
}
```
## Argument Reference
The following arguments are supported:
* `credential_name` - (Required) Name of the credential to retrieve.
* `model_id` - (Optional) Model ID associated with this credential. Use this when the same credential name is used for different models.
## Attributes Reference
In addition to all arguments above, the following attributes are exported:
* `credential_info` - Map of additional non-sensitive information about the credential.
## Security Note
For security reasons, the `credential_values` (sensitive data like API keys) are not exposed through data sources. This prevents accidental exposure of sensitive information in Terraform plans and logs. If you need to access credential values, you should manage them through the resource directly or use external secret management systems.
## Common Use Cases
### 1. Cross-Stack References
Use data sources to reference credentials created in other Terraform configurations or stacks:
```terraform
data "litellm_credential" "shared_openai" {
credential_name = "openai-shared-key"
}
resource "litellm_model" "gpt4" {
model_name = "gpt-4-cross-stack"
custom_llm_provider = "openai"
base_model = "gpt-4"
additional_litellm_params = {
credential_reference = data.litellm_credential.shared_openai.credential_name
}
}
```
### 2. Conditional Logic
Use credential information for conditional resource creation:
```terraform
data "litellm_credential" "optional_cred" {
credential_name = var.credential_name
}
resource "litellm_vector_store" "conditional_store" {
count = length(data.litellm_credential.optional_cred.credential_info) > 0 ? 1 : 0
vector_store_name = "conditional-store"
custom_llm_provider = "weaviate"
litellm_credential_name = data.litellm_credential.optional_cred.credential_name
}
```
### 3. Validation and Verification
Verify that required credentials exist before creating dependent resources:
```terraform
data "litellm_credential" "required_cred" {
credential_name = "production-api-key"
}
# This will fail if the credential doesn't exist
resource "litellm_model" "production_model" {
model_name = "production-gpt-4"
custom_llm_provider = "openai"
base_model = "gpt-4"
additional_litellm_params = {
credential_name = data.litellm_credential.required_cred.credential_name
}
}

View file

@ -0,0 +1,225 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "litellm_vector_store Data Source - terraform-provider-litellm"
subcategory: ""
description: |-
Retrieves information about an existing LiteLLM vector store.
---
# litellm_vector_store (Data Source)
Retrieves information about an existing LiteLLM vector store. This data source allows you to reference vector stores that were created outside of Terraform or in other Terraform configurations.
## Example Usage
```terraform
# Retrieve an existing vector store by ID
data "litellm_vector_store" "existing_store" {
vector_store_id = "vs-12345"
}
# Use the vector store information in outputs
output "vector_store_info" {
value = {
name = data.litellm_vector_store.existing_store.vector_store_name
provider = data.litellm_vector_store.existing_store.custom_llm_provider
created_at = data.litellm_vector_store.existing_store.created_at
}
}
```
## Example Usage for Cross-Reference
```terraform
# Get vector store info to reference in other configurations
data "litellm_vector_store" "shared_store" {
vector_store_id = var.shared_vector_store_id
}
# Create a model that might use the same credential as the vector store
data "litellm_credential" "store_credential" {
credential_name = data.litellm_vector_store.shared_store.litellm_credential_name
}
resource "litellm_model" "embedding_model" {
model_name = "embedding-model"
custom_llm_provider = "openai"
base_model = "text-embedding-ada-002"
mode = "embedding"
additional_litellm_params = {
credential_name = data.litellm_credential.store_credential.credential_name
}
}
```
## Example Usage for Validation
```terraform
# Verify vector store exists and get its configuration
data "litellm_vector_store" "production_store" {
vector_store_id = "production-vector-store-id"
}
# Create resources only if the vector store is properly configured
resource "litellm_model" "rag_model" {
count = data.litellm_vector_store.production_store.custom_llm_provider == "pinecone" ? 1 : 0
model_name = "rag-enabled-model"
custom_llm_provider = "openai"
base_model = "gpt-4"
mode = "chat"
additional_litellm_params = {
vector_store_id = data.litellm_vector_store.production_store.vector_store_id
}
}
```
## Example Usage for Monitoring
```terraform
# Get vector store details for monitoring and alerting
data "litellm_vector_store" "monitored_stores" {
for_each = toset(var.vector_store_ids)
vector_store_id = each.value
}
# Output store information for monitoring systems
output "vector_store_status" {
value = {
for k, v in data.litellm_vector_store.monitored_stores : k => {
name = v.vector_store_name
provider = v.custom_llm_provider
created_at = v.created_at
updated_at = v.updated_at
metadata = v.vector_store_metadata
}
}
}
```
## Argument Reference
The following arguments are supported:
* `vector_store_id` - (Required) Unique identifier for the vector store to retrieve.
## Attributes Reference
In addition to all arguments above, the following attributes are exported:
* `vector_store_name` - Name of the vector store.
* `custom_llm_provider` - Custom LLM provider for the vector store.
* `vector_store_description` - Description of the vector store.
* `vector_store_metadata` - Map of metadata associated with the vector store.
* `litellm_credential_name` - Name of the LiteLLM credential used.
* `litellm_params` - Map of additional LiteLLM parameters.
* `created_at` - Timestamp when the vector store was created.
* `updated_at` - Timestamp when the vector store was last updated.
## Common Use Cases
### 1. Cross-Stack References
Reference vector stores created in other Terraform configurations:
```terraform
data "litellm_vector_store" "shared_knowledge_base" {
vector_store_id = var.knowledge_base_id
}
# Use the same credential for consistency
resource "litellm_model" "knowledge_model" {
model_name = "knowledge-retrieval-model"
custom_llm_provider = "openai"
base_model = "gpt-4"
additional_litellm_params = {
vector_store_credential = data.litellm_vector_store.shared_knowledge_base.litellm_credential_name
}
}
```
### 2. Configuration Validation
Validate vector store configuration before creating dependent resources:
```terraform
data "litellm_vector_store" "target_store" {
vector_store_id = var.target_vector_store_id
}
# Ensure the vector store uses the expected provider
locals {
is_pinecone_store = data.litellm_vector_store.target_store.custom_llm_provider == "pinecone"
}
resource "litellm_model" "pinecone_optimized_model" {
count = local.is_pinecone_store ? 1 : 0
model_name = "pinecone-optimized"
custom_llm_provider = "openai"
base_model = "text-embedding-ada-002"
mode = "embedding"
}
```
### 3. Metadata-Based Logic
Use vector store metadata for conditional resource creation:
```terraform
data "litellm_vector_store" "environment_store" {
vector_store_id = var.vector_store_id
}
# Create different resources based on environment metadata
resource "litellm_model" "production_model" {
count = lookup(data.litellm_vector_store.environment_store.vector_store_metadata, "environment", "") == "production" ? 1 : 0
model_name = "production-rag-model"
custom_llm_provider = "openai"
base_model = "gpt-4"
mode = "chat"
}
resource "litellm_model" "development_model" {
count = lookup(data.litellm_vector_store.environment_store.vector_store_metadata, "environment", "") == "development" ? 1 : 0
model_name = "development-rag-model"
custom_llm_provider = "openai"
base_model = "gpt-3.5-turbo"
mode = "chat"
}
```
### 4. Audit and Compliance
Retrieve vector store information for audit and compliance reporting:
```terraform
data "litellm_vector_store" "compliance_stores" {
for_each = toset(var.compliance_vector_store_ids)
vector_store_id = each.value
}
# Generate compliance report
output "compliance_report" {
value = {
for k, v in data.litellm_vector_store.compliance_stores : k => {
store_name = v.vector_store_name
provider = v.custom_llm_provider
credential = v.litellm_credential_name
created_date = v.created_at
last_updated = v.updated_at
metadata = v.vector_store_metadata
}
}
}
```
## Notes
* Vector store IDs are unique identifiers assigned by the LiteLLM system.
* The data source will fail if the specified vector store ID does not exist.
* All computed attributes reflect the current state of the vector store in the LiteLLM system.
* Use this data source to integrate with existing vector stores or to reference stores created outside of Terraform.

Some files were not shown because too many files have changed in this diff Show more