Merge pull request #36286 from BerriAI/litellm_internal_staging

chore(ci): promote internal staging to main
This commit is contained in:
yuneng-jiang 2026-08-08 13:11:47 -07:00 committed by GitHub
commit 10798ca3d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
209 changed files with 14239 additions and 2962 deletions

View file

@ -136,13 +136,6 @@ test_paths:
- tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py
dockerfiles:
- reason: >-
The componentized images the microservices chart deploys are built by no job; wiring both into
the scan workflow costs a full image build each and is deferred to a change that prices the
whole set
paths:
- backend/Dockerfile
- gateway/Dockerfile
- reason: >-
The dashboard container is a static Next.js export served by nginx, and the dashboard build
and lint workflows already exercise that output, so building the image adds no signal about it

View file

@ -13,6 +13,33 @@ How it solves it:
- <blah>
- ...
## User Flow
<!-- Two ordered lists, Before and After, walking the same end user through the same task, written strictly from that user's seat
Read the linked issue, ticket, or customer thread first so the flow reflects the real application and the routes its users actually hit; don't invent a generic scenario
Lead each list with one plain sentence saying where the flow fails (Before) or succeeds (After), then number the steps
Every step is something the user does or observes: the HTTP method and full URL they hit, what they sent, and what visibly came back (status code, error text, the shape of an ID). UI steps name the page URL and what is on screen
No LiteLLM internals: never name functions, files, DB tables, config classes, hooks, callbacks, or code paths. "The upload hands back an ID that looks like OpenAI's own `file-abc123` instead of the scrambled one the gateway returned" is right, "no managed-file row was registered" is wrong
Keep the two lists step-for-step identical until they diverge, so the changed step is obvious
If the bug had a security or authorization consequence, end each list with what another user could or could no longer do
Regenerate this section whenever new commits change the PR's behavior, so it never describes an older revision
Example:
Before: a developer whose app streams chat completions gets no token counts back, so their cost dashboard reads zero
1. They send POST https://litellm-domain/v1/chat/completions with `"stream": true` and no `stream_options`
2. The last SSE chunk arrives with `"usage": null`, so their app records 0 prompt and 0 completion tokens
3. They open https://litellm-domain/ui/?page=logs and see the request logged at $0 spend
After: the same request comes back with real token counts, so the dashboard shows real spend
1. The proxy admin sets `always_include_stream_usage: true` and restarts the proxy
2. The developer sends the same POST https://litellm-domain/v1/chat/completions with `"stream": true` and no `stream_options`
3. The last SSE chunk now carries a `usage` object with real prompt and completion token counts
4. https://litellm-domain/ui/?page=logs shows that request at non-zero spend
-->
## Relevant issues
<!-- e.g., "Fixes #000" -->

View file

@ -10,6 +10,10 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check-sync:
name: Verify schema.prisma copies match root

View file

@ -2,18 +2,19 @@ name: Check UI API Types Sync
on:
pull_request:
paths:
- "litellm/proxy/**"
- "litellm/types/**"
- "ui/litellm-dashboard/src/lib/http/schema.d.ts"
- "ui/litellm-dashboard/scripts/gen-api-types.mjs"
- "ui/litellm-dashboard/package.json"
- "ui/litellm-dashboard/package-lock.json"
- ".github/workflows/check-ui-api-types.yml"
branches:
- main
- litellm_internal_staging
- litellm_oss_staging
- "litellm_**"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check-sync:
name: Verify schema.d.ts matches the proxy OpenAPI spec
@ -24,18 +25,39 @@ jobs:
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
fetch-depth: 2
- name: Detect changes that can affect the generated types
id: changes
run: |
set -euo pipefail
if ! base="$(git rev-parse --verify --quiet HEAD^2 >/dev/null && git rev-parse HEAD^1)"; then
echo "Not a pull request merge commit, running the full check."
echo "relevant=true" >> "$GITHUB_OUTPUT"
exit 0
fi
files="$(git diff --name-only "$base" HEAD)"
if grep -Eq '^(litellm/(proxy|types)/|ui/litellm-dashboard/(src/lib/http/schema\.d\.ts|scripts/gen-api-types\.mjs|package(-lock)?\.json)$|\.github/workflows/check-ui-api-types\.yml$)' <<< "$files"; then
echo "relevant=true" >> "$GITHUB_OUTPUT"
else
echo "No proxy, types or generator changes in this pull request, nothing to verify."
echo "relevant=false" >> "$GITHUB_OUTPUT"
fi
- name: Set up Python
if: steps.changes.outputs.relevant == 'true'
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Set up uv
if: steps.changes.outputs.relevant == 'true'
uses: ./.github/actions/setup-uv-with-retries
with:
version: "0.10.9"
- name: Cache uv dependencies
if: steps.changes.outputs.relevant == 'true'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
@ -46,14 +68,17 @@ jobs:
${{ runner.os }}-uv-
- name: Install backend dependencies
if: steps.changes.outputs.relevant == 'true'
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
if: steps.changes.outputs.relevant == 'true'
env:
PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache
run: uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
- name: Set up Node.js
if: steps.changes.outputs.relevant == 'true'
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: ui/litellm-dashboard/.nvmrc
@ -61,16 +86,19 @@ jobs:
cache-dependency-path: ui/litellm-dashboard/package-lock.json
- name: Install dashboard dependencies
if: steps.changes.outputs.relevant == 'true'
working-directory: ui/litellm-dashboard
run: npm ci
- name: Regenerate types from the live spec
if: steps.changes.outputs.relevant == 'true'
working-directory: ui/litellm-dashboard
env:
LITELLM_PYTHON: "uv run --no-sync python"
run: npm run gen:api
- name: Fail if types are stale
if: steps.changes.outputs.relevant == 'true'
run: |
if ! git diff --exit-code -- ui/litellm-dashboard/src/lib/http/schema.d.ts; then
echo "::error file=ui/litellm-dashboard/src/lib/http/schema.d.ts::Generated API types are out of sync with the proxy OpenAPI spec."

View file

@ -14,6 +14,10 @@ on:
permissions:
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
lint-pr-title:
name: Validate PR title

View file

@ -15,6 +15,10 @@ on:
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
guard:
name: Block fork dependency changes

View file

@ -9,6 +9,10 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
unit-test:
runs-on: ubuntu-latest

View file

@ -12,6 +12,11 @@ on:
- docker/Dockerfile.non_root
- migrations/Dockerfile
- migrations/run.py
- gateway/Dockerfile
- gateway/main.py
- backend/Dockerfile
- backend/main.py
- docker/component_entrypoint.sh
- litellm-proxy-extras/**
- tests/proxy_migration_tests/**
- uv.lock
@ -147,3 +152,63 @@ jobs:
run: |
python -m pip install "pytest==9.0.3"
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py -v
gateway-image:
name: gateway-image
runs-on: ubuntu-latest
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Build gateway image
run: docker build -f gateway/Dockerfile -t litellm-gateway-scan:${{ github.sha }} .
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Verify the gateway serves offline as a non-root uid
env:
LITELLM_IMAGE: litellm-gateway-scan:${{ github.sha }}
LITELLM_COMPONENT_PORT: "4000"
run: |
python -m pip install "pytest==9.0.3"
python -m pytest tests/proxy_migration_tests/test_component_image_serves_offline.py -v
backend-image:
name: backend-image
runs-on: ubuntu-latest
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Build backend image
run: docker build -f backend/Dockerfile -t litellm-backend-scan:${{ github.sha }} .
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Verify the backend serves offline as a non-root uid
env:
LITELLM_IMAGE: litellm-backend-scan:${{ github.sha }}
LITELLM_COMPONENT_PORT: "4001"
run: |
python -m pip install "pytest==9.0.3"
python -m pytest tests/proxy_migration_tests/test_component_image_serves_offline.py -v

View file

@ -11,6 +11,10 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
lint:
runs-on: ubuntu-latest

View file

@ -10,6 +10,10 @@ on:
- litellm_oss_staging
- "litellm_**"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build-ui:
runs-on: ubuntu-latest

View file

@ -10,6 +10,10 @@ on:
- litellm_oss_staging
- "litellm_**"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
frontend-lint:
runs-on: ubuntu-latest

View file

@ -11,6 +11,10 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
runs-on: ubuntu-latest

View file

@ -11,6 +11,10 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
validate-model-prices-json:
runs-on: ubuntu-latest

View file

@ -38,12 +38,15 @@ jobs:
tests/test_litellm/proxy/google_endpoints
tests/test_litellm/proxy/openai_files_endpoint
tests/test_litellm/proxy/batches_endpoints
tests/test_litellm/proxy/fine_tuning_endpoints
tests/test_litellm/proxy/vector_store_files_endpoints
tests/test_litellm/proxy/video_endpoints
tests/test_litellm/proxy/response_api_endpoints
tests/test_litellm/proxy/image_endpoints
tests/test_litellm/proxy/vector_store_endpoints
tests/test_litellm/proxy/agent_endpoints
tests/test_litellm/proxy/a2a
tests/test_litellm/proxy/credential_endpoints
tests/test_litellm/proxy/discovery_endpoints
tests/test_litellm/proxy/health_endpoints
tests/test_litellm/proxy/shutdown

View file

@ -9,7 +9,7 @@ Don't assume that the existing code is correct or the right way of doing things
- easy to maintain/change
- modern
In that order of importance
In descending order of importance
When adding new features, add meaningful tests. Don't add tests that don't check anything substantial and is there just to make the code coverage pass. Yes, code coverage is important, but I'd rather have no signal whether the code is working than tests that don't fail when code is broken. The goal is to have tests that would fail before the feature was added/if the code was mutated in a way that breaks the feature and succeed only when the feature is fully working. I should run mutation testing and see > 90% kill rate
@ -41,7 +41,7 @@ Python max line length is 120, not 88
When you fix violations gated by `ruff-strict-budget.json`, `type-discipline-budget.json`, or `basedpyright-code-budget.json`, run `make lint-budget-update` and commit the lowered limits so the ceilings ratchet down instead of leaving stale headroom. It measures the working tree, so it must contain exactly the fixes you're committing
`make pre-commit` saves its complete output to a log file in .git (overwriting previous pre-commit logs) and prints that path as its first and last output lines. To inspect a run, read or grep that log instead of re-running the multi-minute checks just to see a different slice
`make check` (f.k.a. `make pre-commit`, which still works identically as an alias) saves its complete output to a log file in .git (overwriting previous logs) and prints that path as its first and last output lines. To inspect a run, read or grep that log instead of re-running the multi-minute checks just to see a different slice
If you're trying to create a new function that relies on untyped stuff, instead of adding more Any's and pushing `reportAny` / `reportExplicitAny` closer to their basedpyright ceilings, just validate it in the caller with Pydantic (a model or `TypeAdapter` that returns the typed thing or raises will do) and then pass the now typed variable in

View file

@ -8,7 +8,7 @@
lint-basedpyright lint-e2e-basedpyright lint-basedpyright-budget-update lint-type-discipline lint-type-discipline-budget-update \
lint-ruff-budget lint-ruff-budget-update lint-budget-update lint-gate \
install-dev install-proxy-dev install-test-deps install-hooks \
install-helm-unittest check-circular-imports check-import-safety pre-commit \
install-helm-unittest check-circular-imports check-import-safety check pre-commit \
lint-install lint-fetch-base bootstrap
# Default target
@ -22,7 +22,8 @@ help:
@echo " make install-test-deps - Install the full local test environment"
@echo " make install-helm-unittest - Install helm unittest plugin"
@echo " make install-hooks - Install git hooks (Conventional Commits + Branches)"
@echo " make pre-commit - Run CI-equivalent lint on staged files (run before committing)"
@echo " make check - Run CI-equivalent lint on staged files, or on the diff vs the base branch when nothing is staged"
@echo " make pre-commit - Legacy alias for make check"
@echo " make format - Apply ruff format code formatting"
@echo " make format-check - Check ruff format code formatting (matches CI)"
@echo " make lint - Run all linting (Ruff, basedpyright, format check, circular imports, import safety)"
@ -236,13 +237,20 @@ lint-checks: lint-format-check-changed lint-ruff lint-gate lint-type-discipline
# Faster linting for local development (only checks changed code)
lint-dev: lint-format-changed check-circular-imports check-import-safety
# Run the gating CI checks against your staged files right before committing. Mirrors
# Run the gating CI checks against your changes. Scopes to staged files when anything
# is staged (warning about changed files left unstaged); with nothing staged it falls
# back to the working tree's diff against the merge base with the base branch, so a
# fresh merge commit or an unstaged working tree still gets checked. Mirrors
# test-linting.yml (Python), test-litellm-ui-build.yml's frontend-lint (dashboard), and
# check-ui-api-types.yml (API-type drift), skipping any whose files you didn't stage.
# check-ui-api-types.yml (API-type drift), skipping any whose files aren't in scope.
# Not auto-installed as a git hook so it never slows an unrelated human commit.
pre-commit: bootstrap
check: bootstrap
./scripts/pre_commit_lint.sh
pre-commit:
@echo "make pre-commit is a legacy alias; use make check" >&2
@$(MAKE) check
# Testing targets
test: install-test-deps
$(UV_RUN) pytest tests/

View file

@ -1,9 +1,9 @@
{
"reportAny": {
"limit": 28842
"limit": 27731
},
"reportArgumentType": {
"limit": 2634
"limit": 2626
},
"reportAssignmentType": {
"limit": 329
@ -12,7 +12,7 @@
"limit": 514
},
"reportCallIssue": {
"limit": 117
"limit": 116
},
"reportConstantRedefinition": {
"limit": 40
@ -24,7 +24,7 @@
"limit": 19
},
"reportExplicitAny": {
"limit": 9103
"limit": 8807
},
"reportFunctionMemberAccess": {
"limit": 7
@ -54,10 +54,10 @@
"limit": 0
},
"reportMissingParameterType": {
"limit": 5843
"limit": 5835
},
"reportMissingTypeArgument": {
"limit": 15816
"limit": 15790
},
"reportMissingTypeStubs": {
"limit": 40
@ -72,7 +72,7 @@
"limit": 0
},
"reportOptionalMemberAccess": {
"limit": 1078
"limit": 1077
},
"reportOptionalOperand": {
"limit": 0
@ -84,34 +84,34 @@
"limit": 56
},
"reportPrivateUsage": {
"limit": 1825
"limit": 1824
},
"reportRedeclaration": {
"limit": 8
},
"reportReturnType": {
"limit": 218
"limit": 217
},
"reportTypedDictNotRequiredAccess": {
"limit": 27
"limit": 26
},
"reportUndefinedVariable": {
"limit": 0
},
"reportUnknownArgumentType": {
"limit": 45110
"limit": 45063
},
"reportUnknownLambdaType": {
"limit": 113
},
"reportUnknownMemberType": {
"limit": 39838
"limit": 39773
},
"reportUnknownParameterType": {
"limit": 20237
"limit": 20207
},
"reportUnknownVariableType": {
"limit": 31383
"limit": 31281
},
"reportUnnecessaryCast": {
"limit": 122
@ -123,7 +123,7 @@
"limit": 5
},
"reportUnnecessaryIsInstance": {
"limit": 864
"limit": 862
},
"reportUntypedBaseClass": {
"limit": 0

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.proxy._types import *
from litellm.proxy.auth.auth_checks import delete_cached_project_object
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management_endpoints.common_utils import _set_object_metadata_field
from litellm.proxy.management_helpers.utils import (
@ -514,6 +515,7 @@ async def update_project(
litellm_proxy_admin_name,
premium_user,
prisma_client,
user_api_key_cache,
)
try:
@ -672,6 +674,11 @@ async def update_project(
include={"litellm_budget_table": True, "object_permission": True},
)
await delete_cached_project_object(
project_id=data.project_id,
user_api_key_cache=user_api_key_cache,
)
return updated_project
except Exception as e:
verbose_proxy_logger.exception(
@ -710,7 +717,7 @@ async def delete_project(
}'
```
"""
from litellm.proxy.proxy_server import premium_user, prisma_client
from litellm.proxy.proxy_server import premium_user, prisma_client, user_api_key_cache
try:
if not premium_user:
@ -773,6 +780,11 @@ async def delete_project(
prisma_models.LiteLLM_ProjectTable | None
) = await prisma_client.db.litellm_projecttable.delete(where={"project_id": project_id})
await delete_cached_project_object(
project_id=project_id,
user_api_key_cache=user_api_key_cache,
)
deleted_projects.append(deleted_project)
return deleted_projects

View file

@ -0,0 +1 @@
ALTER TABLE "LiteLLM_AutoRouterSession" ADD COLUMN IF NOT EXISTS "tier_turns" JSONB NOT NULL DEFAULT '{}';

View file

@ -1439,6 +1439,7 @@ model LiteLLM_AutoRouterSession {
total_tokens BigInt @default(0)
spend Float @default(0)
saved_spend Float @default(0)
tier_turns Json @default("{}")
@@id([api_key, session_id, router_name])
@@index([last_turn_at], map: "idx_autorouter_session_last_turn")

View file

@ -13,7 +13,6 @@ import asyncio
import datetime
import uuid
from collections.abc import AsyncIterator, Coroutine
from http.cookiejar import DefaultCookiePolicy
from typing import TYPE_CHECKING, Any, Final, Optional, cast
import litellm
@ -80,8 +79,6 @@ from litellm.a2a_protocol.exceptions import A2ALocalhostURLError
# Use our custom resolver instead of the default A2A SDK resolver
A2ACardResolver: Final = LiteLLMA2ACardResolver
_BLOCK_ALL_COOKIES: Final = DefaultCookiePolicy(allowed_domains=())
def _set_usage_on_logging_obj(
kwargs: dict[str, Any],
@ -770,7 +767,6 @@ async def create_a2a_client(
params={"timeout": timeout},
)
httpx_client: Final = _async_handler.client
httpx_client.cookies.jar.set_policy(_BLOCK_ALL_COOKIES)
if extra_headers:
verbose_proxy_logger.debug("A2A client created with extra_headers=%s", list(extra_headers.keys()))

View file

@ -6,16 +6,33 @@ This module provides fake streaming by converting non-streaming responses into s
"""
import asyncio
from collections.abc import AsyncIterator
from typing import Any, Final, cast
from collections.abc import AsyncIterator, Mapping, Sequence
from typing import Any, Final, Protocol, cast, runtime_checkable
from uuid import uuid4
from pydantic import TypeAdapter
from litellm._logging import verbose_logger
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
get_async_httpx_client,
)
_ANY_KEY_DICT_ADAPTER: Final = TypeAdapter(dict[object, object])
_STR_KEY_DICT_ADAPTER: Final = TypeAdapter(dict[str, object])
_LIST_ADAPTER: Final = TypeAdapter(list[object])
_TEXT_ADAPTER: Final = TypeAdapter(str)
@runtime_checkable
class _SupportsModelDump(Protocol):
def model_dump(self, *, mode: str, exclude_none: bool) -> Mapping[str, object]: ...
@runtime_checkable
class _SupportsPydanticDict(Protocol):
def dict(self, *, exclude_none: bool) -> Mapping[str, object]: ...
class PydanticAITransformation:
"""
@ -28,7 +45,7 @@ class PydanticAITransformation:
"""
@staticmethod
def _remove_none_values(obj: Any) -> Any:
def _remove_none_values(obj: object) -> object:
"""
Recursively remove None values from a dict/list structure.
@ -42,14 +59,18 @@ class PydanticAITransformation:
Cleaned object with None values removed
"""
if isinstance(obj, dict):
return {k: PydanticAITransformation._remove_none_values(v) for k, v in obj.items() if v is not None}
typed_dict: Final = _ANY_KEY_DICT_ADAPTER.validate_python(obj)
return {k: PydanticAITransformation._remove_none_values(v) for k, v in typed_dict.items() if v is not None}
elif isinstance(obj, list):
return [PydanticAITransformation._remove_none_values(item) for item in obj if item is not None]
typed_list: Final = _LIST_ADAPTER.validate_python(obj)
return [PydanticAITransformation._remove_none_values(item) for item in typed_list if item is not None]
else:
return obj
@staticmethod
def _params_to_dict(params: Any) -> dict[str, Any]:
def _params_to_dict(
params: "_SupportsModelDump | _SupportsPydanticDict | Mapping[str, object]",
) -> Mapping[str, object]:
"""
Convert params to a dict, handling Pydantic models.
@ -59,10 +80,10 @@ class PydanticAITransformation:
Returns:
Dict representation of params
"""
if hasattr(params, "model_dump"):
if isinstance(params, _SupportsModelDump):
# Pydantic v2 model
return params.model_dump(mode="python", exclude_none=True)
elif hasattr(params, "dict"):
elif isinstance(params, _SupportsPydanticDict):
# Pydantic v1 model
return params.dict(exclude_none=True)
elif isinstance(params, dict):
@ -75,12 +96,12 @@ class PydanticAITransformation:
async def _poll_for_completion(
client: AsyncHTTPHandler,
endpoint: str,
task_id: str,
task_id: object,
request_id: str,
max_attempts: int = 30,
poll_interval: float = 0.5,
agent_extra_headers: dict[str, str] | None = None,
) -> dict[str, Any]:
) -> dict[str, object]:
"""
Poll for task completion using tasks/get method.
@ -112,10 +133,10 @@ class PydanticAITransformation:
},
)
response.raise_for_status()
poll_data = response.json()
poll_data = _STR_KEY_DICT_ADAPTER.validate_python(response.json())
result = poll_data.get("result", {})
status = result.get("status", {})
result = _STR_KEY_DICT_ADAPTER.validate_python(poll_data.get("result", {}))
status = _STR_KEY_DICT_ADAPTER.validate_python(result.get("status", {}))
state = status.get("state", "")
verbose_logger.debug("Pydantic AI: Poll attempt %s/%s, state=%s", attempt + 1, max_attempts, state)
@ -133,10 +154,10 @@ class PydanticAITransformation:
async def _send_and_poll_raw(
api_base: str,
request_id: str,
params: Any,
params: "_SupportsModelDump | _SupportsPydanticDict | Mapping[str, object]",
timeout: float = 60.0,
agent_extra_headers: dict[str, str] | None = None,
) -> dict[str, Any]:
) -> dict[str, object]:
"""
Send a request to Pydantic AI agent and return the raw task response.
@ -153,14 +174,16 @@ class PydanticAITransformation:
Raw Pydantic AI task response (with history/artifacts)
"""
# Convert params to dict if it's a Pydantic model
params_dict = PydanticAITransformation._params_to_dict(params)
# Remove None values - FastA2A doesn't accept null for optional fields
params_dict = PydanticAITransformation._remove_none_values(params_dict)
params_dict: Final = _ANY_KEY_DICT_ADAPTER.validate_python(
PydanticAITransformation._remove_none_values(PydanticAITransformation._params_to_dict(params))
)
# Ensure the message has 'kind': 'message' as required by FastA2A/Pydantic AI
if "message" in params_dict:
params_dict["message"]["kind"] = "message"
message_value: Final = _ANY_KEY_DICT_ADAPTER.validate_python(params_dict["message"])
message_value["kind"] = "message"
params_dict["message"] = message_value
# Build A2A JSON-RPC request using message/send method for FastA2A compatibility
a2a_request: Final = {
@ -189,11 +212,11 @@ class PydanticAITransformation:
},
)
response.raise_for_status()
response_data = response.json()
response_data = _STR_KEY_DICT_ADAPTER.validate_python(response.json())
# Check if task is already completed
result: Final = response_data.get("result", {})
status: Final = result.get("status", {})
result: Final = _STR_KEY_DICT_ADAPTER.validate_python(response_data.get("result", {}))
status: Final = _STR_KEY_DICT_ADAPTER.validate_python(result.get("status", {}))
state: Final = status.get("state", "")
if state != "completed":
@ -217,10 +240,10 @@ class PydanticAITransformation:
async def send_non_streaming_request(
api_base: str,
request_id: str,
params: Any,
params: "_SupportsModelDump | _SupportsPydanticDict | Mapping[str, object]",
timeout: float = 60.0,
agent_extra_headers: dict[str, str] | None = None,
) -> dict[str, Any]:
) -> dict[str, object]:
"""
Send a non-streaming A2A request to Pydantic AI agent and wait for completion.
@ -253,10 +276,10 @@ class PydanticAITransformation:
async def send_and_get_raw_response(
api_base: str,
request_id: str,
params: Any,
params: "_SupportsModelDump | _SupportsPydanticDict | Mapping[str, object]",
timeout: float = 60.0,
agent_extra_headers: dict[str, str] | None = None,
) -> dict[str, Any]:
) -> dict[str, object]:
"""
Send a request to Pydantic AI agent and return the raw task response.
@ -282,9 +305,9 @@ class PydanticAITransformation:
@staticmethod
def _transform_to_a2a_response(
response_data: dict[str, Any],
response_data: Mapping[str, object],
request_id: str,
) -> dict[str, Any]:
) -> dict[str, object]:
"""
Transform Pydantic AI task response to standard A2A non-streaming format.
@ -328,7 +351,7 @@ class PydanticAITransformation:
}
@staticmethod
def _extract_response_text(response_data: dict[str, Any]) -> tuple[str, str, list]:
def _extract_response_text(response_data: Mapping[str, object]) -> tuple[object, object, Sequence[object]]:
"""
Extract response text from completed task response.
@ -342,52 +365,53 @@ class PydanticAITransformation:
Returns:
Tuple of (full_text, message_id, parts)
"""
result: Final = response_data.get("result", {})
result: Final = _STR_KEY_DICT_ADAPTER.validate_python(response_data.get("result", {}))
# Try to extract from artifacts first (preferred for results)
artifacts: Final = result.get("artifacts", [])
if artifacts:
for artifact in artifacts:
parts = artifact.get("parts", [])
for artifact in _LIST_ADAPTER.validate_python(artifacts):
parts = _LIST_ADAPTER.validate_python(_STR_KEY_DICT_ADAPTER.validate_python(artifact).get("parts", []))
for part in parts:
if part.get("kind") == "text":
text = part.get("text", "")
if (part_dict := _STR_KEY_DICT_ADAPTER.validate_python(part)).get("kind") == "text":
text = part_dict.get("text", "")
if text:
return text, str(uuid4()), parts
# Fall back to history - get the last agent message
history: Final = result.get("history", [])
history: Final = _LIST_ADAPTER.validate_python(result.get("history", []))
for msg in reversed(history):
if msg.get("role") == "agent":
parts = msg.get("parts", [])
message_id = msg.get("messageId", str(uuid4()))
if (msg_dict := _STR_KEY_DICT_ADAPTER.validate_python(msg)).get("role") == "agent":
parts = _LIST_ADAPTER.validate_python(msg_dict.get("parts", []))
message_id = msg_dict.get("messageId", str(uuid4()))
full_text = ""
for part in parts:
if part.get("kind") == "text":
full_text += part.get("text", "")
if (part_dict := _STR_KEY_DICT_ADAPTER.validate_python(part)).get("kind") == "text":
full_text += _TEXT_ADAPTER.validate_python(part_dict.get("text", ""))
if full_text:
return full_text, message_id, parts
# Fall back to message field (original format)
message: Final = result.get("message", {})
if message:
parts = message.get("parts", [])
message_id = message.get("messageId", str(uuid4()))
message_dict: Final = _STR_KEY_DICT_ADAPTER.validate_python(message)
parts = _LIST_ADAPTER.validate_python(message_dict.get("parts", []))
message_id = message_dict.get("messageId", str(uuid4()))
full_text = ""
for part in parts:
if part.get("kind") == "text":
full_text += part.get("text", "")
if (part_dict := _STR_KEY_DICT_ADAPTER.validate_python(part)).get("kind") == "text":
full_text += _TEXT_ADAPTER.validate_python(part_dict.get("text", ""))
return full_text, message_id, parts
return "", str(uuid4()), []
@staticmethod
async def fake_streaming_from_response(
response_data: dict[str, Any],
response_data: Mapping[str, object],
request_id: str,
chunk_size: int = 50,
delay_ms: int = 10,
) -> AsyncIterator[dict[str, Any]]:
) -> AsyncIterator[dict[str, object]]:
"""
Convert a non-streaming A2A response into fake streaming chunks.
@ -410,12 +434,12 @@ class PydanticAITransformation:
full_text, message_id, parts = PydanticAITransformation._extract_response_text(response_data)
# Extract input message from raw response for history
result: Final = response_data.get("result", {})
history: Final = result.get("history", [])
input_message = {}
result: Final = _STR_KEY_DICT_ADAPTER.validate_python(response_data.get("result", {}))
history: Final = _LIST_ADAPTER.validate_python(result.get("history", []))
input_message = _STR_KEY_DICT_ADAPTER.validate_python({})
for msg in history:
if msg.get("role") == "user":
input_message = msg
if (msg_dict := _STR_KEY_DICT_ADAPTER.validate_python(msg)).get("role") == "user":
input_message = msg_dict
break
# Generate IDs for streaming events
@ -426,45 +450,49 @@ class PydanticAITransformation:
# 1. Emit initial task event (kind: "task", status: "submitted")
# Format matches A2ACompletionBridgeTransformation.create_task_event
task_event: Final = {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"contextId": context_id,
"history": [
{
"contextId": context_id,
"kind": "message",
"messageId": input_message_id,
"parts": input_message.get("parts", [{"kind": "text", "text": ""}]),
"role": "user",
"taskId": task_id,
}
],
"id": task_id,
"kind": "task",
"status": {
"state": "submitted",
task_event: Final = _STR_KEY_DICT_ADAPTER.validate_python(
{
"jsonrpc": "2.0",
"id": request_id,
"result": {
"contextId": context_id,
"history": [
{
"contextId": context_id,
"kind": "message",
"messageId": input_message_id,
"parts": input_message.get("parts", [{"kind": "text", "text": ""}]),
"role": "user",
"taskId": task_id,
}
],
"id": task_id,
"kind": "task",
"status": {
"state": "submitted",
},
},
},
}
}
)
yield task_event
# 2. Emit status update (kind: "status-update", status: "working")
# Format matches A2ACompletionBridgeTransformation.create_status_update_event
working_event: Final = {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"contextId": context_id,
"final": False,
"kind": "status-update",
"status": {
"state": "working",
working_event: Final = _STR_KEY_DICT_ADAPTER.validate_python(
{
"jsonrpc": "2.0",
"id": request_id,
"result": {
"contextId": context_id,
"final": False,
"kind": "status-update",
"status": {
"state": "working",
},
"taskId": task_id,
},
"taskId": task_id,
},
}
}
)
yield working_event
# Small delay to simulate processing
@ -473,29 +501,32 @@ class PydanticAITransformation:
# 3. Emit artifact update chunks (kind: "artifact-update")
# Format matches A2ACompletionBridgeTransformation.create_artifact_update_event
if full_text:
full_text_str: Final = _TEXT_ADAPTER.validate_python(full_text)
# Split text into chunks
for i in range(0, len(full_text), chunk_size):
chunk_text = full_text[i : i + chunk_size]
is_last_chunk = (i + chunk_size) >= len(full_text)
for i in range(0, len(full_text_str), chunk_size):
chunk_text = full_text_str[i : i + chunk_size]
is_last_chunk = (i + chunk_size) >= len(full_text_str)
artifact_event = {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"contextId": context_id,
"kind": "artifact-update",
"taskId": task_id,
"artifact": {
"artifactId": artifact_id,
"parts": [
{
"kind": "text",
"text": chunk_text,
}
],
artifact_event = _STR_KEY_DICT_ADAPTER.validate_python(
{
"jsonrpc": "2.0",
"id": request_id,
"result": {
"contextId": context_id,
"kind": "artifact-update",
"taskId": task_id,
"artifact": {
"artifactId": artifact_id,
"parts": [
{
"kind": "text",
"text": chunk_text,
}
],
},
},
},
}
}
)
yield artifact_event
# Add delay between chunks (except for last chunk)
@ -503,19 +534,21 @@ class PydanticAITransformation:
await asyncio.sleep(delay_ms / 1000.0)
# 4. Emit final status update (kind: "status-update", status: "completed", final: true)
completed_event: Final = {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"contextId": context_id,
"final": True,
"kind": "status-update",
"status": {
"state": "completed",
completed_event: Final = _STR_KEY_DICT_ADAPTER.validate_python(
{
"jsonrpc": "2.0",
"id": request_id,
"result": {
"contextId": context_id,
"final": True,
"kind": "status-update",
"status": {
"state": "completed",
},
"taskId": task_id,
},
"taskId": task_id,
},
}
}
)
yield completed_event
verbose_logger.info("Pydantic AI: Fake streaming completed for request_id=%s", request_id)

View file

@ -7,6 +7,7 @@ from litellm.litellm_core_utils.env_utils import get_env_int, get_env_int_or_non
DEFAULT_HEALTH_CHECK_PROMPT: Final = str(os.getenv("DEFAULT_HEALTH_CHECK_PROMPT", "test from litellm"))
AZURE_DEFAULT_RESPONSES_API_VERSION: Final = str(os.getenv("AZURE_DEFAULT_RESPONSES_API_VERSION", "preview"))
ROUTER_MAX_FALLBACKS: Final = int(os.getenv("ROUTER_MAX_FALLBACKS", 5))
ROUTER_FALLBACK_ERROR_DETAIL_MAX_CHARS: Final = 2000
DEFAULT_BATCH_SIZE: Final = int(os.getenv("DEFAULT_BATCH_SIZE", 512))
DEFAULT_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_FLUSH_INTERVAL_SECONDS", 5))
DEFAULT_S3_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_S3_FLUSH_INTERVAL_SECONDS", 10))
@ -279,6 +280,7 @@ TOOL_POLICY_CACHE_TTL_SECONDS: Final = int(os.getenv("TOOL_POLICY_CACHE_TTL_SECO
GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS: Final = int(
os.getenv("GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS", 24 * 60 * 60)
)
BEDROCK_APPLY_GUARDRAIL_CHUNK_BUDGET_CHARS: Final = 25_000
# Aggregation threshold: default to 80% of the asyncio queue maxsize so the check can always trigger.
# Must be < LITELLM_ASYNCIO_QUEUE_MAXSIZE; if set higher the aggregation logic will never fire.
MAX_SIZE_IN_MEMORY_QUEUE: Final = int(os.getenv("MAX_SIZE_IN_MEMORY_QUEUE", int(LITELLM_ASYNCIO_QUEUE_MAXSIZE * 0.8)))
@ -1320,6 +1322,7 @@ X_LITELLM_DISABLE_CALLBACKS: Final = "x-litellm-disable-callbacks"
LITELLM_METADATA_FIELD: Final = "litellm_metadata"
OLD_LITELLM_METADATA_FIELD: Final = "metadata"
RETURN_RAW_MODEL_NAME_METADATA_KEY: Final = "_complexity_router_return_raw_model_name"
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY: Final = "_session_deployment_affinity_ttl"
INTERNAL_CALL_ORIGIN_METADATA_KEY: Final = "internal_call_origin"
LITELLM_TRUNCATED_PAYLOAD_FIELD: Final = "litellm_truncated"
LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE: Final = (

View file

@ -16,7 +16,10 @@ import asyncio
import os
import time
import traceback
from collections.abc import Mapping
from types import MappingProxyType
from typing import Final
from urllib.parse import urlparse
from litellm._logging import verbose_logger
from litellm.integrations.custom_batch_logger import CustomBatchLogger
@ -27,6 +30,16 @@ from litellm.llms.custom_httpx.http_handler import (
)
from litellm.types.utils import StandardAuditLogPayload, StandardLoggingPayload
DEFAULT_AZURE_AUTHORITY_HOST: Final = "https://login.microsoftonline.com"
DEFAULT_AZURE_MONITOR_SCOPE: Final = "https://monitor.azure.com/.default"
MONITOR_SCOPE_BY_AUTHORITY_HOST: Final[Mapping[str, str]] = MappingProxyType(
{
"login.microsoftonline.com": DEFAULT_AZURE_MONITOR_SCOPE,
"login.microsoftonline.us": "https://monitor.azure.us/.default",
}
)
class AzureSentinelLogger(CustomBatchLogger):
"""
@ -42,6 +55,7 @@ class AzureSentinelLogger(CustomBatchLogger):
client_id: str | None = None,
client_secret: str | None = None,
audit_stream_name: str | None = None,
authority_host: str | None = None,
**kwargs,
):
"""
@ -62,6 +76,10 @@ class AzureSentinelLogger(CustomBatchLogger):
If not provided, will use AZURE_SENTINEL_CLIENT_SECRET or AZURE_CLIENT_SECRET env var.
audit_stream_name (str, optional): Stream name from DCR for audit logs.
If not provided, will use AZURE_SENTINEL_AUDIT_STREAM_NAME env var or the standard stream name.
authority_host (str, optional): Microsoft Entra authority host that issues the OAuth2 token,
e.g. "https://login.microsoftonline.us" for Azure Government. If not provided, will use
AZURE_SENTINEL_AUTHORITY_HOST or AZURE_AUTHORITY_HOST env vars, or default to the Azure
Public Cloud authority. The Azure Monitor audience is derived from it.
"""
self.async_httpx_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.LoggingCallback)
@ -76,6 +94,12 @@ class AzureSentinelLogger(CustomBatchLogger):
resolved_client_secret: Final = (
client_secret or os.getenv("AZURE_SENTINEL_CLIENT_SECRET") or os.getenv("AZURE_CLIENT_SECRET")
)
resolved_authority_host: Final = self._normalize_authority_host(
authority_host
or os.getenv("AZURE_SENTINEL_AUTHORITY_HOST")
or os.getenv("AZURE_AUTHORITY_HOST")
or DEFAULT_AZURE_AUTHORITY_HOST
)
if not resolved_dcr_immutable_id:
raise ValueError(
@ -119,7 +143,8 @@ class AzureSentinelLogger(CustomBatchLogger):
)
# OAuth2 scope for Azure Monitor
self.oauth_scope = "https://monitor.azure.com/.default"
self.authority_host = resolved_authority_host
self.oauth_scope = self._resolve_oauth_scope(authority_host=resolved_authority_host)
self.oauth_token: str | None = None
self.oauth_token_expires_at: float | None = None
@ -129,6 +154,26 @@ class AzureSentinelLogger(CustomBatchLogger):
self.log_queue: list[StandardLoggingPayload] = []
self.audit_log_queue: list[StandardAuditLogPayload] = []
@staticmethod
def _normalize_authority_host(authority_host: str) -> str:
"""
Normalize an authority host into an absolute URL with no trailing slash.
Accepts the scheme-qualified form litellm documents ("https://login.microsoftonline.us")
and the bare-host form the azure-identity AzureAuthorityHosts constants use.
"""
stripped: Final = authority_host.strip().rstrip("/")
return stripped if "://" in stripped else f"https://{stripped}"
@staticmethod
def _resolve_oauth_scope(authority_host: str) -> str:
"""
Map an authority host to the Azure Monitor Logs Ingestion audience for the same cloud,
falling back to the Azure Public Cloud audience for an unrecognized host.
"""
host: Final = urlparse(authority_host).hostname or ""
return MONITOR_SCOPE_BY_AUTHORITY_HOST.get(host, DEFAULT_AZURE_MONITOR_SCOPE)
@staticmethod
def _build_api_endpoint(endpoint: str, dcr_immutable_id: str, stream_name: str) -> str:
return f"{endpoint.rstrip('/')}/dataCollectionRules/{dcr_immutable_id}/streams/{stream_name}?api-version=2023-01-01"
@ -150,7 +195,7 @@ class AzureSentinelLogger(CustomBatchLogger):
assert self.client_id is not None, "client_id is required"
assert self.client_secret is not None, "client_secret is required"
token_url: Final = f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token"
token_url: Final = f"{self.authority_host}/{self.tenant_id}/oauth2/v2.0/token"
token_data: Final = {
"client_id": self.client_id,

View file

@ -57,6 +57,7 @@ from litellm.integrations.otel.model.semconv import (
Metric,
Network,
NetworkTransport,
RpcSystem,
Server,
resolve_operation,
resolve_provider,
@ -102,6 +103,7 @@ __all__ = [
"ProxyRequestSpanData",
"RequestContext",
"RequestIdentity",
"RpcSystem",
"Server",
"ServerInfo",
"ServiceSpanData",

View file

@ -33,6 +33,7 @@ from litellm.integrations.otel.model.payloads import (
is_mcp_list_tools,
is_mcp_tool_call,
)
from litellm.integrations.otel.model.semconv import Error
from litellm.integrations.otel.model.spans import SpanRole, span_role_for_service
from litellm.integrations.otel.model.utils import to_ns
from litellm.integrations.otel.plumbing.context import (
@ -634,18 +635,23 @@ class OpenTelemetryV2(CustomLogger):
"""Stamp the v2 error.* attributes on the FastAPI-owned SERVER span for a
failure that dies before any LLM-call span exists (malformed body, auth /
validation rejection). Called from the proxy's global exception handler via
``_close_dangling_otel_server_span``. The instrumentor still owns the span's
status and lifecycle, so this only decorates it never sets status, never
ends it and emits no exception event, matching v1's SERVER-span behavior
and avoiding a duplicate of the event ``async_post_call_failure_hook`` or
the ``auth`` phase span already records."""
``_close_dangling_otel_server_span``, which swallows the exception into a
``JSONResponse`` so the instrumentor never sees it and leaves the span
``UNSET``; the status is set here instead (v1 did the same from the handler)
so a failed request reads as failed and not merely as a span carrying an
error message. The instrumentor still owns the span's lifecycle, so this
never ends it. The exception event is recorded only when nothing stamped
this span already ``async_post_call_failure_hook`` and the ``auth`` phase
span record their own, and a second event would duplicate it while the
attributes are always restamped so ``error.code`` stays pinned to the real
response status."""
if span is None or not is_recordable_span(span):
return
already_stamped: Final = Error.TYPE in (getattr(span, "attributes", None) or ())
stamp_error(
span,
_span_error_from_exception(exception, status_code=status_code),
record_event=False,
set_status=False,
record_event=not already_stamped,
)
async def async_post_call_failure_hook(

View file

@ -31,7 +31,9 @@ from litellm.integrations.otel.model.semconv import (
MCP,
Error,
GenAI,
JsonRpc,
LiteLLM,
RpcSystem,
Server,
)
from litellm.integrations.otel.model.spans import db_system
@ -94,11 +96,14 @@ class GenAIMapper:
_MCP_ATTRS: dict[str, Callable[[MCPToolCallSpanData], AttrValue | None]] = {
GenAI.OPERATION_NAME: lambda d: d.operation.value,
JsonRpc.SYSTEM: lambda d: RpcSystem.JSONRPC.value if d.server_address and d.server_port else None,
MCP.METHOD_NAME: lambda d: d.method,
MCP.SESSION_ID: lambda d: d.session_id,
GenAI.TOOL_NAME: lambda d: d.tool_name or None,
GenAI.TOOL_CALL_ARGUMENTS: lambda d: d.arguments_json,
GenAI.TOOL_CALL_RESULT: lambda d: d.result_json,
Server.ADDRESS: lambda d: d.server_address,
Server.PORT: lambda d: d.server_port,
LiteLLM.MCP_SERVER_NAME: lambda d: d.server_name,
LiteLLM.CALL_ID: lambda d: d.identity.call_id or None,
f"{LiteLLM.COST_PREFIX}total": lambda d: d.response_cost,

View file

@ -364,6 +364,34 @@ class LLMCallSpanData:
# --- the MCP tool-call model ------------------------------------------------- #
def _upstream_address_port(resource: str | None) -> tuple[str | None, int | None]:
"""Split a redacted MCP server origin into ``server.address`` / ``server.port``.
``mcp_server_resource`` is a scheme + host + port origin with userinfo, path,
query and fragment already stripped. The port falls back to the scheme default
when the origin omits it, because a consumer that keys a downstream dependency
off the address renders a missing port as ``0``.
The origin is rebuilt without its IPv6 brackets upstream, so reading the port can
raise on an address the host check still admits: a zone-scoped ``fe80::1%25eth0``
leaves a truthy hostname of ``fe80`` behind. Both halves are read inside the guard
so an unparseable origin yields no address rather than propagating out of span
construction, matching how the redactor guards the same split.
"""
if not resource:
return None, None
try:
parsed: Final = urlsplit(resource)
hostname: Final = parsed.hostname
port: Final = parsed.port
except ValueError:
return None, None
if not hostname:
return None, None
default_port: Final = 443 if parsed.scheme == "https" else 80 if parsed.scheme == "http" else None
return hostname, port or default_port
@dataclass(frozen=True)
class MCPToolCallSpanData:
"""One MCP ``tools/call`` execution, parsed from a closed request's payload.
@ -378,6 +406,8 @@ class MCPToolCallSpanData:
method: str
tool_name: str
server_name: str | None
server_address: str | None
server_port: int | None
session_id: str | None
arguments_json: str | None
result_json: str | None
@ -390,11 +420,14 @@ class MCPToolCallSpanData:
cls, payload: StandardLoggingPayload, capture_content: bool = False
) -> MCPToolCallSpanData:
meta: Final = _mcp_tool_call_metadata(cast(Mapping[str, object], payload))
address, port = _upstream_address_port(as_str(meta.get("mcp_server_resource")) or None)
return cls(
operation=resolve_operation(as_str(payload.get("call_type"))),
method=MCPMethod.TOOLS_CALL.value,
tool_name=as_str(meta.get("name")) or "",
server_name=as_str(meta.get("mcp_server_name")),
server_address=address,
server_port=port,
session_id=as_str(meta.get("mcp_session_id")),
arguments_json=(
_json_or_none(meta.get("arguments")) if capture_content and meta.get("arguments") is not None else None

View file

@ -130,11 +130,25 @@ class JsonRpc:
"""JSON-RPC keys carried on MCP spans. The error/status code lives in the
``rpc.*`` namespace per semconv, not ``jsonrpc.*``."""
SYSTEM: Final = "rpc.system"
REQUEST_ID: Final = "jsonrpc.request.id"
PROTOCOL_VERSION: Final = "jsonrpc.protocol.version"
RESPONSE_STATUS_CODE: Final = "rpc.response.status_code"
class RpcSystem(str, Enum):
"""Well-known values for ``rpc.system``. MCP frames every message as JSON-RPC 2.0.
Naming the system also classifies the span: a CLIENT span carrying none of the
``rpc.*``/``http.*``/``db.*``/``messaging.*`` families records no span type or
subtype in backends that derive those from the attribute family. It is emitted
only alongside ``server.address``/``server.port``, since a backend that reads it
as a downstream dependency names that dependency from the server address.
"""
JSONRPC = "jsonrpc"
class NetworkTransport(str, Enum):
"""Well-known values for ``network.transport``."""

View file

@ -9,7 +9,7 @@ server-side using litellm router's search tools.
import asyncio
import math
import uuid
from collections.abc import AsyncIterator, Mapping
from collections.abc import AsyncIterator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, cast
import litellm
@ -37,10 +37,17 @@ from litellm.types.integrations.custom_logger import (
AgenticLoopRequestPatch,
)
from litellm.types.integrations.websearch_interception import (
AnthropicSearchQuery,
AnthropicServerToolUseBlock,
WebSearchInterceptionConfig,
)
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import CallTypes, LlmProviders
from litellm.types.utils import (
AgenticLoopParams,
CallTypes,
LlmProviders,
StandardLoggingUserAPIKeyMetadata,
)
from litellm.utils import ProviderConfigManager
if TYPE_CHECKING:
@ -263,7 +270,7 @@ class WebSearchInterceptionLogger(CustomLogger):
return None
# Check if request has tools with native web_search
tools: Final = kwargs.get("tools")
tools: Final[Sequence[dict[str, object]] | None] = kwargs.get("tools")
if not tools:
return None
@ -312,7 +319,9 @@ class WebSearchInterceptionLogger(CustomLogger):
return kwargs
def _convert_responses_tools(self, kwargs: Mapping[str, object], tools: list[dict[str, object]]) -> dict | None:
def _convert_responses_tools(
self, kwargs: Mapping[str, object], tools: Sequence[dict[str, object]]
) -> dict[str, object] | None:
"""Convert Responses API web search tools to the LiteLLM standard function tool."""
if not any(is_web_search_tool_responses(tool) for tool in tools):
return None
@ -377,7 +386,7 @@ class WebSearchInterceptionLogger(CustomLogger):
)
@staticmethod
def _tool_name(tool: dict[str, Any]) -> str | None:
def _tool_name(tool: Mapping[str, object]) -> object:
"""Effective tool name, handling OpenAI ``function`` wrapper shape."""
fn: Final = tool.get("function")
if tool.get("type") == "function" and isinstance(fn, dict):
@ -833,22 +842,48 @@ class WebSearchInterceptionLogger(CustomLogger):
def _build_native_result_blocks(
tool_calls: list[dict],
structured_results: list[SearchResponse | None],
) -> list[dict[str, object]]:
"""Build one ``web_search_tool_result`` block per tool_call."""
blocks: Final[list[dict[str, object]]] = []
for i, tool_call in enumerate(tool_calls):
tool_use_id = tool_call.get("id") or ""
structured = structured_results[i] if i < len(structured_results) else None
blocks.append(
WebSearchTransformation.build_web_search_tool_result_block(
tool_use_id=tool_use_id,
search_response=structured,
)
) -> tuple[Mapping[str, object], ...]:
"""
Build a ``server_tool_use`` + ``web_search_tool_result`` pair per tool_call.
The pair is what Anthropic's spec requires: a bare result block, or one
keyed by the model's ``toolu_...`` id instead of a ``srvtoolu_...`` one,
is rejected on replay ("String should match pattern '^srvtoolu_'") and
leaves native clients without a search to attach the sources to.
"""
return tuple(
block
for i, tool_call in enumerate(tool_calls)
for block in WebSearchInterceptionLogger._native_result_pair(
query=WebSearchInterceptionLogger._tool_call_query(tool_call),
search_response=structured_results[i] if i < len(structured_results) else None,
)
return blocks
)
@staticmethod
def _inject_native_blocks(response: Any, native_blocks: list[dict[str, object]]) -> Any:
def _tool_call_query(tool_call: Mapping[str, object]) -> str:
tool_input: Final = tool_call.get("input")
if not isinstance(tool_input, Mapping):
return ""
query: Final = tool_input.get("query")
return query if isinstance(query, str) else ""
@staticmethod
def _native_result_pair(
query: str,
search_response: SearchResponse | None,
) -> tuple[Mapping[str, object], Mapping[str, object]]:
tool_use_id: Final = f"srvtoolu_{uuid.uuid4().hex}"
return (
AnthropicServerToolUseBlock(id=tool_use_id, input=AnthropicSearchQuery(query=query)).model_dump(),
WebSearchTransformation.build_web_search_tool_result_block(
tool_use_id=tool_use_id,
search_response=search_response,
),
)
@staticmethod
def _inject_native_blocks(response: Any, native_blocks: Sequence[Mapping[str, object]]) -> Any:
"""Prepend native blocks to response content, dict or object form."""
if not native_blocks:
return response
@ -1243,7 +1278,7 @@ class WebSearchInterceptionLogger(CustomLogger):
kwargs_for_followup: Final = self._prepare_followup_kwargs(kwargs)
if logging_obj is not None:
agentic_params: Final = logging_obj.model_call_details.get("agentic_loop_params", {})
agentic_params: Final[AgenticLoopParams] = logging_obj.model_call_details.get("agentic_loop_params", {})
full_model_name = agentic_params.get("model", model)
verbose_logger.debug(
"WebSearchInterception: Built anthropic request patch [call_id=%s model=%s messages=%d searches=%d]",
@ -1288,6 +1323,7 @@ class WebSearchInterceptionLogger(CustomLogger):
search_tool: Final = self._select_search_tool_from_router(llm_router=llm_router)
search_provider: str | None = None
search_litellm_params: dict[str, Any] = {}
search_tool_name: Final = self._selected_search_tool_name(search_tool=search_tool)
if search_tool is not None:
await self._authorize_search_tool(search_tool=search_tool, kwargs=kwargs)
search_litellm_params = dict(search_tool.get("litellm_params", {}) or {})
@ -1304,12 +1340,30 @@ class WebSearchInterceptionLogger(CustomLogger):
verbose_logger.debug(
"WebSearchInterception: Executing search for '%s' using provider '%s'", query, search_provider
)
user_api_key_auth: Final = self._get_user_api_key_auth_from_kwargs(kwargs)
search_metadata: Final = (
None
if user_api_key_auth is None
else self._build_search_request_metadata(
user_api_key_auth=user_api_key_auth,
search_tool_name=search_tool_name,
)
)
search_kwargs: Final = {
key: value
for key, value in search_litellm_params.items()
if key != "search_provider" and value is not None
}
result: Final = await litellm.asearch(query=query, search_provider=search_provider, **search_kwargs)
result: Final = (
await litellm.asearch(query=query, search_provider=search_provider, **search_kwargs)
if search_metadata is None
else await litellm.asearch(
query=query,
search_provider=search_provider,
litellm_metadata=search_metadata,
**search_kwargs,
)
)
# Format using transformation function
search_result_text: Final = WebSearchTransformation.format_search_response(result)
@ -1366,6 +1420,35 @@ class WebSearchInterceptionLogger(CustomLogger):
team_object=team_object,
)
@staticmethod
def _build_search_request_metadata(
user_api_key_auth: "UserAPIKeyAuth",
search_tool_name: str | None,
) -> Mapping[str, object]:
"""
Spend-tracking metadata for the intercepted search, so its provider cost is logged
and billed against the key/user/team that made the originating LLM request instead
of being dropped by the proxy's spend hook for lack of an owner.
"""
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
user_api_key_metadata: Final[StandardLoggingUserAPIKeyMetadata] = (
LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(user_api_key_dict=user_api_key_auth)
)
return { # mutable-ok: litellm's metadata channel is a plain dict its logging path reads and enriches
**user_api_key_metadata,
"model_group": search_tool_name,
"user_api_key": user_api_key_auth.api_key,
"user_api_key_auth": user_api_key_auth,
}
@staticmethod
def _selected_search_tool_name(search_tool: Mapping[str, object] | None) -> str | None:
if search_tool is None:
return None
search_tool_name: Final = search_tool.get("search_tool_name")
return search_tool_name if isinstance(search_tool_name, str) and search_tool_name else None
@staticmethod
def _get_user_api_key_auth_from_kwargs(kwargs: Mapping[str, object] | None) -> "UserAPIKeyAuth | None":
if not kwargs:

View file

@ -412,6 +412,15 @@ class WebSearchTransformation:
block that should accompany the model's text reply when the original
request used a native ``web_search_*`` tool.
The spec'd shape carries page text only in ``encrypted_content``, an
opaque server-issued blob that we cannot mint. Emitting the four spec
fields alone would drop the snippet entirely, leaving the client (and
the model, on any replayed follow-up turn) with URLs and titles but no
evidence to answer from, forcing a fetch per result. So the snippet is
carried in an additive ``snippet`` key alongside the spec fields.
``encrypted_content`` stays empty rather than holding plaintext, which
would assert encryption semantics that do not hold.
Spec reference:
https://docs.anthropic.com/en/api/web-search-tool
@ -438,6 +447,7 @@ class WebSearchTransformation:
"title": title,
"page_age": page_age,
"encrypted_content": "",
"snippet": getattr(r, "snippet", "") or "",
}
)
return {

View file

@ -6,7 +6,7 @@ import io
import json
import mimetypes
import re
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from os import PathLike
from pathlib import Path
from typing import TYPE_CHECKING, Any, Final, Literal, cast
@ -1775,3 +1775,24 @@ def split_concatenated_json_objects(raw: str) -> list[dict[str, Any]]:
idx = end_idx
return results
def text_completion_prompt_to_messages(prompt: object) -> tuple[AllMessageValues, ...]:
"""
Wrap an OpenAI ``/v1/completions`` ``prompt`` into Chat Completion messages.
Mirrors what ``litellm.text_completion`` does on the real-time path: a
string becomes a single user message, and a list of strings becomes one
user message per element. Pre-tokenized prompts (``list[int]`` /
``list[list[int]]``) are only meaningful for the OpenAI-family text
endpoints, so they are rejected here rather than silently forwarded, as is
an empty prompt, which every chat-shaped provider rejects downstream.
"""
prompt_type_name: Final = type(prompt).__name__
if isinstance(prompt, str) and prompt:
return (ChatCompletionUserMessage(role="user", content=prompt),)
entries: Final = cast("Sequence[object]", prompt) if isinstance(prompt, Sequence) else ()
string_entries: Final = tuple(entry for entry in entries if isinstance(entry, str) and entry)
if string_entries and len(string_entries) == len(entries):
return tuple(ChatCompletionUserMessage(role="user", content=entry) for entry in string_entries)
raise ValueError(f"`prompt` must be a non-empty string or a non-empty list of strings. Got: {prompt_type_name}.")

View file

@ -1,5 +1,6 @@
import asyncio
import json
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, Protocol, cast
import litellm
@ -20,11 +21,24 @@ from .litellm_logging import Logging as LiteLLMLogging
if TYPE_CHECKING:
from websockets.asyncio.client import ClientConnection
from litellm.types.guardrails import GuardrailEventHooks
CLIENT_CONNECTION_CLASS = ClientConnection
else:
CLIENT_CONNECTION_CLASS = Any
class _ClientWebSocketExceptions(Protocol):
ConnectionClosed: type[Exception]
class _ClientWebSocket(Protocol):
exceptions: _ClientWebSocketExceptions
async def send_text(self, data: str) -> None: ...
async def receive_text(self) -> str: ...
class RealtimeEventNormalizer(Protocol):
def should_drop(self, event: object) -> bool: ...
def normalize(self, event: dict) -> dict: ...
@ -48,13 +62,13 @@ class RealTimeStreaming:
logging_obj: LiteLLMLogging,
provider_config: BaseRealtimeConfig | None = None,
model: str = "",
user_api_key_dict: Any | None = None,
user_api_key_dict: object | None = None,
request_data: dict | None = None,
backend_uses_beta_protocol: bool | None = None,
force_transcription_model: str | None = None,
event_normalizer: RealtimeEventNormalizer | None = None,
):
self.websocket = websocket
self.websocket: _ClientWebSocket = websocket
self.backend_ws = backend_ws
self.logging_obj = logging_obj
self.messages: list[OpenAIRealtimeEvents] = []
@ -127,7 +141,7 @@ class RealTimeStreaming:
]
)
_CLIENT_AUDIO_BUFFER_COMMIT_TYPES = frozenset(["input_audio_buffer.commit", "input_audio_buffer.end"])
_AUDIO_FORMAT_MAP: dict[str, dict[str, Any]] = {
_AUDIO_FORMAT_MAP: dict[str, dict[str, str | int]] = {
"pcm16": {"type": "audio/pcm", "rate": 24000},
"g711_ulaw": {"type": "audio/G711-ulaw", "rate": 8000},
"g711_alaw": {"type": "audio/G711-alaw", "rate": 8000},
@ -281,6 +295,7 @@ class RealTimeStreaming:
if event_obj.get("type") != "response.done":
return
response: Final = cast(dict[str, Any], event_obj.get("response", {}))
item: Mapping[str, object]
for item in response.get("output", []):
if item.get("type") == "function_call":
self.tool_calls.append(
@ -384,7 +399,7 @@ class RealTimeStreaming:
return message
try:
message_obj: Final = json.loads(message)
message_obj: Final[Mapping[str, object]] = json.loads(message)
except (json.JSONDecodeError, TypeError):
return message
@ -487,7 +502,7 @@ class RealTimeStreaming:
if self._backend_setup_complete and not self._flushing_pending_messages_until_setup:
return False
try:
msg_obj: Final = json.loads(message)
msg_obj: Final[Mapping[str, object]] = json.loads(message)
except (json.JSONDecodeError, TypeError):
return False
return msg_obj.get("type") in RealTimeStreaming._CLIENT_AUDIO_BUFFER_TYPES
@ -555,7 +570,7 @@ class RealTimeStreaming:
def _event_to_client_json(self, event: dict) -> str:
return json.dumps(self._normalize_event_for_ga_client(event))
async def _send_event_to_client(self, event: Any, event_str: str) -> bool:
async def _send_event_to_client(self, event: object, event_str: str) -> bool:
if self._should_drop_event_from_client(event):
return False
if isinstance(event, dict):
@ -595,12 +610,12 @@ class RealTimeStreaming:
def _make_disable_auto_response_message(self) -> str:
"""Return a session.update that disables VAD auto-response."""
turn_detection: Final[dict[str, Any]] = {
turn_detection: Final[dict[str, str | bool]] = {
"type": "server_vad",
"create_response": False,
}
if self._backend_uses_beta_protocol:
session: dict[str, Any] = {"turn_detection": turn_detection}
session: dict[str, object] = {"turn_detection": turn_detection}
else:
session = {
"type": "realtime",
@ -654,7 +669,7 @@ class RealTimeStreaming:
def _has_realtime_guardrails_for_event_hooks(
self,
event_hooks: list[Any],
event_hooks: Sequence["GuardrailEventHooks"],
) -> bool:
"""Return True if any callback would run for one of ``event_hooks``."""
from litellm.integrations.custom_guardrail import CustomGuardrail
@ -699,7 +714,7 @@ class RealTimeStreaming:
transcript: str,
item_id: str | None = None,
pre_block_backend_message: str | None = None,
event_hooks: list[Any] | None = None,
event_hooks: Sequence["GuardrailEventHooks"] | None = None,
) -> bool:
"""
Run registered guardrails on realtime text (transcript, user message, tool output).
@ -753,7 +768,7 @@ class RealTimeStreaming:
raise
# Extract the human-readable error from the detail dict (HTTPException)
# or fall back to str(e) for plain ValueError.
detail = getattr(e, "detail", None)
detail: object | None = getattr(e, "detail", None)
if isinstance(detail, dict):
safe_msg = detail.get("error") or str(e)
elif detail is not None:
@ -826,7 +841,7 @@ class RealTimeStreaming:
return True
return False
async def _handle_provider_config_message(self, raw_response) -> None:
async def _handle_provider_config_message(self, raw_response: str) -> None:
"""Process a backend message when a provider_config is set (transformed path)."""
returned_object: Final = self.provider_config.transform_realtime_response(
raw_response,
@ -910,7 +925,7 @@ class RealTimeStreaming:
await self._send_event_to_client(event, event_str)
@staticmethod
def _parse_backend_event(raw_response: str) -> dict | None:
def _parse_backend_event(raw_response: str) -> dict[str, object] | None:
"""Parse a backend frame once. Returns None for non-JSON or non-object frames."""
try:
event: Final = json.loads(raw_response)
@ -1020,7 +1035,7 @@ class RealTimeStreaming:
objects and any test doubles that expose a .scope dict.
"""
try:
headers: Final = websocket.scope.get("headers", [])
headers: Final[Sequence[tuple[bytes | str, bytes | str]]] = websocket.scope.get("headers", [])
for name, value in headers:
if isinstance(name, bytes):
name = name.decode("latin-1")
@ -1071,9 +1086,9 @@ class RealTimeStreaming:
session["output_modalities"] = ["text"]
# 3-7. Lift flat audio fields into the nested audio object
audio: Final[dict[str, Any]] = {}
inp: Final[dict[str, Any]] = {}
out: Final[dict[str, Any]] = {}
audio: Final[dict[str, object]] = {}
inp: Final[dict[str, object]] = {}
out: Final[dict[str, object]] = {}
# voice → audio.output.voice
if "voice" in session:
@ -1190,7 +1205,7 @@ class RealTimeStreaming:
# model; check them with the same guardrail used for
# user text so an attacker cannot smuggle blocked
# content into a function_call_output.
output = item.get("output", "")
output: object = item.get("output", "")
output_text = output if isinstance(output, str) else json.dumps(output)
if output_text:
# Build the sanitized function_call_output up
@ -1241,7 +1256,7 @@ class RealTimeStreaming:
# interaction turn.
continue
elif item.get("role") == "user":
content_list = item.get("content", [])
content_list: Sequence[object] = item.get("content", [])
texts = [
c.get("text", "")
for c in content_list
@ -1280,7 +1295,7 @@ class RealTimeStreaming:
and not self._guardrail_turn_detection_update_sent
and self._has_audio_transcription_guardrails()
):
session = msg_obj.setdefault("session", {})
session: object = msg_obj.setdefault("session", {})
if isinstance(session, dict):
existing_td = session.get("turn_detection")
if not isinstance(existing_td, dict):

View file

@ -3,7 +3,7 @@ import time
from collections.abc import Iterator, Mapping, Sequence
from itertools import groupby
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Union, cast
from typing import TYPE_CHECKING, Any, Final, TypedDict, Union, cast
from litellm._logging import verbose_logger
from litellm.types.llms.openai import (
@ -30,6 +30,7 @@ from litellm.types.utils import (
from litellm.utils import print_verbose, token_counter
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.types.litellm_core_utils.streaming_chunk_builder_utils import (
UsagePerChunk,
)
@ -39,6 +40,60 @@ if TYPE_CHECKING:
)
class _ThinkingBlockFragment(TypedDict, total=False):
type: str | None
data: str | None
thinking: str | None
signature: str | None
class _ThinkingDelta(TypedDict, total=False):
thinking_blocks: Sequence[_ThinkingBlockFragment]
class _ThinkingChoice(TypedDict, total=False):
delta: _ThinkingDelta
class _ThinkingChunk(TypedDict):
choices: Sequence[_ThinkingChoice]
class _ContentChoice(TypedDict, total=False):
delta: Mapping[str, str | None]
class _ContentChunk(TypedDict):
choices: Sequence[_ContentChoice]
class _AudioDelta(TypedDict, total=False):
audio: ChatCompletionAudioDelta | None
class _AudioChoice(TypedDict, total=False):
delta: _AudioDelta
class _AudioChunk(TypedDict):
choices: Sequence[_AudioChoice]
class _UsageBearingChunk(TypedDict, total=False):
usage: Usage | None
_hidden_params: Mapping[str, str]
class _UsageSummary(TypedDict):
prompt_tokens: int | None
completion_tokens: int | None
cache_creation_input_tokens: int | None
cache_read_input_tokens: int | None
completion_tokens_details: CompletionTokensDetails | None
prompt_tokens_details: PromptTokensDetailsWrapper | None
cost: float | None
def capture_cache_creation_token_details(
prompt_tokens_details: PromptTokensDetailsWrapper | None,
current: CacheCreationTokenDetails | None,
@ -78,7 +133,7 @@ class ChunkProcessor:
return []
first_chunk: Final = chunks[0]
first_hidden_params: dict[str, Any] = {}
first_hidden_params: dict[str, object] = {}
if isinstance(first_chunk, dict):
candidate = first_chunk.get("_hidden_params", {})
if isinstance(candidate, dict):
@ -115,8 +170,8 @@ class ChunkProcessor:
@staticmethod
def apply_provider_assembled_streaming_metadata(
response: ModelResponse,
chunks: list[Any],
logging_obj: Any | None = None,
chunks: list[object],
logging_obj: "Logging | None" = None,
) -> None:
if not chunks:
return
@ -456,7 +511,7 @@ class ChunkProcessor:
)
def get_combined_content(
self, chunks: list[dict[str, Any]], delta_key: str = "content"
self, chunks: Sequence["_ContentChunk"], delta_key: str = "content"
) -> ChatCompletionAssistantContentValue:
content_list: Final[list[str]] = []
for chunk in chunks:
@ -475,7 +530,7 @@ class ChunkProcessor:
return combined_content
def get_combined_thinking_content(
self, chunks: list[dict[str, Any]]
self, chunks: Sequence["_ThinkingChunk"]
) -> list[Union["ChatCompletionThinkingBlock", "ChatCompletionRedactedThinkingBlock"]] | None:
from litellm.types.llms.openai import (
ChatCompletionRedactedThinkingBlock,
@ -532,10 +587,10 @@ class ChunkProcessor:
return thinking_blocks
return None
def get_combined_reasoning_content(self, chunks: list[dict[str, Any]]) -> ChatCompletionAssistantContentValue:
def get_combined_reasoning_content(self, chunks: Sequence["_ContentChunk"]) -> ChatCompletionAssistantContentValue:
return self.get_combined_content(chunks, delta_key="reasoning_content")
def get_combined_audio_content(self, chunks: list[dict[str, Any]]) -> ChatCompletionAudioResponse:
def get_combined_audio_content(self, chunks: Sequence["_AudioChunk"]) -> ChatCompletionAudioResponse:
base64_data_list: Final[list[str]] = []
transcript_list: Final[list[str]] = []
expires_at: int | None = None
@ -544,7 +599,7 @@ class ChunkProcessor:
for chunk in chunks:
choices = chunk["choices"]
for choice in choices:
delta = choice.get("delta") or {}
delta: _AudioDelta = choice.get("delta") or {}
audio: ChatCompletionAudioDelta | None = delta.get("audio")
if audio is not None:
for k, v in audio.items():
@ -565,7 +620,7 @@ class ChunkProcessor:
id=id,
)
def _usage_chunk_calculation_helper(self, usage_chunk: Usage) -> dict:
def _usage_chunk_calculation_helper(self, usage_chunk: Usage) -> "_UsageSummary":
prompt_tokens = 0
completion_tokens = 0
## anthropic prompt caching information ##
@ -623,8 +678,8 @@ class ChunkProcessor:
return reasoning_tokens
@staticmethod
def _extract_usage_chunk(chunk: dict[str, Any] | ModelResponse | ModelResponseStream) -> Usage | None:
usage_chunk: Usage | dict[str, Any] | None = None
def _extract_usage_chunk(chunk: "_UsageBearingChunk | ModelResponse | ModelResponseStream") -> Usage | None:
usage_chunk: Usage | None = None
if hasattr(chunk, "usage") and chunk.usage is not None:
usage_chunk = chunk.usage
elif "usage" in chunk:
@ -640,7 +695,7 @@ class ChunkProcessor:
def _calculate_usage_per_chunk(
self,
chunks: list[dict[str, Any] | ModelResponse],
chunks: Sequence["_UsageBearingChunk | ModelResponse"],
) -> "UsagePerChunk":
from litellm.types.litellm_core_utils.streaming_chunk_builder_utils import (
UsagePerChunk,
@ -721,13 +776,7 @@ class ChunkProcessor:
"web_search_requests",
)
prompt_tokens_details = (
cast(
PromptTokensDetailsWrapper | None,
usage_chunk_dict["prompt_tokens_details"],
)
or prompt_tokens_details
)
prompt_tokens_details = usage_chunk_dict["prompt_tokens_details"] or prompt_tokens_details
cache_creation_token_details = capture_cache_creation_token_details(
prompt_tokens_details, cache_creation_token_details
@ -758,7 +807,7 @@ class ChunkProcessor:
@staticmethod
def _reset_anthropic_cursor_completion_tokens(
chunks: list[dict[str, Any] | ModelResponse],
chunks: Sequence["_UsageBearingChunk | ModelResponse"],
completion_tokens: int,
completion_usage_updates: int,
) -> int:
@ -797,7 +846,7 @@ class ChunkProcessor:
def calculate_usage(
self,
chunks: list[dict[str, Any] | ModelResponse],
chunks: Sequence["_UsageBearingChunk | ModelResponse"],
model: str,
completion_output: str,
messages: list | None = None,
@ -851,8 +900,8 @@ class ChunkProcessor:
setattr(returned_usage, "cache_read_input_tokens", cache_read_input_tokens) # for anthropic
if completion_tokens_details is not None:
if isinstance(completion_tokens_details, CompletionTokensDetails):
returned_usage.completion_tokens_details = CompletionTokensDetailsWrapper(
**completion_tokens_details.model_dump()
returned_usage.completion_tokens_details = CompletionTokensDetailsWrapper.model_validate(
completion_tokens_details.model_dump()
)
else:
returned_usage.completion_tokens_details = completion_tokens_details

View file

@ -4,9 +4,12 @@ This file contains common utils for anthropic calls.
import copy
import re
from typing import Any, Final
from collections.abc import Mapping, Sequence
from types import MappingProxyType
from typing import Any, Final, Literal
import httpx
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
import litellm
from litellm.litellm_core_utils.prompt_templates.common_utils import (
@ -1057,6 +1060,152 @@ def sanitize_tool_use_ids_in_anthropic_messages(messages: list[Any]) -> list[Any
return out
class _ReplayedSearchQuery(BaseModel):
model_config = ConfigDict(extra="allow")
query: str = ""
class _ReplayedWebSearchResult(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["web_search_result"]
url: str = ""
title: str = ""
snippet: str = ""
encrypted_content: str = ""
class _ReplayedWebSearchToolResult(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["web_search_tool_result"]
tool_use_id: str
content: tuple[_ReplayedWebSearchResult, ...]
class _ReplayedServerToolUse(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["server_tool_use"]
id: str
input: _ReplayedSearchQuery = _ReplayedSearchQuery()
class _TextBlock(BaseModel):
type: Literal["text"] = "text"
text: str
_WEB_SEARCH_TOOL_RESULT_ADAPTER: Final = TypeAdapter(_ReplayedWebSearchToolResult)
_SERVER_TOOL_USE_ADAPTER: Final = TypeAdapter(_ReplayedServerToolUse)
def _flattenable_web_search_tool_result(block: object) -> _ReplayedWebSearchToolResult | None:
"""
The parsed block when it is a ``web_search_tool_result`` carrying no
``encrypted_content``, else None for anything Anthropic itself issued.
An empty ``content`` list is flattenable too. It is what the interceptor emits
when a search legitimately returns nothing and when a search raises, and it
carries neither evidence to preserve nor an ``encrypted_content`` to respect,
so leaving it in place only buys the 400 this whole function exists to avoid.
"""
try:
parsed: Final = _WEB_SEARCH_TOOL_RESULT_ADAPTER.validate_python(block)
except ValidationError:
return None
if any(result.encrypted_content for result in parsed.content):
return None
return parsed
def _replayed_server_tool_use(block: object) -> _ReplayedServerToolUse | None:
try:
return _SERVER_TOOL_USE_ADAPTER.validate_python(block)
except ValidationError:
return None
def _render_web_search_results(query: str, results: tuple[_ReplayedWebSearchResult, ...]) -> str:
header: Final = f"Web search results for '{query}':" if query else "Web search results:"
if not results:
return f"{header}\n\nNo results were returned."
body: Final = "\n\n".join(
"\n".join(
line
for line in (
f"Title: {result.title}" if result.title else "",
f"URL: {result.url}" if result.url else "",
f"Snippet: {result.snippet}" if result.snippet else "",
)
if line
)
for result in results
)
return f"{header}\n\n{body}" if body else header
def _rewrite_replayed_web_search_block(
block: object,
flattenable: Mapping[str, _ReplayedWebSearchToolResult],
queries: Mapping[str, str],
) -> object | None:
parsed_result: Final = _flattenable_web_search_tool_result(block)
if parsed_result is not None:
return _TextBlock(
text=_render_web_search_results(queries.get(parsed_result.tool_use_id, ""), parsed_result.content)
).model_dump()
parsed_use: Final = _replayed_server_tool_use(block)
if parsed_use is not None and parsed_use.id in flattenable:
return None
return block
def _flatten_web_search_results_in_message(message: object) -> object:
if not isinstance(message, Mapping) or not isinstance(message.get("content"), Sequence):
return message
content: Final = message["content"]
if isinstance(content, str):
return message
flattenable: Final = MappingProxyType(
{
parsed.tool_use_id: parsed
for parsed in (_flattenable_web_search_tool_result(block) for block in content)
if parsed is not None
}
)
if not flattenable:
return message
queries: Final = MappingProxyType(
{
parsed.id: parsed.input.query
for parsed in (_replayed_server_tool_use(block) for block in content)
if parsed is not None
}
)
rewritten: Final = tuple(_rewrite_replayed_web_search_block(block, flattenable, queries) for block in content)
return {**message, "content": [b for b in rewritten if b is not None]} # mutable-ok: JSON wire format
def flatten_unencrypted_web_search_results_in_anthropic_messages( # mutable-ok: as sibling sanitizers
messages: list[Any],
) -> list[Any]:
"""
Return a new message list with replayed ``web_search_tool_result`` blocks that
carry no ``encrypted_content`` rewritten into plain ``text`` blocks holding the
same title / url / snippet evidence.
``encrypted_content`` is an opaque blob only Anthropic's own search backend can
mint, so blocks synthesized by LiteLLM (websearch interception against a search
provider) are rejected with ``Invalid encrypted_content in search_result block``
when a native client loops them back as history. Flattening them keeps the
evidence in the conversation instead of 400ing the follow-up turn, and leaves
genuine Anthropic-issued blocks untouched.
"""
return [_flatten_web_search_results_in_message(m) for m in messages] # mutable-ok: JSON wire format
def process_anthropic_headers(headers: httpx.Headers | dict) -> dict:
openai_headers: Final = {}
if "anthropic-ratelimit-requests-limit" in headers:

View file

@ -1,8 +1,9 @@
from collections.abc import AsyncIterator, Coroutine, Iterator
from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping
from typing import (
TYPE_CHECKING,
Any,
Final,
TypeAlias,
cast,
)
@ -33,8 +34,12 @@ if TYPE_CHECKING:
# Anthropic-only keys already mapped by the translator; strip on extra_kwargs re-merge.
ANTHROPIC_ONLY_REQUEST_KEYS: Final[frozenset[str]] = frozenset({"output_config"})
_AnthropicMessages: TypeAlias = "list[dict[str, object]]"
_AnthropicSystem: TypeAlias = "str | list[dict[str, object]] | None"
_ContextManagementSpec: TypeAlias = "dict[str, object] | list[dict[str, object]] | None"
def _messages_have_compaction_block(messages: list[dict]) -> bool:
def _messages_have_compaction_block(messages: _AnthropicMessages) -> bool:
"""Return True when any message carries a ``compaction`` content block."""
for msg in messages:
content = msg.get("content")
@ -54,8 +59,10 @@ def _proxy_router_fallback() -> "Router | None":
return _proxy_router
def _extract_proxy_litellm_metadata(kwargs: dict[str, Any]) -> dict[str, Any] | None:
"""Return ``kwargs["litellm_metadata"]`` when it's a dict; ``None`` otherwise.
def _extract_proxy_litellm_metadata(
kwargs: Mapping[str, object],
) -> "tuple[dict[str, object], UserAPIKeyAuth | None] | tuple[None, None]":
"""Return ``(kwargs["litellm_metadata"], its user_api_key_auth)`` when it's a dict; ``(None, None)`` otherwise.
The proxy attaches its auth/spend-attribution fields (``user_api_key``,
``user_api_key_team_id``, ``litellm_call_id``, the full ``UserAPIKeyAuth``
@ -68,18 +75,19 @@ def _extract_proxy_litellm_metadata(kwargs: dict[str, Any]) -> dict[str, Any] |
"""
litellm_metadata: Final = kwargs.get("litellm_metadata")
if not isinstance(litellm_metadata, dict):
return None
return litellm_metadata
return None, None
user_api_key_auth: Final[UserAPIKeyAuth | None] = litellm_metadata.get("user_api_key_auth")
return litellm_metadata, user_api_key_auth
async def _prepare_context_managed_request(
*,
model: str,
messages: list[dict],
tools: list[dict] | None,
system: Any | None,
context_management_spec: Any,
litellm_metadata: dict | None,
messages: _AnthropicMessages,
tools: list[dict[str, object]] | None,
system: _AnthropicSystem,
context_management_spec: _ContextManagementSpec,
litellm_metadata: dict[str, object] | None,
additional_drop_params: list[str] | None,
llm_router: "Router | None",
user_api_key_auth: "UserAPIKeyAuth | None" = None,
@ -102,11 +110,11 @@ async def _prepare_context_managed_request(
if polyfill_will_run:
history_result: PolyfillResult | None = None
working_messages: list[dict] = messages
working_system: Any | None = system
working_messages: _AnthropicMessages = messages
working_system: _AnthropicSystem = system
else:
history_result = apply_client_compaction_block_history(
messages=cast(list[dict[str, Any]], messages),
messages=messages,
system=system,
)
working_messages = history_result.messages if history_result is not None else messages
@ -136,7 +144,7 @@ async def _prepare_context_managed_request(
# to non-Anthropic backends that would reject them.
if polyfill_will_run and history_result is None:
history_result = apply_client_compaction_block_history(
messages=cast(list[dict[str, Any]], messages),
messages=messages,
system=system,
)
return history_result
@ -144,7 +152,7 @@ async def _prepare_context_managed_request(
def _polyfill_will_run(
*,
context_management_spec: Any,
context_management_spec: _ContextManagementSpec,
additional_drop_params: list[str] | None,
) -> bool:
"""Return True when ``compact_20260112`` will run via the polyfill dispatcher.
@ -171,7 +179,7 @@ def _polyfill_will_run(
def _spec_has_non_compact_edits(
*,
context_management_spec: Any,
context_management_spec: _ContextManagementSpec,
additional_drop_params: list[str] | None,
) -> bool:
"""Return True when the spec includes edits other than ``compact_20260112``.
@ -209,9 +217,9 @@ def _context_management_explicitly_dropped(additional_drop_params: list[str] | N
def _normalize_spec_edits(
*,
context_management_spec: Any,
context_management_spec: _ContextManagementSpec,
additional_drop_params: list[str] | None,
) -> list[dict[str, Any]] | None:
) -> list[dict[str, object]] | None:
"""Return the normalized ``edits`` list, or ``None`` if the polyfill won't run.
Delegates spec-shape normalization to the dispatcher's ``_normalize_spec``
@ -236,11 +244,11 @@ def _normalize_spec_edits(
async def _run_polyfill_if_enabled(
*,
model: str,
messages: list[dict],
tools: list[dict] | None,
system: Any | None,
context_management_spec: Any,
litellm_metadata: dict | None,
messages: _AnthropicMessages,
tools: list[dict[str, object]] | None,
system: _AnthropicSystem,
context_management_spec: _ContextManagementSpec,
litellm_metadata: dict[str, object] | None,
additional_drop_params: list[str] | None,
llm_router: "Router | None",
user_api_key_auth: "UserAPIKeyAuth | None" = None,
@ -306,7 +314,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
def _route_openai_thinking_to_responses_api_if_needed(
completion_kwargs: dict[str, Any],
*,
thinking: dict[str, Any] | None,
thinking: Mapping[str, object] | None,
) -> None:
"""
When users call `litellm.anthropic.messages.*` with a non-Anthropic model and
@ -407,12 +415,12 @@ class LiteLLMMessagesToCompletionTransformationHandler:
def _prepare_completion_kwargs(
*,
max_tokens: int,
messages: list[dict],
messages: _AnthropicMessages,
model: str,
metadata: dict | None = None,
stop_sequences: list[str] | None = None,
stream: bool | None = False,
system: str | list[dict[str, Any]] | None = None,
system: _AnthropicSystem = None,
temperature: float | None = None,
thinking: dict | None = None,
tool_choice: dict | None = None,
@ -420,7 +428,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
top_k: int | None = None,
top_p: float | None = None,
output_format: dict | None = None,
extra_kwargs: dict[str, Any] | None = None,
extra_kwargs: Mapping[str, object] | None = None,
) -> tuple[dict[str, Any], dict[str, str]]:
"""Prepare kwargs for litellm.completion/acompletion.
@ -433,7 +441,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
Logging as LiteLLMLoggingObject,
)
request_data: Final = {
request_data: Final[dict[str, object]] = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
@ -528,7 +536,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
@staticmethod
async def async_anthropic_messages_handler(
max_tokens: int,
messages: list[dict],
messages: _AnthropicMessages,
model: str,
metadata: dict | None = None,
stop_sequences: list[str] | None = None,
@ -537,7 +545,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
temperature: float | None = None,
thinking: dict | None = None,
tool_choice: dict | None = None,
tools: list[dict] | None = None,
tools: list[dict[str, object]] | None = None,
top_k: int | None = None,
top_p: float | None = None,
output_format: dict | None = None,
@ -551,10 +559,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
requested_router if requested_router is not None else _proxy_router_fallback()
)
proxy_litellm_metadata: Final = _extract_proxy_litellm_metadata(kwargs)
user_api_key_auth: Final[UserAPIKeyAuth | None] = (
proxy_litellm_metadata.get("user_api_key_auth") if proxy_litellm_metadata is not None else None
)
proxy_litellm_metadata, user_api_key_auth = _extract_proxy_litellm_metadata(kwargs)
polyfill_result: Final = await _prepare_context_managed_request(
model=model,
@ -618,7 +623,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
@staticmethod
def anthropic_messages_handler(
max_tokens: int,
messages: list[dict],
messages: _AnthropicMessages,
model: str,
metadata: dict | None = None,
stop_sequences: list[str] | None = None,
@ -627,7 +632,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
temperature: float | None = None,
thinking: dict | None = None,
tool_choice: dict | None = None,
tools: list[dict] | None = None,
tools: list[dict[str, object]] | None = None,
top_k: int | None = None,
top_p: float | None = None,
output_format: dict | None = None,
@ -688,10 +693,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
if context_management is None and not _messages_have_compaction_block(messages):
polyfill_result: PolyfillResult | None = None
else:
proxy_litellm_metadata: Final = _extract_proxy_litellm_metadata(kwargs)
user_api_key_auth: Final[UserAPIKeyAuth | None] = (
proxy_litellm_metadata.get("user_api_key_auth") if proxy_litellm_metadata is not None else None
)
proxy_litellm_metadata, user_api_key_auth = _extract_proxy_litellm_metadata(kwargs)
polyfill_result = run_async_function(
_prepare_context_managed_request,
model=model,

View file

@ -348,6 +348,26 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
merged_chunk["context_management"] = ContextManagementResponse(applied_edits=list(self.applied_edits))
return self._augment_message_delta_usage(merged_chunk)
def _handle_choiceless_chunk(self, chunk: "ModelResponseStream") -> bool:
"""Consume an OpenAI-compatible chunk that carries no ``choices``.
``choices`` is legitimately empty on metadata-only chunks; the final
usage chunk emitted when ``stream_options.include_usage`` is set is the
common case (vLLM and other OpenAI-compatible servers do this). Such a
chunk carries no content-block information, so the caller must not run
the content-block state machine over it.
Returns True when a merged ``message_delta`` was queued (usage folded
into the held stop-reason chunk); False when the chunk should be
skipped entirely.
"""
if self.holding_stop_reason_chunk is not None and _optional_attr(chunk, "usage") is not None:
self.chunk_queue.append(self._merge_usage_into_held_stop_reason_chunk(chunk))
self.queued_usage_chunk = True
self.holding_stop_reason_chunk = None
return True
return False
def _ensure_context_management_attached(self, message_delta_chunk: MessageBlockDelta) -> MessageBlockDelta:
"""Attach ``context_management`` to a ``message_delta`` chunk if
``self.applied_edits`` is non-empty and the chunk does not already
@ -509,6 +529,11 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
if chunk == "None" or chunk is None:
raise Exception
if not getattr(chunk, "choices", None):
if self._handle_choiceless_chunk(chunk):
return self.chunk_queue.popleft()
continue
should_start_new_block = self._should_start_new_content_block(chunk)
is_opening_first_block = self.sent_content_block_start is False
if is_opening_first_block and self._is_blank_delta(chunk):
@ -732,6 +757,11 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
if chunk == "None" or chunk is None:
raise Exception
if not getattr(chunk, "choices", None):
if self._handle_choiceless_chunk(chunk):
return self.chunk_queue.popleft()
continue
should_start_new_block = self._should_start_new_content_block(chunk)
is_opening_first_block = self.sent_content_block_start is False
if is_opening_first_block and self._is_blank_delta(chunk):

View file

@ -14,6 +14,7 @@ from typing import Any, Final, cast
import litellm
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.anthropic.common_utils import (
flatten_unencrypted_web_search_results_in_anthropic_messages,
sanitize_tool_use_ids_in_anthropic_messages,
strip_empty_text_blocks_from_anthropic_messages,
)
@ -222,6 +223,7 @@ async def anthropic_messages(
# Replay of cross-provider tool history (e.g. kimi -> Anthropic) may carry
# ids like ``functions.Bash:0`` that violate Anthropic's id pattern.
messages = sanitize_tool_use_ids_in_anthropic_messages(messages)
messages = flatten_unencrypted_web_search_results_in_anthropic_messages(messages)
from litellm.integrations.anthropic_cache_control_hook import (
AnthropicCacheControlHook,
@ -413,6 +415,7 @@ def anthropic_messages_handler(
if not kwargs.pop("_litellm_messages_presanitized", False):
messages = strip_empty_text_blocks_from_anthropic_messages(messages)
messages = sanitize_tool_use_ids_in_anthropic_messages(messages)
messages = flatten_unencrypted_web_search_results_in_anthropic_messages(messages)
from litellm.integrations.anthropic_cache_control_hook import (
AnthropicCacheControlHook,

View file

@ -1,8 +1,9 @@
from collections.abc import Coroutine, Iterable
from typing import Any, Final, Literal
from typing import Any, Final, Literal, TypedDict
import httpx
from openai import AsyncAzureOpenAI, AzureOpenAI
from openai.types.shared_params.metadata import Metadata
from typing_extensions import overload
from ...types.llms.openai import (
@ -22,6 +23,16 @@ from ...types.llms.openai import (
from .common_utils import BaseAzureLLM
class _RunThreadStreamData(TypedDict):
thread_id: str
assistant_id: str
additional_instructions: str | None
instructions: str | None
metadata: Metadata | None
model: str | None
tools: Iterable[AssistantToolParam] | None
class AzureAssistantsAPI(BaseAzureLLM):
def __init__(self) -> None:
super().__init__()
@ -212,9 +223,9 @@ class AzureAssistantsAPI(BaseAzureLLM):
response_obj: OpenAIMessage | None = None
if getattr(thread_message, "status", None) is None:
thread_message.status = "completed"
response_obj = OpenAIMessage(**thread_message.dict())
response_obj = OpenAIMessage.model_validate(thread_message.dict())
else:
response_obj = OpenAIMessage(**thread_message.dict())
response_obj = OpenAIMessage.model_validate(thread_message.dict())
return response_obj
# fmt: off
@ -301,9 +312,9 @@ class AzureAssistantsAPI(BaseAzureLLM):
response_obj: OpenAIMessage | None = None
if getattr(thread_message, "status", None) is None:
thread_message.status = "completed"
response_obj = OpenAIMessage(**thread_message.dict())
response_obj = OpenAIMessage.model_validate(thread_message.dict())
else:
response_obj = OpenAIMessage(**thread_message.dict())
response_obj = OpenAIMessage.model_validate(thread_message.dict())
return response_obj
async def async_get_messages(
@ -443,7 +454,7 @@ class AzureAssistantsAPI(BaseAzureLLM):
message_thread: Final = await openai_client.beta.threads.create(**data)
return Thread(**message_thread.dict())
return Thread.model_validate(message_thread.dict())
# fmt: off
@ -539,7 +550,7 @@ class AzureAssistantsAPI(BaseAzureLLM):
message_thread: Final = azure_openai_client.beta.threads.create(**data)
return Thread(**message_thread.dict())
return Thread.model_validate(message_thread.dict())
async def async_get_thread(
self,
@ -566,7 +577,7 @@ class AzureAssistantsAPI(BaseAzureLLM):
response: Final = await openai_client.beta.threads.retrieve(thread_id=thread_id)
return Thread(**response.dict())
return Thread.model_validate(response.dict())
# fmt: off
@ -642,7 +653,7 @@ class AzureAssistantsAPI(BaseAzureLLM):
response: Final = openai_client.beta.threads.retrieve(thread_id=thread_id)
return Thread(**response.dict())
return Thread.model_validate(response.dict())
# def delete_thread(self):
# pass
@ -730,7 +741,8 @@ class AzureAssistantsAPI(BaseAzureLLM):
event_handler: AssistantEventHandler | None,
litellm_params: dict | None = None,
) -> AssistantStreamManager[AssistantEventHandler]:
data: Final[dict[str, Any]] = {
stream_fn: Final = client.beta.threads.runs.stream
base_data: Final[_RunThreadStreamData] = {
"thread_id": thread_id,
"assistant_id": assistant_id,
"additional_instructions": additional_instructions,
@ -740,8 +752,8 @@ class AzureAssistantsAPI(BaseAzureLLM):
"tools": tools,
}
if event_handler is not None:
data["event_handler"] = event_handler
return client.beta.threads.runs.stream(**data)
return stream_fn(**base_data, event_handler=event_handler)
return stream_fn(**base_data)
# fmt: off

View file

@ -12,7 +12,6 @@ from litellm.litellm_core_utils.cloud_storage_security import (
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.llms.base_llm.batches.transformation import BaseBatchesConfig
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.bedrock import (
BedrockCreateBatchRequest,
BedrockCreateBatchResponse,
@ -29,7 +28,7 @@ from litellm.types.llms.openai import (
from litellm.types.utils import LiteLLMBatch, LlmProviders
from ..base_aws_llm import BaseAWSLLM
from ..common_utils import CommonBatchFilesUtils
from ..common_utils import CommonBatchFilesUtils, resolve_s3_encryption_key_id
# Bedrock batch input files are uploaded as
# s3://bucket/litellm-bedrock-files-{model, ":" -> "-"}-{uuid4}.jsonl (see
@ -200,7 +199,10 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig):
)
# Add optional KMS encryption key ID if provided
s3_encryption_key_id = litellm_params.get("s3_encryption_key_id") or get_secret_str("AWS_S3_ENCRYPTION_KEY_ID")
s3_encryption_key_id = resolve_s3_encryption_key_id(
litellm_params=litellm_params,
optional_params=optional_params,
)
if s3_encryption_key_id:
s3_output_config["s3EncryptionKeyId"] = s3_encryption_key_id

View file

@ -26,7 +26,7 @@ from litellm.llms.base_llm.anthropic_messages.transformation import (
)
from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.secret_managers.main import get_secret
from litellm.secret_managers.main import get_secret, get_secret_str
if TYPE_CHECKING:
from litellm.types.llms.openai import AllMessageValues
@ -1304,6 +1304,23 @@ def get_anthropic_beta_from_headers(headers: dict) -> list[str]:
return []
def resolve_s3_encryption_key_id(
litellm_params: Mapping[str, Any],
optional_params: Mapping[str, Any] | None = None,
) -> str | None:
"""
Resolve the SSE-KMS key configured for Bedrock batch/file S3 objects.
Precedence: `s3_encryption_key_id` in litellm_params, then optional_params
(client-side / request params), then the AWS_S3_ENCRYPTION_KEY_ID env var.
"""
candidates: Final = tuple(
source.get("s3_encryption_key_id") for source in (litellm_params, optional_params) if source is not None
)
explicit: Final = next((value for value in candidates if isinstance(value, str) and value), None)
return explicit or get_secret_str("AWS_S3_ENCRYPTION_KEY_ID")
class CommonBatchFilesUtils:
"""
Common utilities for Bedrock batch and file operations.

View file

@ -2,7 +2,9 @@ import base64
import json
import os
import time
from collections.abc import Mapping, MutableMapping
from collections.abc import Iterable, Mapping, MutableMapping
from functools import cache
from itertools import chain
from types import MappingProxyType
from typing import Any, Final
from urllib.parse import unquote
@ -10,7 +12,7 @@ from urllib.parse import unquote
import httpx
from httpx import Headers, Response
from openai.types.file_deleted import FileDeleted
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, TypeAdapter
from litellm._logging import verbose_logger
from litellm._uuid import uuid
@ -26,12 +28,16 @@ from litellm.litellm_core_utils.cloud_storage_security import (
split_configured_cloud_bucket_name,
validate_managed_cloud_file_id,
)
from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data
from litellm.litellm_core_utils.prompt_templates.common_utils import (
extract_file_data,
text_completion_prompt_to_messages,
)
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.files.transformation import (
BaseFilesConfig,
LiteLLMLoggingObj,
)
from litellm.types.llms.bedrock import BedrockBatchRecordKind
from litellm.types.llms.openai import (
AllMessageValues,
CreateFileRequest,
@ -41,12 +47,14 @@ from litellm.types.llms.openai import (
OpenAICreateFileRequestOptionalParams,
OpenAIFileObject,
PathLike,
ResponseInputParam,
ResponsesAPIOptionalRequestParams,
)
from litellm.types.utils import ExtractedFileData, LlmProviders, SpecialEnums
from litellm.utils import get_llm_provider
from ..base_aws_llm import BaseAWSLLM
from ..common_utils import BedrockError
from ..common_utils import BedrockError, resolve_s3_encryption_key_id
# litellm_params key used to hand the SigV4-signed GET headers from
# `transform_file_content_request` to `validate_environment` (the only hook
@ -55,6 +63,26 @@ from ..common_utils import BedrockError
S3_SIGNED_GET_HEADERS_PARAM: Final = "_s3_signed_get_headers"
def _frozen_mapping(items: Iterable[tuple[str, Any]]) -> Mapping[str, Any]:
return MappingProxyType(dict(items))
# JSONL batch records are untyped json, so the `/v1/responses` fields are
# validated into their concrete Responses API types before being handed to the
# Responses-to-Chat bridge. Both adapters drop keys the Responses API doesn't
# define, which is what the bridge would ignore anyway. Built on first use
# rather than at import: `ResponseInputParam` is a deep union and only batch
# files carrying `/v1/responses` records need it.
@cache
def _responses_input_adapter() -> TypeAdapter[str | ResponseInputParam]:
return TypeAdapter(str | ResponseInputParam)
@cache
def _responses_request_adapter() -> TypeAdapter[ResponsesAPIOptionalRequestParams]:
return TypeAdapter(ResponsesAPIOptionalRequestParams)
class _BedrockS3RequestParams(BaseModel):
"""Typed view of the credential/region params the S3 GetObject path reads."""
@ -303,41 +331,55 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
# example; add others here as they adopt the same schema.
CONVERSE_INVOKE_PROVIDERS = ("nova",)
# OpenAI batch URL that signals an embedding request. Per OpenAI Batch API
# spec, every JSONL record carries a `url` field; we use it as the
# authoritative signal to route the line to the embedding code path
# instead of inferring from the presence of `input` vs `messages`.
# OpenAI batch URLs that select which request shape a JSONL line carries.
# Per the OpenAI Batch API spec every record carries a `url`, so we use it
# as the authoritative routing signal instead of inferring from the
# presence of `input` vs `prompt` vs `messages`.
OPENAI_EMBEDDINGS_URL = "/v1/embeddings"
OPENAI_TEXT_COMPLETIONS_URL = "/v1/completions"
OPENAI_RESPONSES_URL = "/v1/responses"
@staticmethod
def _is_embedding_record(openai_jsonl_record: dict[str, Any]) -> bool:
def _classify_batch_record(openai_jsonl_record: Mapping[str, Any]) -> BedrockBatchRecordKind:
"""
Decide whether an OpenAI batch JSONL line is an embedding request.
Decide which OpenAI endpoint shape an OpenAI batch JSONL line carries.
Precedence (strict - any explicit `url` short-circuits):
1. `url == "/v1/embeddings"` -> embedding. Authoritative per the
OpenAI Batch API spec.
2. Any other non-empty `url` (e.g. `/v1/chat/completions`) -> NOT
embedding. We trust the caller's explicit signal even if the
body would otherwise suggest embedding; misrouting a chat
record into the embedding transformer would corrupt the
modelInput, while a chat-shaped body sent to the chat path
either succeeds or fails cleanly inside that transformer.
3. `url` missing/empty -> fall back to body shape. Requires
`input` present AND `messages` absent so a malformed record
carrying both keys routes to the chat path (safer default:
Anthropic transforms ignore unknown top-level keys, whereas
the embedding transformer would silently drop the messages).
Precedence (strict - any recognized `url` short-circuits):
1. A `url` matching a supported endpoint wins. Authoritative per the
OpenAI Batch API spec, which requires it on every record.
2. Any other non-empty `url` -> chat. We trust the caller's explicit
signal rather than re-deriving it from the body, and an
unexpectedly-shaped body fails cleanly inside the chat
transformer instead of being silently misrouted.
3. `url` missing/empty -> fall back to body shape. `messages` wins
over the other keys so a malformed record carrying several of
them keeps its conversation instead of having it dropped, and a
bare `input` stays an embedding for backwards compatibility
(that ambiguity with `/v1/responses` is only resolvable from
`url`).
"""
url: Final = openai_jsonl_record.get("url")
if url == BedrockFilesConfig.OPENAI_EMBEDDINGS_URL:
return True
if url:
return False
body: Final = openai_jsonl_record.get("body", {})
if not isinstance(body, dict):
return False
return "input" in body and "messages" not in body
match openai_jsonl_record.get("url"):
case BedrockFilesConfig.OPENAI_EMBEDDINGS_URL:
return BedrockBatchRecordKind.EMBEDDING
case BedrockFilesConfig.OPENAI_TEXT_COMPLETIONS_URL:
return BedrockBatchRecordKind.TEXT_COMPLETION
case BedrockFilesConfig.OPENAI_RESPONSES_URL:
return BedrockBatchRecordKind.RESPONSES
case None | "":
pass
case _:
return BedrockBatchRecordKind.CHAT
body: Final = openai_jsonl_record.get("body")
if not isinstance(body, Mapping):
return BedrockBatchRecordKind.CHAT
if "messages" in body:
return BedrockBatchRecordKind.CHAT
if "prompt" in body:
return BedrockBatchRecordKind.TEXT_COMPLETION
if "input" in body:
return BedrockBatchRecordKind.EMBEDDING
return BedrockBatchRecordKind.CHAT
# Identifier for the Bedrock Titan v2 InvokeModel body schema as stored
# in `model_prices_and_context_window.json`. Centralized so future
@ -544,9 +586,84 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
)
return dict(titan_config._transform_request(input=input_text, inference_params=inference_params))
@staticmethod
def _transform_text_completion_body_to_chat_body(openai_request_body: Mapping[str, Any]) -> Mapping[str, Any]:
"""
Rewrite an OpenAI `/v1/completions` batch body as a Chat Completions body.
Bedrock batch `modelInput` is the model's InvokeModel/Converse body, and
no Bedrock batch model takes a bare `prompt`, so the wrapping that
`litellm.text_completion` does in real time has to happen here too.
"""
prompt: Final = openai_request_body.get("prompt")
if prompt is None:
raise ValueError(
"Batch record for /v1/completions is missing required `prompt` field: "
f"model={openai_request_body.get('model', '')}"
)
return _frozen_mapping(
chain(
((key, value) for key, value in openai_request_body.items() if key != "prompt"),
(("messages", text_completion_prompt_to_messages(prompt)),),
)
)
@staticmethod
def _transform_responses_body_to_chat_body(openai_request_body: Mapping[str, Any]) -> Mapping[str, Any]:
"""
Rewrite an OpenAI `/v1/responses` batch body as a Chat Completions body.
Delegates to the same Responses-to-Chat bridge the real-time path uses
for providers without a native Responses API (which is every Bedrock
model), so `input`, `instructions`, `max_output_tokens` and the tool
params translate identically in batch and real time. The bridge always
emits a `tools` key; an empty one is dropped rather than shipped as an
empty array inside `modelInput`.
"""
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
responses_input: Final = openai_request_body.get("input")
if responses_input is None:
raise ValueError(
"Batch record for /v1/responses is missing required `input` field: "
f"model={openai_request_body.get('model', '')}"
)
chat_body: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model=openai_request_body.get("model", ""),
input=_responses_input_adapter().validate_python(responses_input),
responses_api_request=_responses_request_adapter().validate_python(
_frozen_mapping(
(key, value) for key, value in openai_request_body.items() if key not in ("model", "input")
)
),
metadata=openai_request_body.get("metadata"),
)
return _frozen_mapping((key, value) for key, value in chat_body.items() if key != "tools" or value)
@staticmethod
def _transform_batch_body_to_chat_body(
openai_request_body: Mapping[str, Any],
record_kind: BedrockBatchRecordKind,
) -> Mapping[str, Any]:
"""
Normalize a non-embedding batch body to the Chat Completions shape the
per-provider Bedrock transformations expect.
"""
match record_kind:
case BedrockBatchRecordKind.TEXT_COMPLETION:
return BedrockFilesConfig._transform_text_completion_body_to_chat_body(openai_request_body)
case BedrockBatchRecordKind.RESPONSES:
return BedrockFilesConfig._transform_responses_body_to_chat_body(openai_request_body)
case BedrockBatchRecordKind.CHAT:
return openai_request_body
case BedrockBatchRecordKind.EMBEDDING:
raise ValueError("Embedding batch records do not have a chat-completion equivalent")
def _map_openai_to_bedrock_params(
self,
openai_request_body: dict[str, Any],
openai_request_body: Mapping[str, Any],
provider: str | None = None,
) -> dict[str, Any]:
"""
@ -658,14 +775,18 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
provider = self.get_bedrock_invoke_provider(model)
# Route to the embedding transformer when the OpenAI batch line
# targets /v1/embeddings; otherwise fall back to the existing
# chat-completion path. We branch here (rather than inside
# targets /v1/embeddings; every other endpoint shape is normalized
# to chat completions first. We branch here (rather than inside
# `_map_openai_to_bedrock_params`) so the chat helper keeps its
# narrow contract and the embedding helper can evolve independently.
if self._is_embedding_record(_openai_jsonl_content):
record_kind = self._classify_batch_record(_openai_jsonl_content)
if record_kind is BedrockBatchRecordKind.EMBEDDING:
model_input = self._map_openai_embedding_to_bedrock_params(openai_request_body=openai_body)
else:
model_input = self._map_openai_to_bedrock_params(openai_request_body=openai_body, provider=provider)
model_input = self._map_openai_to_bedrock_params(
openai_request_body=self._transform_batch_body_to_chat_body(openai_body, record_kind),
provider=provider,
)
# Create Bedrock batch record
record_id = _openai_jsonl_content.get("custom_id", f"CALL{str(idx).zfill(7)}")
@ -733,6 +854,10 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
content=file_content,
api_base=api_base,
optional_params=optional_params,
s3_encryption_key_id=resolve_s3_encryption_key_id(
litellm_params=litellm_params,
optional_params=optional_params,
),
)
litellm_params["upload_url"] = api_base
@ -750,6 +875,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
content: str,
api_base: str,
optional_params: dict,
s3_encryption_key_id: str | None = None,
) -> tuple[dict, str]:
"""
Sign S3 PUT request using the same proven logic as S3Logger.
@ -782,12 +908,25 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
content_hash: Final = hashlib.sha256(content.encode("utf-8")).hexdigest()
# Prepare headers with required S3 headers (same as s3_v2.py)
request_headers: Final = {
"Content-Type": "application/json", # JSONL files are JSON content
"x-amz-content-sha256": content_hash, # REQUIRED by S3
"Content-Language": "en",
"Cache-Control": "private, immutable, max-age=31536000, s-maxage=0",
}
sse_headers: Final = (
MappingProxyType(
{
"x-amz-server-side-encryption": "aws:kms",
"x-amz-server-side-encryption-aws-kms-key-id": s3_encryption_key_id,
}
)
if s3_encryption_key_id
else MappingProxyType({})
)
request_headers: Final = MappingProxyType(
{
"Content-Type": "application/json", # JSONL files are JSON content
"x-amz-content-sha256": content_hash, # REQUIRED by S3
"Content-Language": "en",
"Cache-Control": "private, immutable, max-age=31536000, s-maxage=0",
**sse_headers,
}
)
# Use requests.Request to prepare the request (same pattern as s3_v2.py)
req: Final = requests.Request("PUT", api_base, data=content, headers=request_headers)

View file

@ -8,11 +8,12 @@ import sys
import threading
import time
from collections.abc import Callable, Mapping
from http.cookiejar import CookieJar, DefaultCookiePolicy
from typing import TYPE_CHECKING, Any, Final, Optional
import certifi
import httpx
from aiohttp import ClientSession, TCPConnector
from aiohttp import ClientSession, DummyCookieJar, TCPConnector
from httpx import USE_CLIENT_DEFAULT, AsyncHTTPTransport, HTTPTransport
from httpx._types import RequestFiles
@ -144,6 +145,15 @@ def _handler_may_close_client(client_refcount: int, owns_client: bool) -> bool:
return owns_client and client_refcount <= _CLIENT_REFCOUNT_WHEN_HANDLER_IS_SOLE_REFERRER
def blocked_cookie_jar() -> CookieJar:
"""A jar that stores no response cookie and sends none, for httpx clients.
LiteLLM's outbound clients are pooled and shared by every caller, so a cookie one
upstream sets would be replayed to every other upstream on a matching domain.
"""
return CookieJar(policy=DefaultCookiePolicy(allowed_domains=()))
_STREAMING_ERROR_BODY_READ_TIMEOUT_SECONDS: Final = 5.0
_STREAMING_ERROR_BODY_READ_EXECUTOR: Final = concurrent.futures.ThreadPoolExecutor(
max_workers=50,
@ -587,6 +597,7 @@ class AsyncHTTPHandler:
verify=ssl_config,
cert=cert,
headers=default_headers,
cookies=blocked_cookie_jar(),
follow_redirects=True,
)
@ -1063,6 +1074,7 @@ class AsyncHTTPHandler:
def session_factory() -> ClientSession:
return ClientSession(
connector=TCPConnector(**transport_connector_kwargs),
cookie_jar=DummyCookieJar(),
trust_env=trust_env,
)
@ -1132,6 +1144,7 @@ class HTTPHandler:
verify=ssl_config,
cert=cert,
headers=default_headers,
cookies=blocked_cookie_jar(),
follow_redirects=True,
)

View file

@ -14,7 +14,7 @@ from litellm.llms.custom_httpx.http_handler import (
_get_httpx_client,
get_async_httpx_client,
)
from litellm.llms.vertex_ai.common_utils import get_vertex_base_url
from litellm.llms.vertex_ai.common_utils import VertexAIError, get_vertex_base_url
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM
from litellm.types.llms.openai import CreateBatchRequest
from litellm.types.llms.vertex_ai import (
@ -98,9 +98,6 @@ class VertexAIBatchPrediction(VertexLLM):
data=json.dumps(vertex_batch_request),
)
if response.status_code != 200:
raise Exception(f"Error: {response.status_code} {response.text}")
_json_response: Final = response.json()
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(
response=_json_response
@ -130,8 +127,6 @@ class VertexAIBatchPrediction(VertexLLM):
error_body[:1000],
)
raise
if response.status_code != 200:
raise Exception(f"Error: {response.status_code} {response.text}")
_json_response: Final = response.json()
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(
@ -243,7 +238,9 @@ class VertexAIBatchPrediction(VertexLLM):
)
if response.status_code != 200:
raise Exception(f"Error: {response.status_code} {response.text}")
raise VertexAIError(
status_code=response.status_code, message=f"Error: {response.status_code} {response.text}"
)
_json_response: Final = response.json()
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(
@ -293,7 +290,9 @@ class VertexAIBatchPrediction(VertexLLM):
headers=headers,
)
if response.status_code != 200:
raise Exception(f"Error: {response.status_code} {response.text}")
raise VertexAIError(
status_code=response.status_code, message=f"Error: {response.status_code} {response.text}"
)
_json_response: Final = response.json()
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(
@ -366,7 +365,9 @@ class VertexAIBatchPrediction(VertexLLM):
)
if response.status_code != 200:
raise Exception(f"Error: {response.status_code} {response.text}")
raise VertexAIError(
status_code=response.status_code, message=f"Error: {response.status_code} {response.text}"
)
_json_response: Final = response.json()
vertex_batch_response: Final = (
@ -391,7 +392,9 @@ class VertexAIBatchPrediction(VertexLLM):
params=params,
)
if response.status_code != 200:
raise Exception(f"Error: {response.status_code} {response.text}")
raise VertexAIError(
status_code=response.status_code, message=f"Error: {response.status_code} {response.text}"
)
_json_response: Final = response.json()
vertex_batch_response: Final = (
@ -461,7 +464,7 @@ class VertexAIBatchPrediction(VertexLLM):
sync_handler: Final = _get_httpx_client()
try:
response: Final = sync_handler.post(
sync_handler.post(
url=api_base,
headers=headers,
data=json.dumps({}),
@ -475,9 +478,6 @@ class VertexAIBatchPrediction(VertexLLM):
)
raise
if response.status_code != 200:
raise Exception(f"Error: {response.status_code} {response.text}")
# HTTPHandler.get() does not accept a timeout parameter
retrieve_response: Final = sync_handler.get(
url=retrieve_api_base,
@ -489,7 +489,10 @@ class VertexAIBatchPrediction(VertexLLM):
retrieve_response.status_code,
retrieve_response.text[:1000],
)
raise Exception(f"Error: {retrieve_response.status_code} {retrieve_response.text}")
raise VertexAIError(
status_code=retrieve_response.status_code,
message=f"Error: {retrieve_response.status_code} {retrieve_response.text}",
)
_json_response: Final = retrieve_response.json()
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(
@ -508,7 +511,7 @@ class VertexAIBatchPrediction(VertexLLM):
llm_provider=litellm.LlmProviders.VERTEX_AI,
)
try:
response: Final = await client.post(
await client.post(
url=api_base,
headers=headers,
data=json.dumps({}),
@ -521,8 +524,6 @@ class VertexAIBatchPrediction(VertexLLM):
e.response.text[:1000],
)
raise
if response.status_code != 200:
raise Exception(f"Error: {response.status_code} {response.text}")
# AsyncHTTPHandler.get() does not accept a timeout parameter
retrieve_response: Final = await client.get(
@ -535,7 +536,10 @@ class VertexAIBatchPrediction(VertexLLM):
retrieve_response.status_code,
retrieve_response.text[:1000],
)
raise Exception(f"Error: {retrieve_response.status_code} {retrieve_response.text}")
raise VertexAIError(
status_code=retrieve_response.status_code,
message=f"Error: {retrieve_response.status_code} {retrieve_response.text}",
)
_json_response: Final = retrieve_response.json()
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(

View file

@ -1,7 +1,9 @@
from typing import Any, Final
from urllib.parse import unquote
from litellm._uuid import uuid
from litellm.llms.vertex_ai.common_utils import (
VertexAIError,
_convert_vertex_datetime_to_openai_datetime,
)
from litellm.types.llms.openai import BatchJobStatus, CreateBatchRequest
@ -199,16 +201,40 @@ class VertexAIBatchTransformation:
gcs_file_uri format: gs://litellm-testing-bucket/litellm-vertex-files/publishers/google/models/gemini-1.5-flash-001/e9412502-2c91-42a6-8e61-f5c294cc0fc8
returns: "publishers/google/models/gemini-1.5-flash-001"
Raises a 400 `VertexAIError` when the uri carries no parseable model path.
"""
from urllib.parse import unquote
decoded_uri: Final = unquote(gcs_file_uri)
model_path: Final = decoded_uri.split("publishers/")[1]
parts: Final = model_path.split("/")
model: Final = f"publishers/{'/'.join(parts[:3])}"
model: Final = cls._parse_model_from_gcs_file(gcs_file_uri)
if model is None:
raise VertexAIError(
status_code=400,
message=(
"Vertex AI batch creation requires the model to be part of `input_file_id`, but "
f"'{gcs_file_uri}' contains no 'publishers/<publisher>/models/<model>' path segment. "
"Either upload the input file through LiteLLM (POST /v1/files with "
"custom_llm_provider=vertex_ai), which encodes the model into the returned file id, or "
"pass a uri of the form "
"gs://<bucket>/<prefix>/publishers/<publisher>/models/<model>/<file>"
),
)
return model
@classmethod
def _parse_model_from_gcs_file(cls, gcs_file_uri: str) -> str | None:
"""
Returns the `publishers/<publisher>/models/<model>` path from a gcs uri, or None if the uri
does not contain one.
"""
_, separator, model_path = unquote(gcs_file_uri).partition("publishers/")
if not separator:
return None
parts: Final = model_path.split("/")
if len(parts) < 3 or parts[1] != "models" or not parts[2]:
return None
return f"publishers/{'/'.join(parts[:3])}"
@classmethod
def is_unmanaged_gcs_batch_input_file_id(cls, input_file_id: str | None) -> bool:
"""
@ -216,7 +242,11 @@ class VertexAIBatchTransformation:
LiteLLM-managed unified file id) with a `publishers/` model path that
`_get_model_from_gcs_file` can parse.
"""
return input_file_id is not None and input_file_id.startswith("gs://") and "publishers/" in input_file_id
return (
input_file_id is not None
and input_file_id.startswith("gs://")
and cls._parse_model_from_gcs_file(input_file_id) is not None
)
@classmethod
def get_bare_model_name_from_gcs_file(cls, gcs_file_uri: str) -> str:

View file

@ -109,7 +109,7 @@ if MCP_AVAILABLE:
############ MCP Server REST API Routes #################
async def _safe_fire_mcp_tool_call_logging(
logging_obj: Any | None,
result: Any,
result: "CallToolResult",
start_time: datetime,
end_time: datetime,
user_api_key_auth: UserAPIKeyAuth | None = None,

View file

@ -13,9 +13,9 @@ import time
import traceback
import types
import uuid
from collections.abc import AsyncIterator, Callable, Mapping
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
from datetime import datetime
from typing import TYPE_CHECKING, Any, Final, cast
from typing import TYPE_CHECKING, Any, Final, Protocol
import httpx
from fastapi import FastAPI, HTTPException
@ -145,7 +145,7 @@ try:
)
# Robust auth lookup keyed by session_object.
_session_obj_auth_storage: "weakref.WeakKeyDictionary[Any, MCPAuthenticatedUser]" = weakref.WeakKeyDictionary()
_session_obj_auth_storage: "weakref.WeakKeyDictionary[object, MCPAuthenticatedUser]" = weakref.WeakKeyDictionary()
except ImportError as e:
verbose_logger.debug("MCP module not found: %s", e)
MCP_AVAILABLE = False
@ -493,14 +493,14 @@ if MCP_AVAILABLE:
def _gateway_create_initialization_options(
self,
notification_options: NotificationOptions | None = None,
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
experimental_capabilities: dict[str, dict[str, object]] | None = None,
) -> InitializationOptions:
opts: Final = Server.create_initialization_options(
self,
notification_options=notification_options,
experimental_capabilities=experimental_capabilities or {},
)
updates: Final[dict[str, Any]] = {}
updates: Final[dict[str, str]] = {}
merged: Final = _mcp_gateway_initialize_instructions.get()
if merged is not None:
updates["instructions"] = merged
@ -549,6 +549,17 @@ if MCP_AVAILABLE:
_stateful_session_locks: Final[dict[str, asyncio.Lock]] = {}
_stateful_session_active_request_counts: Final[dict[str, int]] = {}
class _TerminableTransport(Protocol):
async def terminate(self) -> None: ...
class _TransportRegistry(Protocol):
def __contains__(self, session_id: object, /) -> bool: ...
def pop(self, session_id: str, default: None, /) -> "_TerminableTransport | None": ...
def _stateful_server_instances() -> _TransportRegistry:
return getattr(session_manager_stateful, "_server_instances", {})
def _remove_stateful_session_tracking(session_id: str) -> None:
_stateful_session_auth_contexts.pop(session_id, None)
_stateful_session_auth_context_last_seen.pop(session_id, None)
@ -578,8 +589,8 @@ if MCP_AVAILABLE:
) -> None:
"""Terminate expired stateful sessions and drop their auth contexts."""
now = time.monotonic() if now is None else now
server_instances: Final = getattr(session_manager_stateful, "_server_instances", {})
expired_session_ids: Final = []
server_instances: Final = _stateful_server_instances()
expired_session_ids: Final[list[str]] = []
for session_id, last_seen in _stateful_session_auth_context_last_seen.items():
if _stateful_session_active_request_counts.get(session_id, 0) > 0:
continue
@ -619,7 +630,7 @@ if MCP_AVAILABLE:
session may proceed, or ``False`` when the caller is already at the cap
with every session in flight (the new ``initialize`` should be rejected).
"""
server_instances: Final = getattr(session_manager_stateful, "_server_instances", {})
server_instances: Final = _stateful_server_instances()
def _owned_live_session_ids() -> list[str]:
return [
@ -778,7 +789,7 @@ if MCP_AVAILABLE:
get_virtual_tool_definitions,
)
return [Tool(**d) for d in get_virtual_tool_definitions()]
return [Tool.model_validate(d) for d in get_virtual_tool_definitions()]
# Get mcp_servers from context variable
verbose_logger.debug("MCP list_tools - Calling _list_mcp_tools")
@ -847,7 +858,7 @@ if MCP_AVAILABLE:
async def _build_virtual_call_logging_obj(
name: str,
arguments: dict[str, Any],
arguments: dict[str, object],
user_api_key_auth: UserAPIKeyAuth,
) -> LiteLLMLoggingObj | None:
"""Run the pre-call pipeline (guardrails + logging setup) for a virtual
@ -885,7 +896,7 @@ if MCP_AVAILABLE:
async def _dispatch_virtual_mcp_tool(
name: str,
arguments: dict[str, Any] | None,
arguments: dict[str, object] | None,
user_api_key_auth: UserAPIKeyAuth | None,
client_ip: str | None,
mcp_servers: list[str] | None = None,
@ -957,7 +968,7 @@ if MCP_AVAILABLE:
)
@server.call_tool()
async def mcp_server_tool_call(name: str, arguments: dict[str, Any] | None) -> CallToolResult:
async def mcp_server_tool_call(name: str, arguments: dict[str, object] | None) -> CallToolResult:
"""
Call a specific tool with the provided arguments
Args:
@ -1621,7 +1632,7 @@ if MCP_AVAILABLE:
async def _get_user_oauth_extra_headers_from_db(
server: MCPServer,
user_api_key_auth: UserAPIKeyAuth | None,
prefetched_creds: dict[str, dict[str, Any]] | None = None,
prefetched_creds: 'Mapping[str, "OAuthCredentialPayload"] | None' = None,
) -> dict[str, str] | None:
"""Stored OAuth2 token for (user, server) as an ``Authorization: Bearer`` header, or None.
@ -1646,7 +1657,7 @@ if MCP_AVAILABLE:
Returns a dict keyed by server_id to avoid N+1 queries in asyncio.gather loops.
"""
user_id: Final = getattr(user_api_key_auth, "user_id", None) if user_api_key_auth else None
user_id: Final[str | None] = getattr(user_api_key_auth, "user_id", None) if user_api_key_auth else None
if not user_id:
return {}
try:
@ -1871,7 +1882,7 @@ if MCP_AVAILABLE:
list_tools_start_time: Final = datetime.now()
litellm_logging_obj: LiteLLMLoggingObj | None = None
list_tools_request_data: dict[str, Any] = {}
list_tools_request_data: dict[str, object] = {}
if log_list_tools_to_spendlogs:
# This is intentionally minimal: only async_success_handler / post_call_failure_hook
@ -1879,7 +1890,7 @@ if MCP_AVAILABLE:
list_tools_call_id: Final = str(uuid.uuid4())
# Derive trace_id from raw_headers when not explicitly passed (same as A2A / MCP call_tool)
effective_litellm_trace_id: Final = litellm_trace_id or get_chain_id_from_headers(raw_headers)
spend_logs_metadata: Final[dict[str, Any]] = {
spend_logs_metadata: Final[dict[str, object]] = {
"mcp_operation": "list_tools",
}
if isinstance(list_tools_log_source, str):
@ -2615,7 +2626,7 @@ if MCP_AVAILABLE:
async def execute_mcp_tool(
name: str,
arguments: dict[str, Any],
arguments: dict[str, object],
allowed_mcp_servers: list[MCPServer],
start_time: datetime,
user_api_key_auth: UserAPIKeyAuth | None = None,
@ -2882,7 +2893,7 @@ if MCP_AVAILABLE:
_request_auth_header.reset(_auth_token)
_request_extra_headers.reset(_extra_token)
_request_resolved_auth_headers.reset(_resolved_token)
response = CallToolResult(content=cast(Any, local_content), isError=False)
response = CallToolResult(content=local_content, isError=False)
# Try managed MCP server tool (the name is bare; the prefix boundary was
# already resolved above against this server's registered prefixes)
@ -2956,7 +2967,7 @@ if MCP_AVAILABLE:
arguments = hook_result["arguments"] # pyright: ignore[reportAny] # hook returns untyped args
local_content = await _handle_local_mcp_tool(original_tool_name, arguments)
response = CallToolResult(content=cast(Any, local_content), isError=False)
response = CallToolResult(content=local_content, isError=False)
return await _run_post_mcp_call_guardrails(
result=response,
@ -3003,7 +3014,7 @@ if MCP_AVAILABLE:
async def _fire_mcp_tool_call_logging(
logging_obj: LiteLLMLoggingObj,
result: Any,
result: CallToolResult,
start_time: datetime,
end_time: datetime,
user_api_key_auth: UserAPIKeyAuth | None = None,
@ -3070,7 +3081,7 @@ if MCP_AVAILABLE:
@client
async def call_mcp_tool(
name: str,
arguments: dict[str, Any] | None = None,
arguments: dict[str, object] | None = None,
user_api_key_auth: UserAPIKeyAuth | None = None,
mcp_auth_header: str | None = None,
mcp_servers: list[str] | None = None,
@ -3161,7 +3172,7 @@ if MCP_AVAILABLE:
async def mcp_get_prompt(
name: str,
arguments: dict[str, Any] | None = None,
arguments: dict[str, object] | None = None,
user_api_key_auth: UserAPIKeyAuth | None = None,
mcp_auth_header: str | None = None,
mcp_servers: list[str] | None = None,
@ -3262,7 +3273,7 @@ if MCP_AVAILABLE:
def _get_standard_logging_mcp_tool_call(
name: str,
arguments: dict[str, Any],
arguments: dict[str, object],
server_name: str | None,
session_id: str | None = None,
) -> StandardLoggingMCPToolCall:
@ -3291,13 +3302,13 @@ if MCP_AVAILABLE:
async def _handle_managed_mcp_tool(
server_name: str,
name: str,
arguments: dict[str, Any],
arguments: dict[str, object],
user_api_key_auth: UserAPIKeyAuth | None = None,
mcp_auth_header: str | None = None,
mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
oauth2_headers: dict[str, str] | None = None,
raw_headers: dict[str, str] | None = None,
litellm_logging_obj: Any | None = None,
litellm_logging_obj: LiteLLMLoggingObj | None = None,
host_progress_callback: Callable | None = None,
) -> CallToolResult:
"""Handle tool execution for managed server tools"""
@ -3320,7 +3331,7 @@ if MCP_AVAILABLE:
return call_tool_result
async def _handle_local_mcp_tool(
name: str, arguments: dict[str, Any]
name: str, arguments: dict[str, object]
) -> list[TextContent | ImageContent | EmbeddedResource]:
"""
Handle tool execution for local registry tools
@ -3426,7 +3437,8 @@ if MCP_AVAILABLE:
Extract mcp-session-id from ASGI scope headers.
Returns None if not present.
"""
for header_name, header_value in scope.get("headers", []):
scope_headers: Final[Sequence[tuple[bytes | str, bytes | str]]] = scope.get("headers", [])
for header_name, header_value in scope_headers:
name = header_name if isinstance(header_name, bytes) else header_name.encode()
if name.lower() == b"mcp-session-id":
return header_value.decode() if isinstance(header_value, bytes) else str(header_value)
@ -3528,7 +3540,7 @@ if MCP_AVAILABLE:
if message.get("type") != "http.request":
break
body = message.get("body", b"") or b""
body: bytes = message.get("body", b"") or b""
if body:
# Only retain up to the remaining peek budget for sniffing.
# The full ``message`` is already in memory (delivered by
@ -3571,9 +3583,9 @@ if MCP_AVAILABLE:
Fixes https://github.com/BerriAI/litellm/issues/20992
"""
_mcp_session_header: Final = b"mcp-session-id"
_headers: Final = scope.get("headers", [])
_headers: Final[Sequence[tuple[bytes | str, bytes | str]]] = scope.get("headers", [])
def _normalize_header_name(header_name: Any) -> bytes | None:
def _normalize_header_name(header_name: object) -> bytes | None:
if isinstance(header_name, bytes):
return header_name.lower()
if isinstance(header_name, str):
@ -3902,7 +3914,8 @@ if MCP_AVAILABLE:
def _get_authorization_header_from_scope(scope: Scope) -> str | None:
"""First ``Authorization`` header value in the ASGI scope, or None."""
for key, value in scope.get("headers", []):
scope_headers: Final[Sequence[tuple[bytes, bytes]]] = scope.get("headers", [])
for key, value in scope_headers:
if key.lower() == b"authorization":
return value.decode("latin-1")
return None
@ -3921,7 +3934,8 @@ if MCP_AVAILABLE:
``MCPRequestHandler.process_mcp_request``), and forwarding it upstream
would leak the proxy key to a third-party MCP server.
"""
has_litellm_key_header: Final = any(key.lower() == b"x-litellm-api-key" for key, _ in scope.get("headers", []))
scope_headers: Final[Sequence[tuple[bytes, bytes]]] = scope.get("headers", [])
has_litellm_key_header: Final = any(key.lower() == b"x-litellm-api-key" for key, _ in scope_headers)
if not has_litellm_key_header:
return None
return _get_authorization_header_from_scope(scope)
@ -4115,7 +4129,7 @@ if MCP_AVAILABLE:
async def handle_streamable_http_mcp(scope: Scope, receive: Receive, send: Send) -> None:
"""Handle MCP requests through StreamableHTTP."""
try:
path: Final = scope.get("path", "")
path: Final[str] = scope.get("path", "")
(
user_api_key_auth,
mcp_auth_header,
@ -4135,7 +4149,8 @@ if MCP_AVAILABLE:
)
# Strip any client-supplied x-mcp-toolset-id to prevent forgery.
scope["headers"] = [(k, v) for k, v in scope.get("headers", []) if k.lower() != b"x-mcp-toolset-id"]
scope_headers: Final[Sequence[tuple[bytes, bytes]]] = scope.get("headers", [])
scope["headers"] = [(k, v) for k, v in scope_headers if k.lower() != b"x-mcp-toolset-id"]
# Apply toolset scope if set server-side via ContextVar (set by
# /toolset/{name}/mcp and /{name}/mcp route handlers in proxy_server.py).
@ -4436,7 +4451,7 @@ if MCP_AVAILABLE:
async def handle_sse_mcp(scope: Scope, receive: Receive, send: Send) -> None:
"""Handle MCP requests through SSE."""
try:
path: Final = scope.get("path", "")
path: Final[str] = scope.get("path", "")
(
user_api_key_auth,
mcp_auth_header,
@ -4456,7 +4471,8 @@ if MCP_AVAILABLE:
)
# Strip any client-supplied x-mcp-toolset-id to prevent forgery.
scope["headers"] = [(k, v) for k, v in scope.get("headers", []) if k.lower() != b"x-mcp-toolset-id"]
scope_headers: Final[Sequence[tuple[bytes, bytes]]] = scope.get("headers", [])
scope["headers"] = [(k, v) for k, v in scope_headers if k.lower() != b"x-mcp-toolset-id"]
# Apply toolset scope if set server-side via ContextVar so the
# downstream probe list matches the fully-authorized server set
@ -4680,7 +4696,8 @@ if MCP_AVAILABLE:
) -> Send:
async def wrapped_send(message: Message) -> None:
if message.get("type") == "http.response.start":
for key, value in message.get("headers", []):
response_headers: Final[Sequence[tuple[bytes | str, bytes | str]]] = message.get("headers", [])
for key, value in response_headers:
header_name = key if isinstance(key, bytes) else str(key).encode()
if header_name.lower() == b"mcp-session-id":
session_id = value.decode() if isinstance(value, bytes) else str(value)

View file

@ -2488,6 +2488,16 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
"is active as a reminder that hard enforcement is relaxed."
),
)
apply_user_budget_to_team_keys: bool | None = Field(
None,
description=(
"If True, a user's personal max_budget is enforced on every request they "
"make, including requests made with a team-scoped key. Defaults to False, "
"where a team-scoped key is governed only by the team and team-member "
"budgets and the key owner's personal max_budget does not apply "
"(see GitHub issue #12905)."
),
)
user_url_validation: bool | None = Field(
None,
description=(
@ -3884,12 +3894,19 @@ class OrganizationMemberUpdateResponse(MemberUpdateResponse):
##########################################
class TeamAccessGroupModelGrant(LiteLLMPydanticObjectBase):
access_group_id: str
access_group_name: str
models: tuple[str, ...]
class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable):
team_member_budget_table: LiteLLM_BudgetTableFull | None = None
# Resources inherited from access groups (separate from direct assignments)
access_group_models: list[str] | None = None
access_group_mcp_server_ids: list[str] | None = None
access_group_agent_ids: list[str] | None = None
access_group_details: tuple[TeamAccessGroupModelGrant, ...] | None = None
class TeamInfoResponseObject(TypedDict):
@ -4540,10 +4557,10 @@ class DefaultInternalUserParams(LiteLLMPydanticObjectBase):
user_role: (
Literal[
LitellmUserRoles.INTERNAL_USER,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
LitellmUserRoles.PROXY_ADMIN,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
LitellmUserRoles.INTERNAL_USER,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
]
| None
) = Field(

View file

@ -1,8 +1,10 @@
import asyncio
import hashlib
import json
from collections.abc import Iterator, Mapping, Sequence
from datetime import datetime, timezone
from typing import Any, Final, Protocol, TypedDict
from types import MappingProxyType
from typing import Any, Final, NamedTuple, Protocol, TypedDict
import litellm
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
@ -10,7 +12,7 @@ from litellm.proxy.management_helpers.object_permission_utils import (
handle_update_object_permission_common,
)
from litellm.proxy.utils import PrismaClient
from litellm.repositories.table_repositories import AgentsRepository
from litellm.repositories.table_repositories import AgentsRepository, ObjectPermissionRepository
from litellm.types.agents import AgentConfig, AgentResponse, PatchAgentRequest
@ -86,10 +88,32 @@ def agents_table(prisma_client: PrismaClient) -> AgentTableClient:
return table
class ObjectPermissionGrantRecord(Protocol):
object_permission_id: str
agents: list[str] | None
class ObjectPermissionTableClient(Protocol):
async def find_many(self, where: Mapping[str, object]) -> Sequence[ObjectPermissionGrantRecord]: ...
async def update_many(self, where: Mapping[str, object], data: Mapping[str, object]) -> int: ...
def object_permission_table(prisma_client: PrismaClient) -> ObjectPermissionTableClient:
table: Final[ObjectPermissionTableClient] = ObjectPermissionRepository(prisma_client).table
return table
class GrantMigrationResult(NamedTuple):
rewritten: int
missed: int
class AgentRegistry:
def __init__(self):
self.agent_list: list[AgentResponse] = []
self.config_agents: tuple[AgentConfig, ...] = ()
self.config_agent_legacy_ids: Mapping[str, str] = MappingProxyType({})
def reset_agent_list(self):
self.agent_list = []
@ -100,23 +124,33 @@ class AgentRegistry:
def deregister_agent(self, agent_name: str):
self.agent_list = [agent for agent in self.agent_list if agent.agent_name != agent_name]
def get_agent_list(self, agent_names: Sequence[str] | None = None):
def get_agent_list(self, agent_names: Sequence[str] | None = None) -> tuple[AgentResponse, ...]:
if agent_names is not None:
return [agent for agent in self.agent_list if agent.agent_name in agent_names]
return self.agent_list
return tuple(agent for agent in self.agent_list if agent.agent_name in agent_names)
return tuple(self.agent_list)
def get_public_agent_list(self) -> list[AgentResponse]:
public_agent_list: Final[list[AgentResponse]] = []
if litellm.public_agent_groups is None:
return public_agent_list
for agent in self.agent_list:
if agent.agent_id in litellm.public_agent_groups:
public_agent_list.append(agent)
return public_agent_list
def get_public_agent_list(self) -> tuple[AgentResponse, ...]:
public_agent_groups: Final = litellm.public_agent_groups
if public_agent_groups is None:
return ()
return tuple(
agent for agent in self.agent_list if not self.ids_for_agent(agent.agent_id).isdisjoint(public_agent_groups)
)
def _create_agent_id(self, agent_config: AgentConfig) -> str:
return hashlib.sha256(agent_config["agent_name"].encode()).hexdigest()
def _create_legacy_agent_id(self, agent_config: AgentConfig) -> str:
return hashlib.sha256(json.dumps(agent_config, sort_keys=True).encode()).hexdigest()
def ids_for_agent(self, agent_id: str) -> frozenset[str]:
return frozenset(
{agent_id, *(legacy for legacy, stable in self.config_agent_legacy_ids.items() if stable == agent_id)}
)
def stable_agent_id(self, agent_id: str) -> str:
return self.config_agent_legacy_ids.get(agent_id, agent_id)
def load_agents_from_config(self, agent_config: Sequence[AgentConfig] | None = None):
"""
Register the agents declared in config.yaml and remember them for later rebuilds.
@ -131,12 +165,20 @@ class AgentRegistry:
if agent_config is None:
return
self.config_agents = tuple(agent_config)
for agent_config_item in agent_config:
if not isinstance(agent_config_item, dict):
raise ValueError("agent_config must be a list of dictionaries")
self.config_agents = tuple(agent_config)
self.config_agent_legacy_ids = MappingProxyType(
{
self._create_legacy_agent_id(agent_config_item): self._create_agent_id(agent_config_item)
for agent_config_item in agent_config
if agent_config_item.get("agent_name") and agent_config_item.get("agent_card_params")
}
)
for agent_config_item in agent_config:
agent_name = agent_config_item.get("agent_name")
agent_card_params = agent_config_item.get("agent_card_params")
if not all([agent_name, agent_card_params]):
@ -180,6 +222,45 @@ class AgentRegistry:
self.load_agents_from_config(agent_config if agent_config is not None else self.config_agents)
return self.agent_list
async def migrate_legacy_grant_ids(self, table: ObjectPermissionTableClient) -> GrantMigrationResult:
"""
Rewrite object_permission.agents rows holding a legacy full-entry hash to the
stable name-derived id.
Only the running proxy can do this: the legacy hash is computed from the
resolved config entry (secrets included), so no SQL migration can know it.
Persisting the stable id here is what keeps a grant alive across a later
secret rotation, which re-mints the legacy hash and would otherwise orphan
the stored value. Idempotent; runs of it after the first find no rows.
Each write is a compare-and-swap against the agents array read above, so a
grant edited concurrently is left untouched; the runtime alias keeps covering
it and the next boot retries the rewrite.
"""
legacy_ids: Final = tuple(legacy for legacy, stable in self.config_agent_legacy_ids.items() if legacy != stable)
if not legacy_ids:
return GrantMigrationResult(rewritten=0, missed=0)
rows: Final = await table.find_many(where={"agents": {"has_some": legacy_ids}})
updates: Final = tuple(
(
row.object_permission_id,
tuple(row.agents or ()),
tuple(dict.fromkeys(self.stable_agent_id(agent_id) for agent_id in row.agents or ())),
)
for row in rows
)
counts: Final = await asyncio.gather(
*(
table.update_many(
where={"object_permission_id": object_permission_id, "agents": {"equals": snapshot_agents}},
data={"agents": translated_agents},
)
for object_permission_id, snapshot_agents, translated_agents in updates
)
)
rewritten: Final = sum(counts)
return GrantMigrationResult(rewritten=rewritten, missed=len(updates) - rewritten)
###########################################################
########### DB management helpers for agents ###########
############################################################
@ -492,6 +573,14 @@ class AgentRegistry:
if agent.agent_id == agent_id:
return agent
translated_id: Final = self.config_agent_legacy_ids.get(agent_id)
if translated_id is None:
return None
for agent in self.agent_list:
if agent.agent_id == translated_id:
return agent
return None
except Exception as e:
raise Exception(f"Error getting agent from DB: {e}")

View file

@ -5,7 +5,8 @@ Handles agent permission checking for keys and teams using object_permission_id.
Follows the same pattern as MCP permission handling.
"""
from typing import Final
from dataclasses import dataclass
from typing import Final, TypeAlias
from litellm._logging import verbose_logger
from litellm.proxy._types import (
@ -17,6 +18,27 @@ from litellm.proxy._types import (
from litellm.repositories.table_repositories import AgentsRepository
@dataclass(frozen=True, slots=True)
class UnrestrictedAgentAccess:
"""No agent grant exists on the key or its team, so every agent is reachable."""
@dataclass(frozen=True, slots=True)
class RestrictedAgentAccess:
"""Only ``agent_ids`` are reachable. An empty set denies every agent."""
agent_ids: frozenset[str]
AgentAccess: TypeAlias = UnrestrictedAgentAccess | RestrictedAgentAccess
def _to_stable_ids(agent_ids: frozenset[str]) -> frozenset[str]:
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
return frozenset(global_agent_registry.stable_agent_id(agent_id) for agent_id in agent_ids)
class AgentRequestHandler:
"""
Class to handle agent permission checking, including:
@ -32,37 +54,32 @@ class AgentRequestHandler:
"""
@staticmethod
async def get_allowed_agents(
async def resolve_agent_access(
user_api_key_auth: UserAPIKeyAuth | None = None,
) -> list[str]:
) -> AgentAccess:
"""
Get list of allowed agent IDs for the given user/key based on permissions.
Resolve the agents the given user/key may reach.
Returns:
List[str]: List of allowed agent IDs. Empty list means no restrictions (allow all).
``UnrestrictedAgentAccess`` is only returned when neither the key nor its team
carries any grant. Grants that intersect to nothing stay restricted, so
narrowing a caller can never widen what it reaches.
"""
try:
allowed_agents: list[str] = []
allowed_agents_for_key: Final = await AgentRequestHandler._get_allowed_agents_for_key(user_api_key_auth)
allowed_agents_for_team: Final = await AgentRequestHandler._get_allowed_agents_for_team(user_api_key_auth)
key_access: Final = await AgentRequestHandler._get_allowed_agents_for_key(user_api_key_auth)
team_access: Final = await AgentRequestHandler._get_allowed_agents_for_team(user_api_key_auth)
# If team has agent restrictions, handle inheritance and intersection logic
if len(allowed_agents_for_team) > 0:
if len(allowed_agents_for_key) > 0:
# Key has its own agent permissions - use intersection with team permissions
for agent_id in allowed_agents_for_key:
if agent_id in allowed_agents_for_team:
allowed_agents.append(agent_id)
else:
# Key has no agent permissions - inherit from team
allowed_agents = allowed_agents_for_team
else:
allowed_agents = allowed_agents_for_key
return list(set(allowed_agents))
match (key_access, team_access):
case (UnrestrictedAgentAccess(), UnrestrictedAgentAccess()):
return UnrestrictedAgentAccess()
case (UnrestrictedAgentAccess(), RestrictedAgentAccess(team_ids)):
return RestrictedAgentAccess(_to_stable_ids(team_ids))
case (RestrictedAgentAccess(key_ids), UnrestrictedAgentAccess()):
return RestrictedAgentAccess(_to_stable_ids(key_ids))
case (RestrictedAgentAccess(key_ids), RestrictedAgentAccess(team_ids)):
return RestrictedAgentAccess(_to_stable_ids(key_ids) & _to_stable_ids(team_ids))
except Exception as e:
verbose_logger.warning("Failed to get allowed agents: %s", e)
return []
return UnrestrictedAgentAccess()
@staticmethod
async def is_agent_allowed(
@ -79,13 +96,14 @@ class AgentRequestHandler:
Returns:
bool: True if agent is allowed, False otherwise
"""
allowed_agents: Final = await AgentRequestHandler.get_allowed_agents(user_api_key_auth)
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
# Empty list means no restrictions - allow all
if len(allowed_agents) == 0:
return True
return agent_id in allowed_agents
match await AgentRequestHandler.resolve_agent_access(user_api_key_auth):
case UnrestrictedAgentAccess():
return True
case RestrictedAgentAccess(allowed_agent_ids):
stable_id: Final = global_agent_registry.stable_agent_id(agent_id)
return not global_agent_registry.ids_for_agent(stable_id).isdisjoint(allowed_agent_ids)
@staticmethod
def _get_key_object_permission(
@ -139,55 +157,58 @@ class AgentRequestHandler:
@staticmethod
async def _get_allowed_agents_for_key(
user_api_key_auth: UserAPIKeyAuth | None = None,
) -> list[str]:
) -> AgentAccess:
"""
Get allowed agents for a key.
1. First checks native key-level agent permissions (object_permission)
2. Also includes agents from key's access_group_ids (unified access groups)
A key that declares agents or access groups is restricted even when those
declarations resolve to nothing, so an emptied or deleted access group denies
rather than opening the key up. Lookup failures still propagate to the caller,
which keeps them fail-open.
Note: object_permission is already loaded by get_key_object() in main auth flow.
"""
if user_api_key_auth is None:
return []
return UnrestrictedAgentAccess()
try:
all_agents: list[str] = []
# 1. Get agents from object_permission (native permissions)
key_object_permission: Final = AgentRequestHandler._get_key_object_permission(user_api_key_auth)
if key_object_permission is not None:
# Get direct agents
direct_agents: Final = key_object_permission.agents or []
# Get agents from access groups
access_group_agents: Final = await AgentRequestHandler._get_agents_from_access_groups(
key_object_permission.agent_access_groups or []
)
all_agents = direct_agents + access_group_agents
direct_agents: Final = tuple(
key_object_permission.agents or () if key_object_permission is not None else ()
)
declared_access_groups: Final = tuple(
key_object_permission.agent_access_groups or () if key_object_permission is not None else ()
)
# 2. Fallback: get agent IDs from key's access_group_ids (unified access groups)
key_access_group_ids: Final = user_api_key_auth.access_group_ids or []
if key_access_group_ids:
from litellm.proxy.auth.auth_checks import (
_get_agent_ids_from_access_groups,
)
key_access_group_ids: Final = tuple(user_api_key_auth.access_group_ids or ())
unified_agents: Final = await _get_agent_ids_from_access_groups(
access_group_ids=key_access_group_ids,
)
all_agents.extend(unified_agents)
if not direct_agents and not declared_access_groups and not key_access_group_ids:
return UnrestrictedAgentAccess()
return list(set(all_agents))
access_group_agents: Final = (
tuple(await AgentRequestHandler._get_agents_from_access_groups(list(declared_access_groups)))
if declared_access_groups
else ()
)
unified_agents: Final = (
tuple(await AgentRequestHandler._get_unified_access_group_agents(list(key_access_group_ids)))
if key_access_group_ids
else ()
)
return RestrictedAgentAccess(frozenset(direct_agents + access_group_agents + unified_agents))
except Exception as e:
verbose_logger.warning("Failed to get allowed agents for key: %s", e)
return []
return UnrestrictedAgentAccess()
@staticmethod
async def _get_allowed_agents_for_team(
user_api_key_auth: UserAPIKeyAuth | None = None,
) -> list[str]:
) -> AgentAccess:
"""
Get allowed agents for a team.
@ -195,12 +216,13 @@ class AgentRequestHandler:
2. Also includes agents from team's access_group_ids (unified access groups)
Fetches the team object once and reuses it for both permission sources.
Declared-but-empty grants stay restricted; see `_get_allowed_agents_for_key`.
"""
if user_api_key_auth is None:
return []
return UnrestrictedAgentAccess()
if user_api_key_auth.team_id is None:
return []
return UnrestrictedAgentAccess()
try:
from litellm.proxy.auth.auth_checks import get_team_object
@ -211,7 +233,7 @@ class AgentRequestHandler:
)
if not prisma_client:
return []
return UnrestrictedAgentAccess()
# Fetch the team object once for both permission sources
team_obj: Final = await get_team_object(
@ -223,42 +245,38 @@ class AgentRequestHandler:
)
if team_obj is None:
return []
all_agents: list[str] = []
return UnrestrictedAgentAccess()
# 1. Get agents from object_permission (native permissions)
object_permissions: Final = team_obj.object_permission
if object_permissions is not None:
# Get direct agents
direct_agents: Final = object_permissions.agents or []
# Get agents from access groups
access_group_agents: Final = await AgentRequestHandler._get_agents_from_access_groups(
object_permissions.agent_access_groups or []
)
all_agents = direct_agents + access_group_agents
direct_agents: Final = tuple(object_permissions.agents or () if object_permissions is not None else ())
declared_access_groups: Final = tuple(
object_permissions.agent_access_groups or () if object_permissions is not None else ()
)
# 2. Also include agents from team's access_group_ids (unified access groups)
team_access_group_ids: Final = team_obj.access_group_ids or []
if team_access_group_ids:
from litellm.proxy.auth.auth_checks import (
_get_agent_ids_from_access_groups,
)
team_access_group_ids: Final = tuple(team_obj.access_group_ids or ())
unified_agents: Final = await _get_agent_ids_from_access_groups(
access_group_ids=team_access_group_ids,
)
all_agents.extend(unified_agents)
if not direct_agents and not declared_access_groups and not team_access_group_ids:
return UnrestrictedAgentAccess()
return list(set(all_agents))
access_group_agents: Final = (
tuple(await AgentRequestHandler._get_agents_from_access_groups(list(declared_access_groups)))
if declared_access_groups
else ()
)
unified_agents: Final = (
tuple(await AgentRequestHandler._get_unified_access_group_agents(list(team_access_group_ids)))
if team_access_group_ids
else ()
)
return RestrictedAgentAccess(frozenset(direct_agents + access_group_agents + unified_agents))
except Exception as e:
# litellm-dashboard is the default UI team and will never have agents;
# skip noisy warnings for it.
if user_api_key_auth.team_id != UI_TEAM_ID:
verbose_logger.warning("Failed to get allowed agents for team: %s", e)
return []
return UnrestrictedAgentAccess()
@staticmethod
def _get_config_agent_ids_for_access_groups(config_agents: list, access_groups: list[str]) -> set[str]:
@ -277,18 +295,26 @@ class AgentRequestHandler:
async def _get_db_agent_ids_for_access_groups(prisma_client, access_groups: list[str]) -> set[str]:
"""
Helper to get agent_ids from DB agents that match any of the given access groups.
Query failures propagate so the caller can tell "this group is empty" (deny)
apart from "the lookup failed" (fail-open).
"""
agent_ids: Final[set[str]] = set()
if access_groups and prisma_client is not None:
try:
agents: Final = await AgentsRepository(prisma_client).table.find_many(
where={"agent_access_groups": {"hasSome": access_groups}}
)
for agent in agents:
agent_ids.add(agent.agent_id)
except Exception as e:
verbose_logger.debug("Error getting agents from access groups: %s", e)
return agent_ids
if not access_groups or prisma_client is None:
return set()
agents: Final = await AgentsRepository(prisma_client).table.find_many(
where={"agent_access_groups": {"hasSome": access_groups}}
)
return {agent.agent_id for agent in agents}
@staticmethod
async def _get_unified_access_group_agents(access_group_ids: list[str]) -> list[str]:
"""
Resolve unified access group ids to agent IDs.
"""
from litellm.proxy.auth.auth_checks import _get_agent_ids_from_access_groups
return await _get_agent_ids_from_access_groups(access_group_ids=access_group_ids)
@staticmethod
async def _get_agents_from_access_groups(
@ -300,20 +326,17 @@ class AgentRequestHandler:
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
from litellm.proxy.proxy_server import prisma_client
try:
# Use the helper for config-loaded agents
agent_ids: Final = AgentRequestHandler._get_config_agent_ids_for_access_groups(
global_agent_registry.agent_list, access_groups
)
# Use the helper for config-loaded agents
config_agent_ids: Final = AgentRequestHandler._get_config_agent_ids_for_access_groups(
global_agent_registry.agent_list, access_groups
)
# Use the helper for DB agents
db_agent_ids = await AgentRequestHandler._get_db_agent_ids_for_access_groups(prisma_client, access_groups)
agent_ids.update(db_agent_ids)
# Use the helper for DB agents
db_agent_ids: Final = await AgentRequestHandler._get_db_agent_ids_for_access_groups(
prisma_client, access_groups
)
return list(agent_ids)
except Exception as e:
verbose_logger.warning("Failed to get agents from access groups: %s", e)
return []
return list(config_agent_ids | db_agent_ids)
@staticmethod
async def get_agent_access_groups(

View file

@ -101,7 +101,11 @@ async def _attach_keys_to_agents(agents: Sequence[AgentResponse], prisma_client)
foreign key. Mirrors how spend is joined into the agent response so the UI
never has to cross-reference a full key dump client-side. Only non-secret
fields are exposed (alias, masked key_name, hashed token)."""
agent_ids: Final = [agent.agent_id for agent in agents]
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
agent_ids: Final = tuple(
alias_id for agent in agents for alias_id in global_agent_registry.ids_for_agent(agent.agent_id)
)
if not agent_ids:
return
key_rows: Final = await prisma_client.db.litellm_verificationtoken.find_many(
@ -117,7 +121,12 @@ async def _attach_keys_to_agents(agents: Sequence[AgentResponse], prisma_client)
)
)
for agent in agents:
agent.keys = keys_by_agent.get(agent.agent_id)
matched_keys = [
key_summary
for alias_id in global_agent_registry.ids_for_agent(agent.agent_id)
for key_summary in keys_by_agent.get(alias_id) or ()
]
agent.keys = matched_keys or None
def _redact_sensitive_agent_fields(
@ -239,10 +248,12 @@ async def get_agents(
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import (
AgentRequestHandler,
RestrictedAgentAccess,
UnrestrictedAgentAccess,
)
try:
returned_agents: list[AgentResponse] = []
returned_agents: Sequence[AgentResponse] = ()
# Admin users get all agents
if (
@ -252,37 +263,45 @@ async def get_agents(
returned_agents = global_agent_registry.get_agent_list()
else:
# Get allowed agents from object_permission (key/team level)
allowed_agent_ids: Final = await AgentRequestHandler.get_allowed_agents(user_api_key_auth=user_api_key_dict)
agent_access: Final = await AgentRequestHandler.resolve_agent_access(user_api_key_auth=user_api_key_dict)
all_agents: Final = global_agent_registry.get_agent_list()
# If no restrictions (empty list), return all agents
if len(allowed_agent_ids) == 0:
returned_agents = global_agent_registry.get_agent_list()
else:
# Filter agents by allowed IDs
all_agents: Final = global_agent_registry.get_agent_list()
returned_agents = [agent for agent in all_agents if agent.agent_id in allowed_agent_ids]
match agent_access:
case UnrestrictedAgentAccess():
returned_agents = all_agents
case RestrictedAgentAccess(allowed_agent_ids):
returned_agents = [agent for agent in all_agents if agent.agent_id in allowed_agent_ids]
# Fetch current spend from DB for all returned agents
from litellm.proxy.proxy_server import prisma_client
if prisma_client is not None:
agent_ids: Final = [agent.agent_id for agent in returned_agents]
agent_ids: Final = tuple(
alias_id
for agent in returned_agents
for alias_id in global_agent_registry.ids_for_agent(agent.agent_id)
)
if agent_ids:
db_agents: Final = await agents_table(prisma_client).find_many(
where={"agent_id": {"in": agent_ids}},
)
spend_map: Final = {a.agent_id: a.spend for a in db_agents}
for agent in returned_agents:
if agent.agent_id in spend_map:
agent.spend = spend_map[agent.agent_id]
matched_spends = tuple(
spend_map[alias_id]
for alias_id in global_agent_registry.ids_for_agent(agent.agent_id)
if alias_id in spend_map
)
if matched_spends:
agent.spend = sum(matched_spends)
await _attach_keys_to_agents(returned_agents, prisma_client)
# add is_public field to each agent - we do it this way, to allow setting config agents as public
for agent in returned_agents:
if agent.litellm_params is None:
agent.litellm_params = {}
agent.litellm_params["is_public"] = litellm.public_agent_groups is not None and (
agent.agent_id in litellm.public_agent_groups
agent.litellm_params["is_public"] = litellm.public_agent_groups is not None and not (
global_agent_registry.ids_for_agent(agent.agent_id).isdisjoint(litellm.public_agent_groups)
)
# Redact sensitive fields for non-admin users
@ -863,7 +882,7 @@ async def make_agent_public(
if litellm.public_agent_groups is None:
litellm.public_agent_groups = []
# handle duplicates
if agent.agent_id in litellm.public_agent_groups:
if not AGENT_REGISTRY.ids_for_agent(agent.agent_id).isdisjoint(litellm.public_agent_groups):
raise HTTPException(
status_code=400,
detail=f"Agent with name {agent.agent_name} already in public agent groups",
@ -1043,27 +1062,29 @@ async def get_agent_daily_activity(
# intersect their explicit `agent_ids` filter with the same allowlist.
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import (
AgentRequestHandler,
RestrictedAgentAccess,
UnrestrictedAgentAccess,
)
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
where_condition: Final[dict[str, object]] = {}
if not _user_has_admin_view(user_api_key_dict):
permitted_agent_ids = await AgentRequestHandler.get_allowed_agents(user_api_key_auth=user_api_key_dict)
# `get_allowed_agents` returns an empty list when the caller's key
# and team carry no agent restrictions. For activity scoping that's
# not "see everything" — fall back to the agents the caller
# created so they cannot enumerate other tenants' agents.
permitted_agent_ids: list[str] = []
# An unrestricted caller is not "see everything" for activity scoping. Fall
# back to the agents the caller created so they cannot enumerate other
# tenants' agents.
# Guard against `user_id is None`: a literal None in Prisma
# `where={"created_by": None}` resolves to ``created_by IS NULL``
# and would expose every ownerless agent's rows.
if not permitted_agent_ids:
if user_api_key_dict.user_id is None:
permitted_agent_ids = []
else:
owned_records: Final = await agents_table(prisma_client).find_many(
where={"created_by": user_api_key_dict.user_id}
)
permitted_agent_ids = [a.agent_id for a in owned_records]
match await AgentRequestHandler.resolve_agent_access(user_api_key_auth=user_api_key_dict):
case RestrictedAgentAccess(allowed_agent_ids):
permitted_agent_ids = list(allowed_agent_ids)
case UnrestrictedAgentAccess():
if user_api_key_dict.user_id is not None:
owned_records: Final = await agents_table(prisma_client).find_many(
where={"created_by": user_api_key_dict.user_id}
)
permitted_agent_ids = [a.agent_id for a in owned_records]
if agent_ids_list:
permitted_agent_id_set: Final = set(permitted_agent_ids)

View file

@ -4,8 +4,6 @@ Helper functions for appending A2A agents to model lists.
Used by proxy model endpoints to make agents appear in UI alongside models.
"""
from typing import Final
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
@ -27,20 +25,23 @@ async def append_agents_to_model_group(
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import (
AgentRequestHandler,
RestrictedAgentAccess,
)
allowed_agent_ids: Final = await AgentRequestHandler.get_allowed_agents(user_api_key_auth=user_api_key_dict)
for agent_id in allowed_agent_ids:
agent = global_agent_registry.get_agent_by_id(agent_id)
if agent is not None:
model_groups.append(
ModelGroupInfoProxy(
model_group=f"a2a/{agent.agent_name}",
mode="chat",
providers=["a2a"],
)
)
match await AgentRequestHandler.resolve_agent_access(user_api_key_auth=user_api_key_dict):
case RestrictedAgentAccess(allowed_agent_ids):
for agent_id in allowed_agent_ids:
agent = global_agent_registry.get_agent_by_id(agent_id)
if agent is not None:
model_groups.append(
ModelGroupInfoProxy(
model_group=f"a2a/{agent.agent_name}",
mode="chat",
providers=["a2a"],
)
)
case _:
pass
except Exception as e:
verbose_proxy_logger.debug("Error appending agents to model_group/info: %s", e)
@ -61,30 +62,33 @@ async def append_agents_to_model_info(
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import (
AgentRequestHandler,
RestrictedAgentAccess,
)
allowed_agent_ids: Final = await AgentRequestHandler.get_allowed_agents(user_api_key_auth=user_api_key_dict)
for agent_id in allowed_agent_ids:
agent = global_agent_registry.get_agent_by_id(agent_id)
if agent is not None:
models.append(
{
"model_name": f"a2a/{agent.agent_name}",
"litellm_params": {
"model": f"a2a/{agent.agent_name}",
"custom_llm_provider": "a2a",
},
"model_info": {
"id": agent.agent_id,
"mode": "chat",
"db_model": True,
"created_by": agent.created_by,
"created_at": agent.created_at,
"updated_at": agent.updated_at,
},
}
)
match await AgentRequestHandler.resolve_agent_access(user_api_key_auth=user_api_key_dict):
case RestrictedAgentAccess(allowed_agent_ids):
for agent_id in allowed_agent_ids:
agent = global_agent_registry.get_agent_by_id(agent_id)
if agent is not None:
models.append(
{
"model_name": f"a2a/{agent.agent_name}",
"litellm_params": {
"model": f"a2a/{agent.agent_name}",
"custom_llm_provider": "a2a",
},
"model_info": {
"id": agent.agent_id,
"mode": "chat",
"db_model": True,
"created_by": agent.created_by,
"created_at": agent.created_at,
"updated_at": agent.updated_at,
},
}
)
case _:
pass
except Exception as e:
verbose_proxy_logger.debug("Error appending agents to v2/model/info: %s", e)

View file

@ -13,6 +13,7 @@ import asyncio
import math
import re
import time
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, cast
from fastapi import HTTPException, Request, status
@ -648,28 +649,29 @@ async def common_checks(
)
async def _user_max_budget_check() -> None:
# 4.1 personal budget, if personal key
if (
(team_object is None or team_object.team_id is None)
and user_object is not None
and user_object.max_budget is not None
):
from litellm.proxy.proxy_server import get_current_spend
# 4.1 personal budget
if user_object is None or user_object.max_budget is None:
return
is_team_key: Final = team_object is not None and team_object.team_id is not None
if is_team_key and general_settings.get("apply_user_budget_to_team_keys") is not True:
return
user_budget: Final = user_object.max_budget
user_spend: Final = await get_current_spend(
counter_key=f"spend:user:{user_object.user_id}",
fallback_spend=user_object.spend or 0.0,
from litellm.proxy.proxy_server import get_current_spend
user_budget: Final = user_object.max_budget
user_spend: Final = await get_current_spend(
counter_key=f"spend:user:{user_object.user_id}",
fallback_spend=user_object.spend or 0.0,
max_budget=user_budget,
)
if math.isfinite(user_budget) and user_spend >= user_budget:
raise litellm.BudgetExceededError(
current_cost=user_spend,
max_budget=user_budget,
message=f"ExceededBudget: User={user_object.user_id} over budget. Spend={user_spend}, Budget={user_budget}",
entity_type=Litellm_EntityType.USER.value,
entity_id=user_object.user_id,
)
if math.isfinite(user_budget) and user_spend >= user_budget:
raise litellm.BudgetExceededError(
current_cost=user_spend,
max_budget=user_budget,
message=f"ExceededBudget: User={user_object.user_id} over budget. Spend={user_spend}, Budget={user_budget}",
entity_type=Litellm_EntityType.USER.value,
entity_id=user_object.user_id,
)
# Each scope reads a distinct counter key with no cross-scope ordering
# dependency, so the per-scope Redis-first reads run concurrently instead
@ -2833,7 +2835,7 @@ async def get_org_object(
async def _get_resources_from_access_groups(
access_group_ids: list[str],
access_group_ids: Sequence[str],
resource_field: Literal["access_model_names", "access_mcp_server_ids", "access_agent_ids"],
prisma_client: PrismaClient | None = None,
user_api_key_cache: UserApiKeyCache | None = None,
@ -2892,7 +2894,7 @@ async def _get_resources_from_access_groups(
async def _get_models_from_access_groups(
access_group_ids: list[str],
access_group_ids: Sequence[str],
prisma_client: PrismaClient | None = None,
user_api_key_cache: UserApiKeyCache | None = None,
proxy_logging_obj: ProxyLogging | None = None,
@ -4262,6 +4264,10 @@ async def _project_soft_budget_check(
)
def _project_cache_key(project_id: str) -> str:
return f"project_id:{project_id}"
async def get_project_object(
project_id: str,
prisma_client: PrismaClient | None,
@ -4279,7 +4285,7 @@ async def get_project_object(
return None
# Check cache first
cache_key: Final = f"project_id:{project_id}"
cache_key: Final = _project_cache_key(project_id)
deserialized_project: Final = await user_api_key_cache.async_get_cache(
key=cache_key,
model_type=LiteLLM_ProjectTableCachedObj,
@ -4310,6 +4316,32 @@ async def get_project_object(
return project_obj
async def delete_cached_project_object(
project_id: str,
user_api_key_cache: UserApiKeyCache,
) -> None:
"""
Every endpoint that mutates litellm_projecttable must call this: get_project_object
serves auth cache-first with no freshness check, so without invalidation a stale
project (e.g. a pre-update empty model allowlist) keeps being enforced until the
TTL expires (LIT-3803). Best-effort on both steps: the DB write has already
committed, so a cache backend error must not fail the endpoint; the stale entry
then expires via TTL.
"""
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import publish_auth_cache_invalidation
cache_key: Final = _project_cache_key(project_id)
try:
await user_api_key_cache.async_delete_cache(key=cache_key)
except Exception as e: # noqa: BLE001 # best-effort eviction: any cache backend error must not fail the mutation
verbose_proxy_logger.warning(
"Failed to evict cached project entry %s; a stale project may be served until its TTL expires: %s",
cache_key,
e,
)
await publish_auth_cache_invalidation(cache_key=cache_key)
async def _organization_max_budget_check(
valid_token: UserAPIKeyAuth | None,
team_object: LiteLLM_TeamTable | None,

View file

@ -1,6 +1,7 @@
# What is this?
## Common checks for /v1/models and `/model/info`
import copy
from collections.abc import Sequence
from typing import Any, Final
import litellm
@ -178,8 +179,8 @@ def get_team_models(
def get_complete_model_list(
key_models: list[str],
team_models: list[str],
key_models: Sequence[str],
team_models: Sequence[str],
proxy_model_list: list[str],
user_model: str | None,
infer_model_from_keys: bool | None,
@ -203,7 +204,7 @@ def get_complete_model_list(
def append_unique(models):
for model in models:
if model not in unique_models:
if model not in unique_models and model != SpecialModelNames.no_default_models.value:
unique_models.append(model)
if key_models:

View file

@ -1044,6 +1044,22 @@ def _ensure_parent_otel_span_on_request_state(request: Request) -> None:
request.state.parent_otel_span = parent_otel_span
async def _read_request_body_deferring_parse_failure(
request: Request,
) -> tuple[dict, ProxyException | None]:
"""Parse the body, returning a parse failure instead of raising it.
A body that fails to parse is still a request from a known caller, so auth
must run (resolving identity onto the request's trace) before the 400 goes
out; the caller re-raises the returned exception once identity is seeded.
"""
try:
parsed_body: Final = await _read_request_body(request=request)
except ProxyException as parse_exception:
return {}, parse_exception # mutable-ok: request_data is a plain dict across the whole auth path
return populate_request_with_path_params(request_data=parsed_body, request=request), None
async def _user_api_key_auth_builder(
request: Request,
api_key: str,
@ -2470,6 +2486,7 @@ async def _reserve_budget_after_common_checks(
proxy_logging_obj=proxy_logging_obj,
end_user_id=end_user_id,
end_user_object=end_user_object,
apply_user_budget_to_team_keys=general_settings.get("apply_user_budget_to_team_keys") is True,
fail_closed_budget_enforcement=general_settings.get("fail_closed_budget_enforcement") is True,
)
@ -2515,6 +2532,72 @@ def _resolve_request_principal(request: Request, valid_token: UserAPIKeyAuth) ->
)
async def _authorize_authenticated_request(
user_api_key_auth_obj: UserAPIKeyAuth,
request: Request,
request_data: dict,
route: str,
api_key: str,
) -> UserAPIKeyAuth | None:
"""Authorize an already-authenticated request: disabled-route check, the single
``common_checks`` gate (which also reserves budget), and end-user fallback
resolution. Returns the auth object the exception handler recovered when a check
failed but the request may proceed anyway, else ``None``.
"""
## ENSURE DISABLE ROUTE WORKS ACROSS ALL USER AUTH FLOWS ##
RouteChecks.should_call_route(route=route, valid_token=user_api_key_auth_obj, request=request)
# Single authorization point. Builder paths MUST NOT call common_checks.
# Route through the same exception handler the builder uses so
# authorization failures (ProxyException, or plain Exception from
# admin-only-route / model-access / budget checks) surface as
# ProxyException consistently with pre-refactor behavior.
try:
await _run_centralized_common_checks(
user_api_key_auth_obj=user_api_key_auth_obj,
request=request,
request_data=request_data,
route=route,
)
except Exception as e:
return await UserAPIKeyAuthExceptionHandler._handle_authentication_error(
e=e,
request=request,
request_data=request_data,
route=route,
parent_otel_span=user_api_key_auth_obj.parent_otel_span,
api_key=api_key,
resolved_identity=user_api_key_auth_obj,
)
# Defense-in-depth: ``_user_api_key_auth_builder`` has multiple early-return
# paths (no master key, /user/auth route, JWT short-circuits) that bypass
# the end-user resolution block. If those paths produced an auth obj
# without an ``end_user_id`` set, fall back to extracting from the request
# body so spend logs are still attributed correctly. Validation honours
# ``litellm.validate_end_user_id_in_db``.
if user_api_key_auth_obj.end_user_id is None:
from litellm.proxy.proxy_server import (
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
raw_end_user_id: Final = get_end_user_id_from_request_body(request_data, _safe_get_request_headers(request))
if raw_end_user_id is not None:
resolved_end_user_id: Final = await resolve_and_validate_end_user_id(
raw_end_user_id=raw_end_user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=user_api_key_auth_obj.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
route=route,
)
if resolved_end_user_id is not None:
user_api_key_auth_obj.end_user_id = resolved_end_user_id
return None
@tracer.wrap()
async def user_api_key_auth(
request: Request,
@ -2535,8 +2618,7 @@ async def user_api_key_auth(
# close, and the trace never reaches the backend.
_ensure_parent_otel_span_on_request_state(request)
request_data = await _read_request_body(request=request)
request_data = populate_request_with_path_params(request_data=request_data, request=request)
request_data, body_parse_exception = await _read_request_body_deferring_parse_failure(request=request)
route: Final[str] = get_request_route(request=request)
## CHECK IF ROUTE IS ALLOWED
@ -2544,69 +2626,41 @@ async def user_api_key_auth(
# triggers (key/user/team object reads) nest under it instead of flattening
# onto the server span. No-op when OTel V2 isn't active.
with phase_span(f"auth {route}"):
user_api_key_auth_obj: Final = await _user_api_key_auth_builder(
request=request,
api_key=api_key,
azure_api_key_header=azure_api_key_header,
anthropic_api_key_header=anthropic_api_key_header,
google_ai_studio_api_key_header=google_ai_studio_api_key_header,
azure_apim_header=azure_apim_header,
request_data=request_data,
custom_litellm_key_header=custom_litellm_key_header,
)
try:
user_api_key_auth_obj: Final = await _user_api_key_auth_builder(
request=request,
api_key=api_key,
azure_api_key_header=azure_api_key_header,
anthropic_api_key_header=anthropic_api_key_header,
google_ai_studio_api_key_header=google_ai_studio_api_key_header,
azure_apim_header=azure_apim_header,
request_data=request_data,
custom_litellm_key_header=custom_litellm_key_header,
)
except Exception:
# The body was read first, so a caller who sent both a malformed body and
# a rejected key used to get the 400; the response is unchanged, and the
# auth failure is still recorded on the trace by the handler that ran.
if body_parse_exception is not None:
raise body_parse_exception
raise
user_api_key_auth_obj.budget_reservation = None
## ENSURE DISABLE ROUTE WORKS ACROSS ALL USER AUTH FLOWS ##
RouteChecks.should_call_route(route=route, valid_token=user_api_key_auth_obj, request=request)
# Single authorization point. Builder paths MUST NOT call common_checks.
# Route through the same exception handler the builder uses so
# authorization failures (ProxyException, or plain Exception from
# admin-only-route / model-access / budget checks) surface as
# ProxyException consistently with pre-refactor behavior.
try:
await _run_centralized_common_checks(
# A body that never parsed is authenticated (so the trace carries identity
# and this ``auth`` span) but not authorized: there is no model to check it
# against, and budget reservation would increment live spend counters that
# only the endpoint's post-call path releases; the endpoint never runs, since
# the parse failure is raised below.
if body_parse_exception is None:
recovered_auth_obj: Final = await _authorize_authenticated_request(
user_api_key_auth_obj=user_api_key_auth_obj,
request=request,
request_data=request_data,
route=route,
)
except Exception as e:
return await UserAPIKeyAuthExceptionHandler._handle_authentication_error(
e=e,
request=request,
request_data=request_data,
route=route,
parent_otel_span=user_api_key_auth_obj.parent_otel_span,
api_key=api_key,
resolved_identity=user_api_key_auth_obj,
)
# Defense-in-depth: ``_user_api_key_auth_builder`` has multiple early-return
# paths (no master key, /user/auth route, JWT short-circuits) that bypass
# the end-user resolution block. If those paths produced an auth obj
# without an ``end_user_id`` set, fall back to extracting from the request
# body so spend logs are still attributed correctly. Validation honours
# ``litellm.validate_end_user_id_in_db``.
if user_api_key_auth_obj.end_user_id is None:
from litellm.proxy.proxy_server import (
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
raw_end_user_id: Final = get_end_user_id_from_request_body(request_data, _safe_get_request_headers(request))
if raw_end_user_id is not None:
resolved_end_user_id: Final = await resolve_and_validate_end_user_id(
raw_end_user_id=raw_end_user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=user_api_key_auth_obj.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
route=route,
)
if resolved_end_user_id is not None:
user_api_key_auth_obj.end_user_id = resolved_end_user_id
if recovered_auth_obj is not None:
return recovered_auth_obj
# Identity is now resolved. Seed it AFTER the auth span closes so the Baggage
# persists on the request task (detaching the span's context token inside the
@ -2618,6 +2672,9 @@ async def user_api_key_auth(
)
user_api_key_auth_obj.request_route = normalize_request_route(route)
if body_parse_exception is not None:
raise body_parse_exception
# Resolve caller identity once, here at the seam, into a single per-request
# Principal projected off the key object the builder already fetched (no
# second lookup). Downstream consumers read identity off this instead of

View file

@ -36,6 +36,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import (
get_original_file_id,
prepare_data_with_credentials,
update_batch_in_database,
validate_managed_id_requirement,
)
from litellm.proxy.utils import handle_exception_on_proxy, is_known_model
from litellm.repositories.table_repositories import ManagedFileRepository
@ -176,6 +177,12 @@ async def create_batch(
}
input_file_id: Final = _create_batch_data.get("input_file_id", None)
await validate_managed_id_requirement(
resource_id=input_file_id,
resource_kind="file",
user_api_key_dict=user_api_key_dict,
managed_files_obj=proxy_logging_obj.get_proxy_hook("managed_files"),
)
unified_file_id: str | Literal[False] = False
model_from_file_id = None
@ -392,6 +399,12 @@ async def retrieve_batch(
data: dict = {}
try:
await validate_managed_id_requirement(
resource_id=batch_id,
resource_kind="batch",
user_api_key_dict=user_api_key_dict,
managed_files_obj=proxy_logging_obj.get_proxy_hook("managed_files"),
)
model_from_id: Final = decode_model_from_file_id(batch_id)
_retrieve_batch_request: Final = RetrieveBatchRequest(
batch_id=batch_id,
@ -840,6 +853,13 @@ async def cancel_batch(
data: dict = {}
try:
await validate_managed_id_requirement(
resource_id=batch_id,
resource_kind="batch",
user_api_key_dict=user_api_key_dict,
managed_files_obj=proxy_logging_obj.get_proxy_hook("managed_files"),
)
# Check for encoded batch ID with model info
model_from_id: Final = decode_model_from_file_id(batch_id)

View file

@ -0,0 +1,153 @@
import asyncio
import json
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING, Final
from litellm._logging import verbose_proxy_logger
from litellm.proxy.common_utils.config_sync_pubsub import (
_ConfigSyncPubSub,
_pubsub_capable_client,
coordination_redis_cache,
)
if TYPE_CHECKING:
from litellm.caching.redis_cache import RedisCache
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
AUTH_CACHE_INVALIDATION_CHANNEL: Final = "litellm_proxy.auth_cache_invalidation"
_POLL_TIMEOUT_SECONDS: Final = 1.0
_BACKOFF_INITIAL_SECONDS: Final = 5.0
_BACKOFF_MAX_SECONDS: Final = 60.0
def auth_cache_invalidation_channel(redis_cache: "RedisCache") -> str:
if redis_cache.namespace is None:
return AUTH_CACHE_INVALIDATION_CHANNEL
return f"{redis_cache.namespace}:{AUTH_CACHE_INVALIDATION_CHANNEL}"
@dataclass(frozen=True, slots=True)
class _CacheInvalidationMessage:
cache_key: str
def _cache_invalidation_message_json(cache_key: str) -> str:
return json.dumps(asdict(_CacheInvalidationMessage(cache_key=cache_key)))
def _cache_key_from_message_data(data: object) -> str | None:
if isinstance(data, bytes):
data = data.decode("utf-8", errors="replace")
if not isinstance(data, str):
return None
try:
parsed: Final = json.loads(data)
except json.JSONDecodeError:
return None
if not isinstance(parsed, dict):
return None
cache_key: Final = parsed.get("cache_key")
return cache_key if isinstance(cache_key, str) else None
async def publish_auth_cache_invalidation(cache_key: str) -> None:
"""
Best-effort broadcast so every worker drops its local in-memory copy of a
mutated management object; without this, only the handling worker and Redis
are evicted and other workers keep serving the stale object until its TTL.
"""
redis_cache: Final = coordination_redis_cache()
if redis_cache is None:
return
try:
client: Final = _pubsub_capable_client(redis_cache)
if client is None:
verbose_proxy_logger.debug(
"auth cache invalidation publish for %s skipped: cluster redis client has no pub/sub support",
cache_key,
)
return
await client.publish(auth_cache_invalidation_channel(redis_cache), _cache_invalidation_message_json(cache_key))
except Exception as e: # noqa: BLE001 # best-effort publish; mutations must never fail on redis errors
verbose_proxy_logger.warning("auth cache invalidation publish for %s failed: %s", cache_key, e)
class AuthCacheInvalidationSubscriber:
__slots__ = ("_redis_cache", "_task", "_user_api_key_cache")
def __init__(
self,
redis_cache: "RedisCache",
user_api_key_cache: "UserApiKeyCache",
) -> None:
self._redis_cache = redis_cache
self._user_api_key_cache = user_api_key_cache
self._task: asyncio.Task[None] | None = None
def start(self) -> None:
if self._task is not None:
return
self._task = asyncio.create_task(self._run())
async def stop(self) -> None:
task: Final = self._task
if task is None:
return
self._task = None
_ = task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def _run(self) -> None:
backoff_seconds = _BACKOFF_INITIAL_SECONDS # rebind-ok: exponential backoff accumulator across reconnects
while True:
try:
client = _pubsub_capable_client(self._redis_cache) # rebind-ok: re-resolved on every reconnect
if client is None:
verbose_proxy_logger.warning(
"auth cache invalidation subscriber disabled: cluster redis client has no pub/sub support; "
"cross-worker eviction falls back to the local cache TTL"
)
return
pubsub = client.pubsub() # rebind-ok: fresh pubsub per reconnect
try:
await pubsub.subscribe(auth_cache_invalidation_channel(self._redis_cache))
backoff_seconds = _BACKOFF_INITIAL_SECONDS # rebind-ok: reset after successful subscribe
await self._consume(pubsub)
finally:
await self._close_pubsub(pubsub)
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001 # any redis failure falls through to backoff and reconnect
verbose_proxy_logger.warning(
"auth cache invalidation subscriber redis error: %s; reconnecting in %.0fs",
e,
backoff_seconds,
)
await asyncio.sleep(backoff_seconds)
backoff_seconds = min(backoff_seconds * 2, _BACKOFF_MAX_SECONDS) # rebind-ok: backoff accumulator
async def _consume(self, pubsub: _ConfigSyncPubSub) -> None:
while True:
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=_POLL_TIMEOUT_SECONDS)
if message is None:
continue
self._apply_message(message)
def _apply_message(self, message: object) -> None:
data: Final = message.get("data") if isinstance(message, dict) else None
cache_key: Final = _cache_key_from_message_data(data)
if cache_key is None:
return
in_memory_cache: Final = self._user_api_key_cache.in_memory_cache
if in_memory_cache is not None:
in_memory_cache.delete_cache(cache_key)
@staticmethod
async def _close_pubsub(pubsub: _ConfigSyncPubSub) -> None:
try:
await pubsub.aclose()
except Exception as e: # noqa: BLE001 # best-effort close of a possibly-broken connection
verbose_proxy_logger.debug("auth cache invalidation pubsub close failed: %s", e)

View file

@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any, Final, Optional
import litellm
from litellm import get_secret
from litellm._logging import verbose_proxy_logger
from litellm.constants import PRE_CALL_EXECUTED_GUARDRAILS_KEY
from litellm.constants import PRE_CALL_EXECUTED_GUARDRAILS_KEY, SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.core_helpers import (
get_metadata_variable_name_from_kwargs,
@ -425,6 +425,7 @@ LITELLM_PROXY_INTERNAL_METADATA_KEYS: Final = frozenset(
"_guardrail_pipelines",
"_pipeline_managed_guardrails",
PRE_CALL_EXECUTED_GUARDRAILS_KEY,
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
"disable_global_guardrails",
"disable_global_guardrail",
"opted_out_global_guardrails",

View file

@ -346,4 +346,4 @@ async def update_credential(
return {"success": True, "message": "Credential updated successfully"}
except Exception as e:
return handle_exception_on_proxy(e)
raise handle_exception_on_proxy(e)

View file

@ -33,6 +33,53 @@ if TYPE_CHECKING:
CACHE_TTL_5M_SECONDS: Final = 300
CACHE_TTL_1H_SECONDS: Final = 3600
AUTOROUTER_BENCHMARKS_SQL: Final = """
WITH windowed AS (
SELECT * FROM "LiteLLM_AutoRouterSession"
WHERE last_turn_at >= $1::timestamp AND first_turn_at < $2::timestamp
),
tier_maps AS (
SELECT router_name, router_type, jsonb_object_agg(tier, tier_turns) AS tier_turns
FROM (
SELECT router_name, router_type, kv.key AS tier, SUM((kv.value)::int)::int AS tier_turns
FROM windowed, LATERAL jsonb_each_text(tier_turns) AS kv
GROUP BY router_name, router_type, kv.key
) per_tier
GROUP BY router_name, router_type
)
SELECT
agg.*,
COALESCE(tier_maps.tier_turns, '{}'::jsonb) AS tier_turns
FROM (
SELECT
router_name,
router_type,
COUNT(*)::int AS sessions,
COALESCE(SUM(turns), 0)::int AS turns,
COALESCE(SUM(unordered_turns), 0)::int AS unordered_turns,
COALESCE(SUM(covered_turns), 0)::int AS covered_turns,
COALESCE(SUM(cache_hits), 0)::int AS cache_hits,
COALESCE(SUM(same_model_turns), 0)::int AS same_model_turns,
COALESCE(SUM(same_model_hits), 0)::int AS same_model_hits,
COALESCE(SUM(first_visit_turns), 0)::int AS first_visit_turns,
COALESCE(SUM(first_visit_hits), 0)::int AS first_visit_hits,
COALESCE(SUM(return_turns), 0)::int AS return_turns,
COALESCE(SUM(return_hits), 0)::int AS return_hits,
COALESCE(SUM(return_expired_misses), 0)::int AS return_expired_misses,
COALESCE(SUM(return_within_ttl_misses), 0)::int AS return_within_ttl_misses,
COALESCE(SUM(ttl_5m_turns), 0)::int AS ttl_5m_turns,
COALESCE(SUM(ttl_1h_turns), 0)::int AS ttl_1h_turns,
COALESCE(SUM(total_tokens), 0)::bigint AS total_tokens,
COALESCE(SUM(spend), 0)::float8 AS spend,
COALESCE(SUM(saved_spend), 0)::float8 AS saved_spend,
COALESCE(SUM(EXTRACT(EPOCH FROM (last_turn_at - first_turn_at))), 0)::float8 AS session_seconds
FROM windowed
GROUP BY router_name, router_type
) agg
LEFT JOIN tier_maps USING (router_name, router_type)
ORDER BY agg.spend DESC
"""
@dataclass(frozen=True, slots=True)
class AutoRouterTurnTransaction:
@ -49,6 +96,7 @@ class AutoRouterTurnTransaction:
cache_hit: bool
cache_ttl_seconds: int | None
cache_touched: bool
tier: str | None = None
class TurnCacheFacts(NamedTuple):
@ -152,11 +200,13 @@ def build_autorouter_turn_transaction(
return None
usage_object_raw: Final = metadata.get("usage_object")
cache: Final = turn_cache_facts(usage_object_raw if isinstance(usage_object_raw, Mapping) else None)
tier_raw: Final = routing_decision.get("tier")
return AutoRouterTurnTransaction(
api_key=api_key,
session_id=_bounded_session_id(session_id),
router_name=router_name,
router_type=str(routing_decision.get("router_type") or "unknown"),
tier=tier_raw if isinstance(tier_raw, str) and tier_raw else None,
model=model,
turn_at=turn_at,
total_tokens=int(payload.get("prompt_tokens") or 0) + int(payload.get("completion_tokens") or 0),
@ -184,6 +234,8 @@ _COVERED: Final = _p("covered")
_CACHE_HIT: Final = _p("cache_hit")
_CACHE_TTL: Final = _p("cache_ttl_seconds")
_TOUCHED: Final = _p("cache_touched")
_TIER: Final = f"{_p('tier')}::text"
_TIER_DELTA: Final = f"(CASE WHEN {_TIER} IS NULL THEN '{{}}'::jsonb ELSE jsonb_build_object({_TIER}, 1) END)"
_IN_ORDER: Final = f"{_TURN_AT}::timestamp >= t.last_turn_at"
_SAME: Final = f"{_IN_ORDER} AND t.last_model = {_MODEL}"
@ -201,7 +253,7 @@ INSERT INTO "LiteLLM_AutoRouterSession" AS t (
last_model, models, turns, unordered_turns, covered_turns, cache_hits,
same_model_turns, same_model_hits, first_visit_turns, first_visit_hits,
return_turns, return_hits, return_expired_misses, return_within_ttl_misses,
ttl_5m_turns, ttl_1h_turns, total_tokens, spend, saved_spend
ttl_5m_turns, ttl_1h_turns, total_tokens, spend, saved_spend, tier_turns
)
VALUES (
{_p("api_key")}, {_p("session_id")}, {_p("router_name")}, {_p("router_type")}, {_TURN_AT}::timestamp, {_TURN_AT}::timestamp,
@ -211,7 +263,8 @@ VALUES (
0, 0, 0, 0,
(CASE WHEN {_CACHE_TTL}::int = {CACHE_TTL_5M_SECONDS} THEN 1 ELSE 0 END),
(CASE WHEN {_CACHE_TTL}::int = {CACHE_TTL_1H_SECONDS} THEN 1 ELSE 0 END),
{_p("total_tokens")}::bigint, {_p("spend")}::float8, {_p("saved_spend")}::float8
{_p("total_tokens")}::bigint, {_p("spend")}::float8, {_p("saved_spend")}::float8,
{_TIER_DELTA}
)
ON CONFLICT (api_key, session_id, router_name) DO UPDATE SET
turns = t.turns + 1,
@ -242,6 +295,9 @@ ON CONFLICT (api_key, session_id, router_name) DO UPDATE SET
ELSE COALESCE((t.models -> {_MODEL} ->> 'ttl')::int, {_CACHE_TTL}::int) END)
)),
last_model = (CASE WHEN {_IN_ORDER} THEN {_MODEL} ELSE t.last_model END),
tier_turns = (CASE WHEN {_TIER} IS NOT NULL AND t.router_type = {_p("router_type")}
THEN t.tier_turns || jsonb_build_object({_TIER}, COALESCE((t.tier_turns ->> {_TIER})::int, 0) + 1)
ELSE t.tier_turns END),
first_turn_at = LEAST(t.first_turn_at, EXCLUDED.first_turn_at),
last_turn_at = GREATEST(t.last_turn_at, EXCLUDED.last_turn_at)
"""

View file

@ -486,6 +486,38 @@ class PrismaWrapper:
os.environ[self._db_url_env_var] = _db_url
return _db_url
@property
def engine_generation(self) -> int:
"""How many query-engine replacements have completed on this wrapper.
Bumped under `_reconnection_lock` only after a replacement engine has
connected, so a change across an await proves a *successful* planned
replacement happened in between a replacement that failed (a real
outage) leaves it untouched.
"""
return self._engine_generation
async def _reconnection_settled(self) -> None:
async with self._reconnection_lock:
pass
async def wait_for_planned_engine_replacement(self, timeout_seconds: float) -> None:
"""Wait, bounded, for an in-flight planned engine replacement to finish.
Both replacement paths (`recreate_prisma_client` and
`_safe_refresh_token`) hold `_reconnection_lock` across their whole
kill/connect window, so re-acquiring it means the replacement has
settled one way or the other. Gives up silently on timeout: a caller
that stopped waiting must treat the replacement as not completed and
consult `engine_generation` rather than assume success.
"""
if timeout_seconds <= 0 or not self._reconnection_lock.locked():
return
try:
await asyncio.wait_for(self._reconnection_settled(), timeout=timeout_seconds)
except asyncio.TimeoutError:
return
async def recreate_prisma_client(
self,
new_db_url: str,

View file

@ -17,6 +17,7 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
from litellm.proxy.openai_files_endpoints.common_utils import (
_is_base64_encoded_unified_file_id,
validate_managed_id_requirement,
)
from litellm.proxy.utils import handle_exception_on_proxy
from litellm.types.utils import LiteLLMFineTuningJob
@ -134,6 +135,18 @@ async def create_fine_tuning_job(
## CHECK IF MANAGED FILE ID
unified_file_id: str | Literal[False] = False
training_file: Final = fine_tuning_request.training_file
await validate_managed_id_requirement(
resource_id=training_file,
resource_kind="file",
user_api_key_dict=user_api_key_dict,
managed_files_obj=proxy_logging_obj.get_proxy_hook("managed_files"),
)
await validate_managed_id_requirement(
resource_id=fine_tuning_request.validation_file,
resource_kind="file",
user_api_key_dict=user_api_key_dict,
managed_files_obj=proxy_logging_obj.get_proxy_hook("managed_files"),
)
response: LiteLLMFineTuningJob | None = None
if training_file:
unified_file_id = _is_base64_encoded_unified_file_id(training_file)
@ -246,6 +259,12 @@ async def retrieve_fine_tuning_job(
try:
if premium_user is not True:
raise ValueError(f"Only premium users can use this endpoint + {CommonProxyErrors.not_premium_user.value}")
await validate_managed_id_requirement(
resource_id=fine_tuning_job_id,
resource_kind="fine-tuning job",
user_api_key_dict=user_api_key_dict,
managed_files_obj=proxy_logging_obj.get_proxy_hook("managed_files"),
)
# Include original request and headers in the data
base_llm_response_processor: Final = ProxyBaseLLMRequestProcessing(data=data)
(
@ -513,6 +532,12 @@ async def cancel_fine_tuning_job(
try:
if premium_user is not True:
raise ValueError(f"Only premium users can use this endpoint + {CommonProxyErrors.not_premium_user.value}")
await validate_managed_id_requirement(
resource_id=fine_tuning_job_id,
resource_kind="fine-tuning job",
user_api_key_dict=user_api_key_dict,
managed_files_obj=proxy_logging_obj.get_proxy_hook("managed_files"),
)
# Include original request and headers in the data
base_llm_response_processor: Final = ProxyBaseLLMRequestProcessing(data=data)
(

View file

@ -6,10 +6,10 @@ import concurrent.futures
import inspect
import json
import os
from collections.abc import Mapping, Sequence
from collections.abc import Awaitable, Callable, Mapping, Sequence
from datetime import datetime, timezone
from types import UnionType
from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar, Union, cast, get_args, get_origin
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeVar, Union, cast, get_args, get_origin
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Request
@ -54,8 +54,8 @@ from litellm.types.guardrails import (
if TYPE_CHECKING:
from types import CodeType
from prisma.actions import LiteLLM_GuardrailsTableActions
from prisma.models import LiteLLM_GuardrailsTable
from pydantic.fields import FieldInfo
from litellm.proxy.utils import PrismaClient
@ -65,24 +65,44 @@ router: Final = APIRouter()
GUARDRAIL_REGISTRY: Final = GuardrailRegistry()
def _guardrails_table(prisma_client: "PrismaClient") -> "LiteLLM_GuardrailsTableActions[LiteLLM_GuardrailsTable]":
table: Final[LiteLLM_GuardrailsTableActions[LiteLLM_GuardrailsTable]] = GuardrailsRepository(prisma_client).table
class _GuardrailsTableActions(Protocol):
async def create(self, data: Mapping[str, object]) -> "LiteLLM_GuardrailsTable": ...
async def delete(self, where: Mapping[str, object]) -> "LiteLLM_GuardrailsTable | None": ...
async def find_unique(self, where: Mapping[str, object]) -> "LiteLLM_GuardrailsTable | None": ...
async def find_many(
self, where: Mapping[str, object], order: Mapping[str, str]
) -> "Sequence[LiteLLM_GuardrailsTable]": ...
async def update(
self, where: Mapping[str, object], data: Mapping[str, object]
) -> "LiteLLM_GuardrailsTable | None": ...
def _as_str_object_mapping(mapping: Mapping[str, object]) -> Mapping[str, object]:
return mapping
def _guardrails_table(prisma_client: "PrismaClient") -> _GuardrailsTableActions:
table: Final[_GuardrailsTableActions] = GuardrailsRepository(prisma_client).table
return table
async def _create_guardrail_row(prisma_client: "PrismaClient", data: Mapping[str, object]) -> "LiteLLM_GuardrailsTable":
row: Final[LiteLLM_GuardrailsTable] = await GuardrailsRepository(prisma_client).table.create(data=data)
row: Final = await _guardrails_table(prisma_client).create(data=data)
return row
async def _delete_guardrail_row(prisma_client: "PrismaClient", where: Mapping[str, object]) -> None:
await GuardrailsRepository(prisma_client).table.delete(where=where)
await _guardrails_table(prisma_client).delete(where=where)
async def _find_team_guardrail_rows(
prisma_client: "PrismaClient", where: Mapping[str, object]
) -> "Sequence[LiteLLM_GuardrailsTable]":
rows: Final[Sequence[LiteLLM_GuardrailsTable]] = await GuardrailsRepository(prisma_client).table.find_many(
rows: Final = await _guardrails_table(prisma_client).find_many(
where=where,
order={"created_at": "desc"},
)
@ -499,10 +519,12 @@ async def update_guardrail(
if existing_guardrail is None:
raise HTTPException(status_code=404, detail=f"Guardrail with ID {guardrail_id} not found")
result: Final = await GUARDRAIL_REGISTRY.update_guardrail_in_db(
guardrail_id=guardrail_id,
guardrail=request.guardrail,
prisma_client=prisma_client,
result: Final = _as_str_object_mapping(
await GUARDRAIL_REGISTRY.update_guardrail_in_db(
guardrail_id=guardrail_id,
guardrail=request.guardrail,
prisma_client=prisma_client,
)
)
guardrail_name: Final = result.get("guardrail_name", "Unknown")
@ -613,7 +635,7 @@ class RegisterGuardrailRequest(BaseModel):
"""Request body for POST /guardrails/register. Follows Generic Guardrail API config."""
guardrail_name: str
litellm_params: dict[str, Any] # guardrail, mode, api_base required; api_key, headers, etc. optional
litellm_params: dict[str, object] # guardrail, mode, api_base required; api_key, headers, etc. optional
guardrail_info: dict[str, object] | None = None
team_id: str | None = None
@ -1172,12 +1194,14 @@ async def patch_guardrail(
)
# Update litellm_params if default_on is provided or pii_entities_config is provided
litellm_params = LitellmParams(**dict(existing_guardrail.get("litellm_params", {})))
existing_litellm_params: Final = _as_str_object_mapping(dict(existing_guardrail.get("litellm_params", {})))
litellm_params = LitellmParams(**existing_litellm_params)
if request.litellm_params is not None:
requested_litellm_params: Final = request.litellm_params.model_dump(exclude_unset=True)
litellm_params_dict: Final = litellm_params.model_dump(exclude_unset=True)
litellm_params_dict.update(requested_litellm_params)
litellm_params = LitellmParams(**litellm_params_dict)
merged_litellm_params: Final = _as_str_object_mapping(litellm_params_dict)
litellm_params = LitellmParams(**merged_litellm_params)
# Update guardrail_info if provided
guardrail_info: Final = (
@ -1193,10 +1217,12 @@ async def patch_guardrail(
litellm_params=litellm_params,
guardrail_info=guardrail_info,
)
result: Final = await GUARDRAIL_REGISTRY.update_guardrail_in_db(
guardrail_id=guardrail_id,
guardrail=guardrail,
prisma_client=prisma_client,
result: Final = _as_str_object_mapping(
await GUARDRAIL_REGISTRY.update_guardrail_in_db(
guardrail_id=guardrail_id,
guardrail=guardrail,
prisma_client=prisma_client,
)
)
guardrail_name = result.get("guardrail_name", "Unknown")
@ -1552,31 +1578,46 @@ async def validate_blocked_words_file(request: dict[str, str]):
return {"valid": False, "error": f"Validation error: {e}"}
def _get_field_type_from_annotation(field_annotation: Any) -> str:
def _dunder_origin(annotation: object) -> object:
origin: Final[object] = getattr(annotation, "__origin__", None)
return origin
def _dunder_name(annotation: object) -> object:
name: Final[object] = getattr(annotation, "__name__", None)
return name
def _dunder_args(annotation: object) -> tuple[object, ...]:
args: Final[tuple[object, ...]] = getattr(annotation, "__args__", ())
return args
def _get_field_type_from_annotation(field_annotation: object) -> str:
"""
Convert a Python type annotation to a UI-friendly type string
"""
# Handle Union types (like Optional[T])
if get_origin(field_annotation) is Union or get_origin(field_annotation) is UnionType:
# For Optional[T], get the non-None type
args: Final = get_args(field_annotation)
args: Final[tuple[object, ...]] = get_args(field_annotation)
non_none_args: Final = [arg for arg in args if arg is not type(None)]
if non_none_args:
field_annotation = non_none_args[0]
# Handle List types
if hasattr(field_annotation, "__origin__") and field_annotation.__origin__ is list:
if hasattr(field_annotation, "__origin__") and _dunder_origin(field_annotation) is list:
return "array"
# Handle Dict types
if hasattr(field_annotation, "__origin__") and field_annotation.__origin__ is dict:
if hasattr(field_annotation, "__origin__") and _dunder_origin(field_annotation) is dict:
return "dict"
# Handle Literal types
if hasattr(field_annotation, "__origin__") and hasattr(field_annotation, "__args__"):
# Check for Literal types (Python 3.8+)
origin: Final = field_annotation.__origin__
if hasattr(origin, "__name__") and origin.__name__ == "Literal":
origin: Final = _dunder_origin(field_annotation)
if hasattr(origin, "__name__") and _dunder_name(origin) == "Literal":
return "select" # For dropdown/select inputs
# Handle basic types
@ -1595,66 +1636,66 @@ def _get_field_type_from_annotation(field_annotation: Any) -> str:
return "string"
def _extract_literal_values(annotation: Any) -> list[str]:
def _extract_literal_values(annotation: object) -> Sequence[object]:
"""
Extract literal values from a Literal type annotation
"""
if hasattr(annotation, "__origin__") and hasattr(annotation, "__args__"):
origin: Final = annotation.__origin__
if hasattr(origin, "__name__") and origin.__name__ == "Literal":
return list(annotation.__args__)
origin: Final = _dunder_origin(annotation)
if hasattr(origin, "__name__") and _dunder_name(origin) == "Literal":
return list(_dunder_args(annotation))
return []
def _get_dict_key_options(field_annotation: Any) -> list[str] | None:
def _get_dict_key_options(field_annotation: object) -> Sequence[object] | None:
"""
Extract key options from Dict[Literal[...], T] types
"""
if (
hasattr(field_annotation, "__origin__")
and field_annotation.__origin__ is dict
and _dunder_origin(field_annotation) is dict
and hasattr(field_annotation, "__args__")
):
args: Final = field_annotation.__args__
args: Final = _dunder_args(field_annotation)
if len(args) >= 2:
key_type: Final = args[0]
return _extract_literal_values(key_type)
return None
def _get_dict_value_type(field_annotation: Any) -> str:
def _get_dict_value_type(field_annotation: object) -> str:
"""
Get the value type from Dict[K, V] types
"""
if (
hasattr(field_annotation, "__origin__")
and field_annotation.__origin__ is dict
and _dunder_origin(field_annotation) is dict
and hasattr(field_annotation, "__args__")
):
args: Final = field_annotation.__args__
args: Final = _dunder_args(field_annotation)
if len(args) >= 2:
value_type: Final = args[1]
return _get_field_type_from_annotation(value_type)
return "string"
def _get_list_element_options(field_annotation: Any) -> list[str] | None:
def _get_list_element_options(field_annotation: object) -> Sequence[object] | None:
"""
Extract element options from List[Literal[...]] types
"""
if (
hasattr(field_annotation, "__origin__")
and field_annotation.__origin__ is list
and _dunder_origin(field_annotation) is list
and hasattr(field_annotation, "__args__")
):
args: Final = field_annotation.__args__
args: Final = _dunder_args(field_annotation)
if len(args) >= 1:
element_type: Final = args[0]
return _extract_literal_values(element_type)
return None
def _should_skip_optional_params(field_name: str, field_annotation: Any) -> bool:
def _should_skip_optional_params(field_name: str, field_annotation: object) -> bool:
"""Check if optional_params field should be skipped (not meaningfully overridden)."""
if field_name != "optional_params":
return False
@ -1664,12 +1705,12 @@ def _should_skip_optional_params(field_name: str, field_annotation: Any) -> bool
# Check if the annotation is still a generic TypeVar (not specialized)
if isinstance(field_annotation, TypeVar) or (
hasattr(field_annotation, "__origin__") and field_annotation.__origin__ is TypeVar
hasattr(field_annotation, "__origin__") and _dunder_origin(field_annotation) is TypeVar
):
return True
# Also skip if it's a generic type that wasn't specialized
if hasattr(field_annotation, "__name__") and field_annotation.__name__ in (
if hasattr(field_annotation, "__name__") and _dunder_name(field_annotation) in (
"T",
"TypeVar",
):
@ -1677,18 +1718,18 @@ def _should_skip_optional_params(field_name: str, field_annotation: Any) -> bool
# Handle Optional[T] where T is still a TypeVar
if hasattr(field_annotation, "__args__"):
non_none_args: Final = [arg for arg in field_annotation.__args__ if arg is not type(None)]
non_none_args: Final = [arg for arg in _dunder_args(field_annotation) if arg is not type(None)]
if non_none_args and isinstance(non_none_args[0], TypeVar):
return True
return False
def _unwrap_optional_type(field_annotation: Any) -> Any:
def _unwrap_optional_type(field_annotation: object) -> object:
"""Unwrap Optional types to get the actual type."""
if get_origin(field_annotation) is Union or get_origin(field_annotation) is UnionType:
# For Optional[BaseModel], get the non-None type
args: Final = get_args(field_annotation)
args: Final[tuple[object, ...]] = get_args(field_annotation)
non_none_args: Final = [arg for arg in args if arg is not type(None)]
if non_none_args:
return non_none_args[0]
@ -1696,20 +1737,20 @@ def _unwrap_optional_type(field_annotation: Any) -> Any:
def _build_field_dict(
field: Any,
field_annotation: Any,
field: "FieldInfo",
field_annotation: object,
description: str,
required: bool,
) -> dict[str, Any]:
) -> dict[str, object]:
"""Build field dictionary for non-nested fields."""
# Determine the field type from annotation
field_type = _get_field_type_from_annotation(field_annotation)
# Check for custom UI type override
field_json_schema_extra: Final = getattr(field, "json_schema_extra", {})
field_json_schema_extra: Final[Mapping[str, object]] = getattr(field, "json_schema_extra", {})
if field_json_schema_extra and "ui_type" in field_json_schema_extra:
ui_type: Final = field_json_schema_extra["ui_type"]
field_type = ui_type.value if hasattr(ui_type, "value") else ui_type
field_type = getattr(ui_type, "value", ui_type)
elif field_json_schema_extra and "type" in field_json_schema_extra:
field_type = field_json_schema_extra["type"]
@ -1748,8 +1789,9 @@ def _build_field_dict(
field_dict["options"] = literal_options
# Add default value if it exists
if field.default is not None and field.default is not ...:
field_dict["default_value"] = field.default
field_default: Final[object] = getattr(field, "default", None)
if field_default is not None and field_default is not ...:
field_dict["default_value"] = field_default
# Copy min, max, step from json_schema_extra for number/percentage inputs
if field_json_schema_extra:
@ -1763,7 +1805,7 @@ def _build_field_dict(
def _extract_fields_recursive(
model: type[BaseModel],
depth: int = 0,
) -> dict[str, Any]:
) -> dict[str, object]:
# Check if we've exceeded the maximum recursion depth
if depth > DEFAULT_MAX_RECURSE_DEPTH:
raise HTTPException(
@ -1817,7 +1859,7 @@ def _extract_fields_recursive(
return fields
def _get_fields_from_model(model_class: type[BaseModel]) -> dict[str, Any]:
def _get_fields_from_model(model_class: type[BaseModel]) -> dict[str, object]:
"""
Get the fields from a Pydantic model as a nested dictionary structure
"""
@ -2141,7 +2183,26 @@ def _resolve_guardrail_input_type(active_guardrail: CustomGuardrail, input_type:
return "response" if input_type == "response" else "request"
def _patch_logging_obj_for_guardrail(litellm_logging_obj: Any, request: ApplyGuardrailRequest) -> None:
class _GuardrailLoggingObj(Protocol):
call_type: str
model_call_details: dict[str, object]
@property
def update_messages(self) -> "Callable[..., object]": ...
@property
def async_success_handler(self) -> "Callable[..., Awaitable[object]]": ...
@property
def success_handler(self) -> "Callable[..., object]": ...
class _GuardrailProxyLogging(Protocol):
@property
def post_call_success_hook(self) -> "Callable[..., Awaitable[object]]": ...
def _patch_logging_obj_for_guardrail(litellm_logging_obj: _GuardrailLoggingObj, request: ApplyGuardrailRequest) -> None:
"""Configure the logging object so Langfuse/OTEL extract input and output correctly."""
litellm_logging_obj.call_type = "pass_through_endpoint"
litellm_logging_obj.model_call_details["call_type"] = "pass_through_endpoint"
@ -2151,8 +2212,8 @@ def _patch_logging_obj_for_guardrail(litellm_logging_obj: Any, request: ApplyGua
async def _emit_guardrail_success_logs(
proxy_logging_obj: Any,
litellm_logging_obj: Any,
proxy_logging_obj: _GuardrailProxyLogging,
litellm_logging_obj: _GuardrailLoggingObj | None,
data: dict,
user_api_key_dict: UserAPIKeyAuth,
response: ApplyGuardrailResponse,

View file

@ -9,11 +9,14 @@ import os
import sys
sys.path.insert(0, os.path.abspath("../..")) # Adds the parent directory to the system path
import asyncio
import copy
import json
import re
import sys
from collections.abc import AsyncGenerator, Mapping
from collections.abc import AsyncGenerator, Mapping, Sequence
from datetime import datetime, timezone
from itertools import accumulate, groupby
from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, NamedTuple, Optional, cast
import httpx
@ -23,6 +26,7 @@ from pydantic import TypeAdapter, ValidationError
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.caching import DualCache
from litellm.constants import BEDROCK_APPLY_GUARDRAIL_CHUNK_BUDGET_CHARS
from litellm.exceptions import ModifyResponseException
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.litellm_core_utils.core_helpers import redact_nested_match_and_regex_keys
@ -46,6 +50,7 @@ from litellm.types.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
BedrockGuardrailOutput,
BedrockGuardrailQualifier,
BedrockGuardrailResponse,
BedrockGuardrailUsage,
BedrockRequest,
BedrockTextContent,
)
@ -53,6 +58,7 @@ from litellm.types.utils import GenericGuardrailAPIInputs
if TYPE_CHECKING:
from botocore.awsrequest import AWSPreparedRequest
from botocore.credentials import Credentials
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
@ -71,6 +77,17 @@ from litellm.types.utils import (
GUARDRAIL_NAME: Final = "bedrock"
_BEDROCK_DYNAMIC_BODY_DENYLIST: Final = frozenset({"content", "source"})
_BEDROCK_TOO_LARGE_ERROR_SUBSTRINGS: Final = (
"text unit",
"maximum input size",
"content size",
"too long",
"too large",
"exceeds the maximum",
)
_BEDROCK_APPLY_GUARDRAIL_MAX_THROTTLE_RETRIES: Final = 3
_BEDROCK_APPLY_GUARDRAIL_BASE_BACKOFF_SECONDS: Final = 0.5
_BEDROCK_WHITESPACE: Final = re.compile(r"\s")
# Resource-less, detect-only InvokeGuardrailChecks API (no guardrail resource required).
_BEDROCK_INVOKE_GUARDRAIL_CHECKS_PATH: Final = "/guardrail-checks/invoke"
# InvokeGuardrailChecks accepts at most 10 content blocks per message. A message with
@ -118,6 +135,29 @@ class GuardrailMessageFilterResult(NamedTuple):
target_indices: list[int] | None
class BedrockContentChunkResult(NamedTuple):
"""One chunk's ApplyGuardrail response, paired with enough bookkeeping to
reconstruct global masked-output positions once every chunk is back.
`content` is the exact content items this chunk was called with -- needed
so an all-clear chunk (empty `outputs`) can still contribute one unmasked
placeholder per item it covers, keeping every later chunk's masked text
aligned to its original global position. `fragment_group_size` is 1 for an
ordinary chunk, and otherwise the total number of consecutive chunk results
that together make up ONE original content item's own text (split because a
list of length 1 could not be bisected by list length). All of them must be
concatenated back into that one item's masked output rather than treated as
separate items. It is a count rather than a boolean because one item can be
bisected more than once: two levels of splitting produce four fragments for
a single item, not two, and grouping them in fixed pairs would emit two
outputs for one message and shift every later message's masked text.
"""
response: BedrockGuardrailResponse
content: tuple[BedrockContentItem, ...]
fragment_group_size: int
class ApplyGuardrailMessageSelection(NamedTuple):
"""Messages selected for an apply_guardrail scan + write-back metadata."""
@ -168,12 +208,14 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
content_filter_threshold: float | None = 0.5,
prompt_attack_threshold: float | None = 0.5,
pii_confidence_threshold: float | None = 0.5,
chunk_budget_chars: int = BEDROCK_APPLY_GUARDRAIL_CHUNK_BUDGET_CHARS,
**kwargs,
):
self.async_handler = get_async_httpx_client(llm_provider=httpxSpecialProvider.GuardrailCallback)
self.guardrailIdentifier = guardrailIdentifier
self.guardrailVersion = guardrailVersion
self.guardrail_provider = "bedrock"
self.chunk_budget_chars = chunk_budget_chars
self.experimental_use_latest_role_message_only = bool(kwargs.get("experimental_use_latest_role_message_only"))
# Resource-less, detect-only InvokeGuardrailChecks mode. Present `checks`
@ -759,12 +801,35 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
request_data: dict | None = None,
logging_event_type: GuardrailEventHooks | None = None,
) -> BedrockGuardrailResponse:
"""Scan `messages`/`response` with ApplyGuardrail, chunking if it is too large.
Content is bin-packed into budget-sized batches and each batch posted
sequentially, every batch independently falling back to bisection if AWS
rejects it. The per-batch responses are merged so callers cannot tell whether
chunking happened.
Content using contextual grounding opts out of chunking entirely: grounding is
scored holistically against the whole reference source, so bisecting it would
fragment that evaluation and yield misleading scores. Such a request keeps the
old behavior of surfacing a too-large error rather than being split.
`logging_event_type` drives what UI and spend logs report. It is distinct from
Bedrock's `source`, which is INPUT vs OUTPUT for the API body and must not be
confused with the proxy hook (pre_call / during_call / post_call); when omitted,
the legacy source-derived mapping is kept for backward compatibility.
A guardrail *block* is logged where it happens, in
`_post_apply_guardrail_content`, because chunking stops immediately and there is
no later merged response to log instead. Everything else that fails out of the
chunking flow (an unrecoverable too-large error, a non-size validation error,
exhausted throttle retries) is a genuine end-to-end failure of this one logical
guardrail call and is logged exactly once here.
"""
start_time: Final = datetime.now(timezone.utc)
credentials, aws_region_name = self._load_credentials()
bedrock_request_data: Final[dict] = dict(
self.convert_to_bedrock_format(source=source, messages=messages, response=response)
)
bedrock_guardrail_response: BedrockGuardrailResponse = BedrockGuardrailResponse()
api_key: str | None = None
if request_data:
dynamic_request_body_params = self.get_guardrail_dynamic_request_body_params(request_data=request_data)
@ -778,6 +843,257 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
if request_data.get("api_key") is not None:
api_key = request_data["api_key"]
event_type: Final = (
logging_event_type
if logging_event_type is not None
else (GuardrailEventHooks.pre_call if source == "INPUT" else GuardrailEventHooks.post_call)
)
content: Final[tuple[BedrockContentItem, ...]] = tuple(bedrock_request_data.get("content") or ())
allow_chunking: Final = not self._content_uses_contextual_grounding(content)
try:
responses: Final = await self._apply_guardrail_content_with_chunking(
content=content,
base_request_data=bedrock_request_data,
credentials=credentials,
aws_region_name=aws_region_name,
api_key=api_key,
request_data=request_data,
event_type=event_type,
start_time=start_time,
allow_chunking=allow_chunking,
)
except HTTPException as exc:
if not isinstance(exc.detail, dict):
self._log_apply_guardrail_failure(
detail=exc.detail,
request_data=request_data,
event_type=event_type,
start_time=start_time,
)
raise
merged_response: Final = self._merge_bedrock_guardrail_responses(responses)
self._log_apply_guardrail_success(
merged_response=merged_response,
request_data=request_data,
event_type=event_type,
start_time=start_time,
)
return merged_response
async def _apply_guardrail_content_with_chunking(
self,
content: Sequence[BedrockContentItem],
base_request_data: Mapping[str, Any],
credentials: "Credentials",
aws_region_name: str,
api_key: str | None,
request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper
event_type: GuardrailEventHooks,
start_time: "datetime",
allow_chunking: bool,
) -> tuple[BedrockContentChunkResult, ...]:
"""Post `content` to ApplyGuardrail, chunking only if AWS rejects it as too large.
Tries `content` as a single call first. AWS's per-request "maximum input
size in text units" quota is account/region/policy-dependent and cannot be
predicted ahead of time, so it is only ever discovered reactively: on an
error whose message indicates the input was too large (a ThrottlingException
in practice, a ValidationException per the docs -- see
``_is_input_too_large_error``), the content is re-sent in smaller pieces.
Probing with the whole payload first is what keeps a request AWS would have
accepted at exactly one call. Packing into fixed batches up front instead
would split conversations AWS was happy to take whole, multiplying billed
calls and guardrail latency on traffic that never had a size problem, and
no fixed budget can avoid that because the real cap is unknown here.
Once a rejection proves the payload is over the cap, a multi-item payload is
re-sent as ``chunk_budget_chars``-sized batches rather than bisected: that
reaches a working size in one step instead of paying an O(log n) ladder of
rejected calls. Bisection remains the fallback for anything bin-packing
cannot make smaller, which is what makes the recursion terminate: a batch
already inside the budget packs back to itself, so it falls through to the
split below. A single oversized
content item (one very long message) is split by its own text instead of
by list length, since a list of length 1 has no items left to bisect --
the resulting fragments all carry a ``fragment_group_size`` so the merge
step can recombine them into the one content item they came from, rather
than treating each fragment as its own item when reconstructing positions
for masking. That count covers however many fragments the item ended up
split into, not just two, since it can be bisected repeatedly: the
outermost single-item split stamps the total leaf count on every leaf
below it, overwriting any smaller count an inner split had set. A real
guardrail block on any (sub-)chunk raises immediately
-- callers must not lose that signal by continuing to post the remaining
chunks.
"""
try:
response: Final = await self._post_apply_guardrail_content_with_retry(
content=content,
base_request_data=base_request_data,
credentials=credentials,
aws_region_name=aws_region_name,
api_key=api_key,
request_data=request_data,
event_type=event_type,
start_time=start_time,
)
return (
BedrockContentChunkResult(
response=response,
content=tuple(content),
fragment_group_size=1,
),
)
except HTTPException as exc:
if allow_chunking and self._is_input_too_large_error(exc.detail):
batches: Final = self._bin_pack_bedrock_content(content, budget=self.chunk_budget_chars)
if len(batches) > 1:
verbose_proxy_logger.warning(
"Bedrock Guardrail: ApplyGuardrail rejected %d content item(s) as too large; "
"re-sending as %d batches of at most %d characters",
len(content),
len(batches),
self.chunk_budget_chars,
)
batch_results: Final = [ # mutable-ok: await needs a list comprehension; frozen to a tuple below
await self._apply_guardrail_content_with_chunking(
content=batch,
base_request_data=base_request_data,
credentials=credentials,
aws_region_name=aws_region_name,
api_key=api_key,
request_data=request_data,
event_type=event_type,
start_time=start_time,
allow_chunking=allow_chunking,
)
for batch in batches
]
return tuple(result for results in batch_results for result in results)
split_content: Final = self._split_bedrock_content(content)
if split_content is None:
raise
first_half, second_half = split_content
is_single_item_text_split: Final = len(content) == 1
verbose_proxy_logger.warning(
"Bedrock Guardrail: ApplyGuardrail rejected %d content item(s) as too large; "
"splitting into %d + %d and retrying each",
len(content),
len(first_half),
len(second_half),
)
first_results: Final = await self._apply_guardrail_content_with_chunking(
content=first_half,
base_request_data=base_request_data,
credentials=credentials,
aws_region_name=aws_region_name,
api_key=api_key,
request_data=request_data,
event_type=event_type,
start_time=start_time,
allow_chunking=allow_chunking,
)
second_results: Final = await self._apply_guardrail_content_with_chunking(
content=second_half,
base_request_data=base_request_data,
credentials=credentials,
aws_region_name=aws_region_name,
api_key=api_key,
request_data=request_data,
event_type=event_type,
start_time=start_time,
allow_chunking=allow_chunking,
)
combined_results: Final = tuple(first_results) + tuple(second_results)
if is_single_item_text_split:
return tuple(
result._replace(fragment_group_size=len(combined_results)) for result in combined_results
)
return combined_results
raise
async def _post_apply_guardrail_content_with_retry(
self,
content: Sequence[BedrockContentItem],
base_request_data: Mapping[str, Any],
credentials: "Credentials",
aws_region_name: str,
api_key: str | None,
request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper
event_type: GuardrailEventHooks,
start_time: "datetime",
) -> BedrockGuardrailResponse:
"""Post one ApplyGuardrail call for `content`, retrying with exponential
backoff on AWS ThrottlingException (HTTP 429).
Chunking already trades one oversized call for several smaller ones, so
retries here are capped low -- they must not multiply per-request latency
by an order of magnitude when the account's per-second text-unit quota is
the binding constraint rather than the per-request size quota.
A too-large rejection is deliberately excluded from the retry. AWS reports
it as a ThrottlingException (429), not only as a ValidationException, but
unlike a genuine throttle it is not transient: re-posting the same
oversized content can never succeed. Retrying it would burn every backoff
sleep and every (billed) attempt before the caller's bisection gets a
chance to split the content, at every level of the recursion.
"""
for attempt in range(_BEDROCK_APPLY_GUARDRAIL_MAX_THROTTLE_RETRIES + 1):
try:
return await self._post_apply_guardrail_content(
content=content,
base_request_data=base_request_data,
credentials=credentials,
aws_region_name=aws_region_name,
api_key=api_key,
request_data=request_data,
event_type=event_type,
start_time=start_time,
)
except HTTPException as exc:
if (
exc.status_code != 429
or self._is_input_too_large_error(exc.detail)
or attempt >= _BEDROCK_APPLY_GUARDRAIL_MAX_THROTTLE_RETRIES
):
raise
await asyncio.sleep(_BEDROCK_APPLY_GUARDRAIL_BASE_BACKOFF_SECONDS * (2**attempt))
raise HTTPException(status_code=500, detail="Bedrock guardrail throttle retries exhausted")
async def _post_apply_guardrail_content(
self,
content: Sequence[BedrockContentItem],
base_request_data: Mapping[str, Any],
credentials: "Credentials",
aws_region_name: str,
api_key: str | None,
request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper
event_type: GuardrailEventHooks,
start_time: "datetime",
) -> BedrockGuardrailResponse:
"""Make exactly one signed ApplyGuardrail HTTP call for `content` and
parse the result. Raises HTTPException on a guardrail block or any
non-200 response (including 429, handled by the retry wrapper above).
AWS also reports some failures inside a 200 body, tagging ``Output.__type``
with an Exception marker. Those deliberately do NOT raise: the request proceeds,
matching the behaviour of this code before chunking existed. The marker survives
the merge, so the one consolidated log entry still records
``guardrail_failed_to_respond`` rather than a success. Making that path fail
closed is a separate change, tracked apart from this PR, and belongs behind the
existing ``unreachable_fallback`` setting rather than a hardcoded status.
A block is logged here rather than by the caller: it ends the whole chunking
flow immediately, with no further chunks attempted, so there is no later
merged response for the caller to log instead.
"""
bedrock_request_data: Final = { # mutable-ok: outbound JSON request body
**base_request_data,
"content": content,
} # mutable-ok: outbound JSON request body
prepared_request: Final = self._prepare_request(
credentials=credentials,
data=bedrock_request_data,
@ -792,42 +1108,16 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
prepared_request.headers,
)
# UI / spend logs use event_type. Bedrock's `source` is INPUT vs OUTPUT for the API
# body, which must not be confused with the proxy hook (pre_call / during_call /
# post_call). When omitted, keep legacy mapping for backward compatibility.
if logging_event_type is not None:
event_type = logging_event_type
else:
event_type = GuardrailEventHooks.pre_call if source == "INPUT" else GuardrailEventHooks.post_call
httpx_response: Final = await self._sign_and_post(
prepared_request=prepared_request,
request_data=request_data,
event_type=event_type,
start_time=start_time,
log_transport_failure=False,
)
#########################################################
# Add guardrail information to request trace
#########################################################
_json_response: Final = httpx_response.json()
tracing_detail: Final = self._build_tracing_detail(_json_response)
# Raw Bedrock JSON is passed here; match/regex redaction runs once inside
# CustomGuardrail.add_standard_logging_guardrail_information_to_request_data.
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider=self.guardrail_provider,
guardrail_json_response=_json_response,
request_data=request_data or {},
guardrail_status=self._get_bedrock_guardrail_response_status(response=httpx_response),
start_time=start_time.timestamp(),
end_time=datetime.now(timezone.utc).timestamp(),
duration=(datetime.now(timezone.utc) - start_time).total_seconds(),
event_type=event_type,
tracing_detail=tracing_detail or None,
)
#########################################################
if httpx_response.status_code == 200:
_json_response: Final = httpx_response.json()
# check if the response was flagged
verbose_proxy_logger.debug(
"Bedrock AI response : %s",
@ -835,19 +1125,462 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
)
bedrock_guardrail_response = BedrockGuardrailResponse(**_json_response)
if self._should_raise_guardrail_blocked_exception(bedrock_guardrail_response):
self._log_apply_guardrail_attempt(
httpx_response=httpx_response,
json_response=_json_response,
request_data=request_data,
event_type=event_type,
start_time=start_time,
)
raise self._get_http_exception_for_blocked_guardrail(
bedrock_guardrail_response, request_data=request_data
)
else:
status_code, detail_message = self._parse_bedrock_guardrail_error_response(httpx_response)
verbose_proxy_logger.error(
"Bedrock AI: error in response. Status code: %s, response: %s",
httpx_response.status_code,
httpx_response.text,
)
raise HTTPException(status_code=status_code, detail=detail_message)
return bedrock_guardrail_response
return bedrock_guardrail_response
status_code, detail_message = self._parse_bedrock_guardrail_error_response(httpx_response)
verbose_proxy_logger.error(
"Bedrock AI: error in response. Status code: %s, response: %s",
httpx_response.status_code,
httpx_response.text,
)
raise HTTPException(status_code=status_code, detail=detail_message)
def _log_apply_guardrail_attempt(
self,
httpx_response: httpx.Response,
json_response: dict, # mutable-ok: raw AWS JSON payload
request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper
event_type: GuardrailEventHooks,
start_time: "datetime",
) -> None:
"""Log a single ApplyGuardrail HTTP attempt as-is (its own status,
derived from its own response). Used only for the blocked-content
case, which ends the whole chunking flow immediately."""
tracing_detail: Final = self._build_tracing_detail(BedrockGuardrailResponse(**json_response))
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider=self.guardrail_provider,
guardrail_json_response=json_response,
request_data=request_data or {}, # mutable-ok: logging helper requires a dict
guardrail_status=self._get_bedrock_guardrail_response_status(response=httpx_response),
start_time=start_time.timestamp(),
end_time=datetime.now(timezone.utc).timestamp(),
duration=(datetime.now(timezone.utc) - start_time).total_seconds(),
event_type=event_type,
tracing_detail=tracing_detail or None,
)
def _log_apply_guardrail_success(
self,
merged_response: BedrockGuardrailResponse,
request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper
event_type: GuardrailEventHooks,
start_time: "datetime",
) -> None:
"""Log one logical ApplyGuardrail call -- possibly several chunk calls
under the hood -- using its final merged response, so a chunked
request produces exactly one telemetry entry, the same as an
unchunked one would.
AWS can report a failure inside an HTTP 200 body by tagging
``Output.__type`` with an exception marker. That marker survives the merge,
so the status is derived from the merged response rather than assumed to be
a success, which is what the pre-chunking code reported for that shape."""
tracing_detail: Final = self._build_tracing_detail(merged_response)
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider=self.guardrail_provider,
guardrail_json_response=dict(merged_response), # mutable-ok: logging helper requires a dict
request_data=request_data or {}, # mutable-ok: logging helper requires a dict
guardrail_status=(
"guardrail_failed_to_respond"
if "Exception" in str((merged_response.get("Output") or {}).get("__type", ""))
else "success"
),
start_time=start_time.timestamp(),
end_time=datetime.now(timezone.utc).timestamp(),
duration=(datetime.now(timezone.utc) - start_time).total_seconds(),
event_type=event_type,
tracing_detail=tracing_detail or None,
)
def _log_apply_guardrail_failure(
self,
detail: object,
request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper
event_type: GuardrailEventHooks,
start_time: "datetime",
) -> None:
"""Log one logical ApplyGuardrail call that failed end-to-end (an
unrecoverable too-large error, a non-size validation error, or
exhausted throttle retries) as a single failure, rather than logging
every failed attempt chunking made along the way."""
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider=self.guardrail_provider,
guardrail_json_response={"error": str(detail)}, # mutable-ok: logging helper requires a dict
request_data=request_data or {}, # mutable-ok: logging helper requires a dict
guardrail_status="guardrail_failed_to_respond",
start_time=start_time.timestamp(),
end_time=datetime.now(timezone.utc).timestamp(),
duration=(datetime.now(timezone.utc) - start_time).total_seconds(),
event_type=event_type,
)
@staticmethod
def _content_uses_contextual_grounding(content: Sequence[BedrockContentItem]) -> bool:
"""True if any content item carries a contextual-grounding qualifier
(``grounding_source``, ``query``, or the ``guard_content`` the response
itself is tagged with once grounding is present)."""
for item in content:
if (item.get("text") or {}).get("qualifiers"): # mutable-ok: read-only empty fallback
return True
return False
@staticmethod
def _bin_pack_bedrock_content(
content: Sequence[BedrockContentItem],
budget: int,
) -> tuple[tuple[BedrockContentItem, ...], ...]:
"""Pack whole content items, in order, into batches whose combined text
length stays within `budget`, in a single pass that carries the running
total rather than re-summing the open batch per item.
This is the fast-path half of the hybrid chunking strategy: bin-packing
at a conservative fixed budget keeps the common case at O(n / budget)
ApplyGuardrail calls instead of the O(log n) round trips pure reactive
bisection pays on every oversized request. An item whose own text
already exceeds `budget` is not split here -- it becomes its own
(still oversized) batch and is sent as-is; if AWS rejects that batch as
too large, `_apply_guardrail_content_with_chunking`'s existing
recursive-bisection fallback takes over for that batch only.
`budget` comes from the guardrail's ``chunk_budget_chars`` setting and
defaults to 25,000, matching ApplyGuardrail's default quota of 25 text
units (roughly 1,000 characters each) per second. Packing to that size and
posting sequentially is what keeps chunking from tripping the rate quota
and trading a size error for a throttle. Accounts with raised quotas can
configure a larger budget to spend fewer calls.
The budget is not a correctness dependency either way. AWS's effective cap
varies by account, region, and policy, is not a fixed character count, and
cannot be read from config, so any batch it still rejects falls back to
bisection, which self-corrects however wrong the value was. An over-large
budget therefore costs one extra probe-and-bisect round trip rather than
failing the request.
"""
if not content:
return (tuple(content),)
lengths: Final = tuple(len((item.get("text") or BedrockTextContent()).get("text") or "") for item in content)
def assign(carried: tuple[int, int], length: int) -> tuple[int, int]:
batch_index, used = carried
if used + length <= budget:
return batch_index, used + length
return batch_index + 1, length
batch_numbers: Final = (index for index, _ in tuple(accumulate(lengths, assign, initial=(0, 0)))[1:])
return tuple(
tuple(item for _, item in group)
for _, group in groupby(zip(batch_numbers, content), key=lambda pair: pair[0])
)
@staticmethod
def _split_bedrock_content(
content: Sequence[BedrockContentItem],
) -> tuple[tuple[BedrockContentItem, ...], tuple[BedrockContentItem, ...]] | None:
"""Bisect `content` into two roughly-equal, non-empty halves.
When `content` already holds more than one item, it is split by list
length. When it holds exactly one item, that item's own text is split
instead (a list of length 1 has no items left to bisect, but one very
long message is still a single content item) -- at the whitespace
character nearest the midpoint rather than a raw character index, so
the cut never lands inside a word/token. This is a plain, lossless
cut with no overlap: concatenating the two fragments in order always
reproduces the original text exactly, so merging back at
``_merge_logical_unit_outputs`` needs no reconciliation step.
Known, accepted limitation: whitespace splitting only guards against
*accidentally* severing a single token (one denied word, one PII
pattern) across the cut. It does not, and cannot without an overlap
window, stop a *multi-word* denied phrase deliberately positioned to
straddle the boundary -- each fragment can scan clean on its own and
still reassemble into the flagged phrase. AWS's own guidance on this
API acknowledges the same gap for input chunking ("a critical piece of
text could span two (or more) chunks if not carefully divided") with
no documented resolution, and overlap-and-reconcile was evaluated and
rejected for this PR: AWS's masking output has no documented
length-preservation guarantee, so reconciling an overlap region against
masked text is not sound in general. Out of scope for this PR.
Returns None when there is nothing left to split -- a single item
whose text is too short to halve into two non-empty pieces -- so the
caller can give up and propagate the original too-large error instead
of recursing forever.
"""
if len(content) > 1:
midpoint: Final = max(1, len(content) // 2)
return tuple(content[:midpoint]), tuple(content[midpoint:])
text_content: Final = content[0].get("text") or BedrockTextContent()
text: Final = text_content.get("text") or ""
if len(text) < 2:
return None
split_at: Final = BedrockGuardrail._nearest_whitespace_split_index(text)
qualifiers: Final = text_content.get("qualifiers")
def fragment(piece: str) -> BedrockContentItem:
block: Final = (
BedrockTextContent(text=piece, qualifiers=qualifiers) if qualifiers else BedrockTextContent(text=piece)
)
return BedrockContentItem(text=block)
return (fragment(text[:split_at]),), (fragment(text[split_at:]),)
@staticmethod
def _nearest_whitespace_split_index(text: str) -> int:
"""Return the index nearest `text`'s midpoint that falls on a whitespace
boundary, so splitting `text[:i]` / `text[i:]` there never severs a word.
Any Unicode whitespace counts, not just an ASCII space. Matching only `" "`
would leave the boundary unguarded for exactly the payloads that get large
enough to need splitting: JSON lines, source code, logs and transcripts are
newline or tab delimited, so a deny-listed word sitting at the midpoint of
one would be cut in half, scan clean on both fragments, and reassemble
intact.
The returned index always leaves both sides non-empty, which is what makes
the caller's recursion terminate. A boundary that would put the split at 0
or at ``len(text)`` is discarded: it would hand back a fragment identical to
the text just rejected as too large, AWS would reject that again, and each
retry would re-split it into the same unchanged fragment until the stack ran
out. The dangerous shape is a text whose only space at or after the midpoint
is its final character.
Falls back to the raw midpoint when no usable whitespace boundary exists, either
because `text` has none at all (a single giant token) or because the only
candidates were degenerate. That is still a correct, lossless split, just no
longer guaranteed word-safe for those cases. `text` must be at least two
characters, which `_split_bedrock_content` guarantees, so the midpoint itself
is never degenerate.
"""
midpoint: Final = len(text) // 2
before: Final = max((found.end() for found in _BEDROCK_WHITESPACE.finditer(text, 0, midpoint)), default=None)
after_match: Final = _BEDROCK_WHITESPACE.search(text, midpoint)
candidates: Final = sorted(
(split for split in (before, after_match.end() if after_match else None) if split is not None),
key=lambda split: abs(split - midpoint),
)
return next((split for split in candidates if 0 < split < len(text)), midpoint)
@staticmethod
def _is_input_too_large_error(detail: object) -> bool:
"""True if `detail` is an AWS error message for input exceeding the
per-request text-unit quota.
Matched on the message rather than the status code on purpose: AWS is not
consistent about which error it raises for this. Observed against a live
guardrail with an active content-filter policy, an oversized request comes
back as a *ThrottlingException* (429) reading ``Input text size (3273 text
units) exceeds the maximum allowed (1000 text units) for the content filter
policy (Classic tier)``, while the documented failure mode is a
ValidationException (400). Keying off the message covers both.
A guardrail *block* is also raised as an HTTPException with status 400,
but its ``detail`` is always a dict (built by
``_get_http_exception_for_blocked_guardrail``); a non-200 API error's
``detail`` is always the plain string returned by
``_parse_bedrock_guardrail_error_response``. Checking ``isinstance(detail,
str)`` is therefore sufficient to never mistake a real block for a
too-large error.
"""
if not isinstance(detail, str):
return False
lowered: Final = detail.lower()
return any(substring in lowered for substring in _BEDROCK_TOO_LARGE_ERROR_SUBSTRINGS)
@staticmethod
def _merge_bedrock_guardrail_responses(
chunk_results: Sequence[BedrockContentChunkResult],
) -> BedrockGuardrailResponse:
"""Merge the per-chunk ApplyGuardrail responses of a chunked request into
one, so a caller cannot tell whether chunking happened.
Only ever called with responses that all passed (a block raises
immediately from ``_apply_guardrail_content_with_chunking`` and is never
added to this list). ``action`` is only set on the merged response when
at least one chunk's raw response included it, and left absent otherwise
-- mirroring a real single-call response and matching what
``_build_tracing_detail`` treats as "Bedrock didn't report an action".
Fields this merge has no opinion on (``actionReason``, ``guardrailCoverage``,
``blockedResponse``, anything AWS adds later) are carried over from the chunk
responses rather than dropped, so the response and the logged telemetry keep
the shape a single unchunked call returned. The merged keys below win.
Per AWS's documented ApplyGuardrail contract, a single call's ``outputs``
is positionally parallel to the ``content`` items *of that call*: an
entry per item when anything in the call was masked, or an empty list
when nothing in the whole call was masked. Downstream masking
(``_apply_masking_to_messages``) walks the merged ``outputs`` by a single
running index across the *original, unchunked* message list, so a later
chunk's masked text must land at the same global position it would have
if chunking had never happened. Naively concatenating each chunk's
``outputs`` breaks that whenever a chunk had nothing masked (its empty
list would otherwise silently swallow its items' slots, shifting every
later chunk's masked text left onto the wrong message). So every
item -- masked or not -- always contributes exactly one entry here,
falling back to that item's own original (unmasked) text when its
chunk returned no output for it; a wholly-untouched result is then
collapsed back to an empty ``outputs`` list to match a real single-call
no-op response. A chunk that returns a nonzero output count not equal
to its item count is passed through as-is instead of guessed at, since
AWS's docs don't cover partial masking within one multi-item call.
"""
logical_units: Final = BedrockGuardrail._group_fragment_units(chunk_results)
per_unit_outputs: Final = tuple(BedrockGuardrail._merge_logical_unit_outputs(unit) for unit in logical_units)
merged_outputs: Final = [ # mutable-ok: logged payload; redaction only traverses dict/list
output for outputs, _ in per_unit_outputs for output in outputs
]
any_masked: Final = any(masked for _, masked in per_unit_outputs)
actions: Final = tuple(
chunk_result.response.get("action")
for chunk_result in chunk_results
if isinstance(chunk_result.response.get("action"), str)
)
merged_action: Final = (
"GUARDRAIL_INTERVENED" if "GUARDRAIL_INTERVENED" in actions else (actions[-1] if actions else None)
)
merged_assessments: Final = [ # mutable-ok: logged payload; redaction only traverses dict/list
assessment
for chunk_result in chunk_results
for assessment in (chunk_result.response.get("assessments") or []) # mutable-ok: logged payload
]
any_usage_reported: Final = any(chunk_result.response.get("usage") for chunk_result in chunk_results)
merged: Final[BedrockGuardrailResponse] = cast( # cast-ok: TypedDict assembled from a comprehension
BedrockGuardrailResponse,
{ # mutable-ok: builds the TypedDict payload
key: value for chunk_result in chunk_results for key, value in chunk_result.response.items()
},
)
if merged_action is not None:
merged["action"] = merged_action
if merged_outputs and any_masked:
merged["outputs"] = merged_outputs
merged["output"] = merged_outputs
if merged_assessments:
merged["assessments"] = merged_assessments
if any_usage_reported:
merged["usage"] = BedrockGuardrail._sum_bedrock_guardrail_usage(chunk_results)
return merged
@staticmethod
def _sum_bedrock_guardrail_usage(
chunk_results: Sequence[BedrockContentChunkResult],
) -> BedrockGuardrailUsage:
"""Sum each chunk's ``usage`` counters field-by-field into one totals dict.
Keys are taken from the responses rather than from a fixed list, so a counter
this code does not know about (AWS has added several) is still summed and
reported instead of being silently dropped to zero."""
chunk_usages: Final = tuple(
chunk_result.response.get("usage") or {} # mutable-ok: read-only empty fallback
for chunk_result in chunk_results
)
return cast( # cast-ok: TypedDict assembled from a comprehension
BedrockGuardrailUsage,
{ # mutable-ok: builds the TypedDict payload
key: sum(usage.get(key) or 0 for usage in chunk_usages)
for key in dict.fromkeys(key for usage in chunk_usages for key in usage)
},
)
@staticmethod
def _group_fragment_units(
chunk_results: Sequence[BedrockContentChunkResult],
) -> tuple[tuple[BedrockContentChunkResult, ...], ...]:
"""Group consecutive text-fragment chunk results back into the one content
item each group came from, leaving every ordinary chunk result as a unit of
one.
The group size is read off the results themselves rather than assumed,
because a single content item can be bisected repeatedly: two levels of
splitting yield four fragments for one item, not two. Assuming a fixed pair
here would emit two outputs for one message and shift every later message's
masked text onto the wrong message."""
def advance(carried: tuple[int, bool], result: BedrockContentChunkResult) -> tuple[int, bool]:
remaining, _ = carried
if remaining == 0:
return max(1, result.fragment_group_size) - 1, True
return remaining - 1, False
starts: Final = tuple(
index
for index, (_, starts_unit) in enumerate(tuple(accumulate(chunk_results, advance, initial=(0, False)))[1:])
if starts_unit
)
return tuple(tuple(chunk_results[start:end]) for start, end in zip(starts, starts[1:] + (len(chunk_results),)))
@staticmethod
def _merge_logical_unit_outputs(
unit: tuple[BedrockContentChunkResult, ...],
) -> tuple[tuple[BedrockGuardrailOutput, ...], bool]:
"""Reduce one logical unit (a fragment group of any size, or a single chunk
result) to the ``BedrockGuardrailOutput`` entries it contributes to the
merged response, plus whether any masking actually happened in it.
Per AWS's documented ApplyGuardrail contract, a single call's
``outputs`` is positionally parallel to the ``content`` items *of that
call*: an entry per item when anything in the call was masked, or an
empty list when nothing in the whole call was masked. Downstream
masking (``_apply_masking_to_messages``) walks the merged ``outputs``
by a single running index across the *original, unchunked* message
list, so a later chunk's masked text must land at the same global
position it would have if chunking had never happened. So every item
-- masked or not -- always contributes exactly one entry here, falling
back to that item's own original (unmasked) text when its chunk
returned no output for it. A chunk that returns a nonzero output count
not equal to its item count is passed through as-is instead of guessed
at, since AWS's docs don't cover partial masking within one multi-item
call.
A unit holding more than one result is a fragment group: every result in it
is one fragment of a single content item's text, so the group collapses to
one entry built from each fragment's masked text (or that fragment's own
original text where it came back unmasked), concatenated in order. This
holds for any group size, not only two.
"""
if len(unit) > 1:
def fragment_outputs(result: BedrockContentChunkResult) -> tuple[BedrockGuardrailOutput, ...]:
return tuple(result.response.get("outputs") or result.response.get("output") or ())
def fragment_text(result: BedrockContentChunkResult) -> str:
source: Final = (result.content[0].get("text") or {}).get( # mutable-ok: read-only fallback
"text"
) or ""
outputs: Final = fragment_outputs(result)
masked: Final = outputs[0].get("text") if outputs else None
return masked if masked is not None else source
merged_text: Final = "".join(fragment_text(result) for result in unit)
any_masked: Final = any(fragment_outputs(result) for result in unit)
return (BedrockGuardrailOutput(text=merged_text),), any_masked
(chunk_result,) = unit
chunk_outputs: Final = chunk_result.response.get("outputs") or chunk_result.response.get("output") or ()
if len(chunk_outputs) == len(chunk_result.content):
return tuple(chunk_outputs), bool(chunk_outputs)
if not chunk_outputs:
return tuple(
BedrockGuardrailOutput(
text=(item.get("text") or {}).get("text") or "" # mutable-ok: read-only fallback
)
for item in chunk_result.content
), False
return tuple(chunk_outputs), True
async def _sign_and_post(
self,
@ -855,6 +1588,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
request_data: dict | None,
event_type: GuardrailEventHooks,
start_time: "datetime",
log_transport_failure: bool = True,
) -> httpx.Response:
"""POST a signed Bedrock request, logging+raising on network/HTTP errors.
@ -862,6 +1596,20 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
transport-error handling cannot drift. Returns the raw ``httpx.Response`` on
success (including non-2xx that httpx did not raise on); the 200-path logging,
status and tracing stay with each caller because the two APIs report differently.
``log_transport_failure=False`` suppresses the ``guardrail_failed_to_respond``
entry for a non-200 that is re-raised as an ``HTTPException``, for callers that
own consolidated per-request logging. The ApplyGuardrail path needs this:
``AsyncHTTPHandler.post`` calls ``raise_for_status()``, so every non-200 lands
in this handler, and one logical request can legitimately produce several of
them (a too-large probe, then each rejected bisection level) while still
succeeding overall. Logging per attempt would report a recovered request as
several failures plus a success.
The connection-level branch below (timeout, endpoint down) still logs
unconditionally: it re-raises the original exception rather than an
``HTTPException``, so no consolidating caller catches it, and suppressing it
would drop the only record of the failure.
"""
try:
return await self.async_handler.post(
@ -882,16 +1630,19 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
status_code,
detail_message,
) = self._parse_bedrock_guardrail_error_response(err_response)
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider=self.guardrail_provider,
guardrail_json_response={"error": detail_message},
request_data=request_data or {},
guardrail_status="guardrail_failed_to_respond",
start_time=start_time.timestamp(),
end_time=datetime.now(timezone.utc).timestamp(),
duration=(datetime.now(timezone.utc) - start_time).total_seconds(),
event_type=event_type,
)
if log_transport_failure:
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider=self.guardrail_provider,
guardrail_json_response={ # mutable-ok: logging helper requires a dict
"error": detail_message
},
request_data=request_data or {}, # mutable-ok: logging helper requires a dict
guardrail_status="guardrail_failed_to_respond",
start_time=start_time.timestamp(),
end_time=datetime.now(timezone.utc).timestamp(),
duration=(datetime.now(timezone.utc) - start_time).total_seconds(),
event_type=event_type,
)
raise HTTPException(status_code=status_code, detail=detail_message) from e
except HTTPException:
raise
@ -900,7 +1651,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider=self.guardrail_provider,
guardrail_json_response={"error": str(e)},
request_data=request_data or {},
request_data=request_data or {}, # mutable-ok: logging helper requires a dict
guardrail_status="guardrail_failed_to_respond",
start_time=start_time.timestamp(),
end_time=datetime.now(timezone.utc).timestamp(),
@ -1027,7 +1778,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider=self.guardrail_provider,
guardrail_json_response={"error": detail_message},
request_data=request_data or {},
request_data=request_data or {}, # mutable-ok: logging helper requires a dict
guardrail_status="guardrail_failed_to_respond",
start_time=start_time.timestamp(),
end_time=datetime.now(timezone.utc).timestamp(),
@ -1043,7 +1794,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider=self.guardrail_provider,
guardrail_json_response={"error": str(e)},
request_data=request_data or {},
request_data=request_data or {}, # mutable-ok: logging helper requires a dict
guardrail_status="guardrail_failed_to_respond",
start_time=start_time.timestamp(),
end_time=datetime.now(timezone.utc).timestamp(),
@ -1061,7 +1812,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider=self.guardrail_provider,
guardrail_json_response=self._sanitize_invoke_checks_response_for_logging(json_response),
request_data=request_data or {},
request_data=request_data or {}, # mutable-ok: logging helper requires a dict
guardrail_status=self._get_invoke_checks_status(bool(violations)),
start_time=start_time.timestamp(),
end_time=datetime.now(timezone.utc).timestamp(),

View file

@ -19,7 +19,7 @@ request is sent with the ``X-Cisco-AI-Defense-API-Key`` header.
import json
import os
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Mapping, Sequence
from dataclasses import dataclass, replace
from datetime import datetime
from typing import TYPE_CHECKING, Any, Final, Literal
@ -94,13 +94,13 @@ class _CiscoVerdict:
is_safe: bool | None
classifications: list[str]
severity: str | None
rules: list[dict[str, Any]]
rules: list[dict[str, object]]
explanation: str | None
event_id: str | None
action: str | None = None
sanitized_text: str | None = None
sanitized_messages: list[dict[str, Any]] | None = None
sanitized_mcp_arguments: dict[str, Any] | None = None
sanitized_messages: list[dict[str, object]] | None = None
sanitized_mcp_arguments: dict[str, object] | None = None
class CiscoAIDefenseGuardrailMissingSecrets(Exception):
@ -136,7 +136,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
api_base: str | None = None,
inspection_type: str | None = None,
inspect_path: str | None = None,
enabled_rules: list[dict[str, Any]] | None = None,
enabled_rules: Sequence[object] | None = None,
integration_profile_id: str | None = None,
integration_profile_version: str | None = None,
integration_tenant_id: str | None = None,
@ -415,7 +415,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
async def async_post_call_streaming_iterator_hook(
self,
user_api_key_dict: UserAPIKeyAuth,
response: AsyncIterator[Any],
response: AsyncIterator[object],
request_data: dict,
):
"""Buffer and inspect streaming chat output before delivery."""
@ -437,7 +437,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
self.guardrail_name,
)
all_chunks: Final[list[Any]] = []
all_chunks: Final[list[object]] = []
try:
async for chunk in response:
all_chunks.append(chunk)
@ -497,7 +497,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
response_obj=assembled,
)
except HTTPException as exc:
error_obj: dict[str, Any] = self._http_exception_to_error_obj(exc)
error_obj: dict[str, object] = self._http_exception_to_error_obj(exc)
verbose_proxy_logger.warning(
"Cisco AI Defense guardrail (%s): streaming response "
"blocked — emitting SSE error event instead of "
@ -531,7 +531,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
for chunk in all_chunks:
yield chunk
def _build_block_payload(self, context: _ScanContext, verdict: _CiscoVerdict) -> dict[str, Any]:
def _build_block_payload(self, context: _ScanContext, verdict: _CiscoVerdict) -> dict[str, object]:
"""Canonical block payload used across all four block paths.
Same dict is the ``HTTPException.detail`` for chat / MCP request
@ -555,34 +555,34 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
"event_id": verdict.event_id,
}
def _http_exception_to_error_obj(self, exc: HTTPException) -> dict[str, Any]:
def _http_exception_to_error_obj(self, exc: HTTPException) -> dict[str, object]:
"""Wrap an ``HTTPException`` detail into the SSE ``error`` payload.
For Cisco's own blocks the detail is already the canonical block
payload, so this is a near-passthrough that just adds ``code``
/ ``guardrail`` defaults for non-Cisco / unstructured details.
"""
error_obj: dict[str, Any] = dict(exc.detail) if isinstance(exc.detail, dict) else {"message": str(exc.detail)}
error_obj: dict[str, object] = {**exc.detail} if isinstance(exc.detail, dict) else {"message": str(exc.detail)}
error_obj.setdefault("message", error_obj.get("error", "Guardrail block"))
error_obj.setdefault("code", exc.status_code)
error_obj.setdefault("guardrail", self.guardrail_name)
return error_obj
@classmethod
def _streaming_content_was_modified(cls, original_chunks: list[Any], assembled: ModelResponse) -> bool:
def _streaming_content_was_modified(cls, original_chunks: Sequence[object], assembled: ModelResponse) -> bool:
"""Decide whether redact changed content or tool/function arguments."""
original_text: Final = cls._extract_streaming_chunk_scan_text(original_chunks)
assembled_text: Final = " ".join(m.get("content", "") for m in cls._extract_response_messages(assembled))
return original_text != assembled_text
@classmethod
def _extract_streaming_chunk_scan_text(cls, chunks: list[Any]) -> str:
def _extract_streaming_chunk_scan_text(cls, chunks: Sequence[object]) -> str:
original_text = ""
argument_text = ""
for chunk in chunks:
choices = getattr(chunk, "choices", None) or []
for c in choices:
delta = getattr(c, "delta", None)
delta: object | None = getattr(c, "delta", None)
if delta is None:
continue
text = getattr(delta, "content", None)
@ -595,7 +595,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
args = cls._extract_tool_call_arguments(tc)
if args:
argument_text += args
fc = getattr(delta, "function_call", None)
fc: object | None = getattr(delta, "function_call", None)
if fc is not None:
args = cls._extract_function_call_arguments(fc)
if args:
@ -673,7 +673,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
allow, WARNING for intervened/redacted, ERROR is left for
upstream API failures.
"""
fields: Final[dict[str, Any]] = {
fields: Final[dict[str, object]] = {
"guardrail": self.guardrail_name,
"surface": context.surface,
"direction": context.direction,
@ -752,7 +752,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
user_api_key_dict: UserAPIKeyAuth,
direction: str = "input",
response_obj: object = None,
) -> dict[str, Any]:
) -> dict[str, object]:
url: Final = f"{self.api_base}{self.inspect_path}"
payload: Final = self._build_chat_payload(messages, request_data, user_api_key_dict)
start_time: Final = datetime.now()
@ -784,7 +784,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
messages: list[dict[str, str]],
request_data: dict,
user_api_key_dict: UserAPIKeyAuth,
) -> dict[str, Any]:
) -> dict[str, object]:
return {
"messages": messages,
"metadata": self._build_metadata(request_data, user_api_key_dict),
@ -798,9 +798,9 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
async def _post_inspection(
self,
url: str,
payload: dict[str, Any],
payload: dict[str, object],
surface: str,
) -> dict[str, Any]:
) -> dict[str, object]:
headers: Final = self._build_headers()
verbose_proxy_logger.debug(
"Cisco AI Defense guardrail: posting %s inspection to %s",
@ -856,8 +856,8 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
self,
request_data: dict,
user_api_key_dict: UserAPIKeyAuth,
) -> dict[str, Any]:
metadata: Final[dict[str, Any]] = {}
) -> dict[str, object]:
metadata: Final[dict[str, object]] = {}
user: Final = request_data.get("user") or getattr(user_api_key_dict, "user_id", None)
if user:
@ -884,8 +884,8 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
return metadata
def _build_config(self) -> dict[str, Any]:
config: Final[dict[str, Any]] = {}
def _build_config(self) -> dict[str, object]:
config: Final[dict[str, object]] = {}
if self.enabled_rules:
config["enabled_rules"] = self.enabled_rules
if self.integration_profile_id:
@ -899,7 +899,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
return config
@staticmethod
def _normalize_rule(rule: object) -> dict[str, Any]:
def _normalize_rule(rule: object) -> dict[str, object]:
"""Coerce a user-supplied rule into the wire-shape dict Cisco expects.
Accepts ``str``, ``dict``, and Pydantic model inputs.
@ -922,7 +922,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
rule = dumped
if isinstance(rule, dict):
normalized: Final[dict[str, Any]] = {}
normalized: Final[dict[str, object]] = {}
rule_name: Final = rule.get("rule_name")
if rule_name:
normalized["rule_name"] = rule_name
@ -950,7 +950,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
context: _ScanContext,
start_time: datetime,
response_obj: object = None,
) -> dict[str, Any]:
) -> dict[str, object]:
"""Parse, log, and (optionally) raise/redact on the Cisco verdict.
``context.direction`` is ``"input"`` for request scans and ``"output"``
@ -1119,10 +1119,10 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
@classmethod
def _sanitize_response_for_logging(
cls,
inspect_response: dict[str, Any],
inspect_response: Mapping[str, object],
surface: str,
action: str | None = None,
) -> dict[str, Any]:
) -> dict[str, object]:
"""Drop bulky / privacy-sensitive fields, recursing into nested dicts.
MCP verdicts are commonly nested under ``result``, so a
@ -1138,9 +1138,9 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
return sanitized
@classmethod
def _strip_sensitive_keys(cls, d: dict[str, Any]) -> dict[str, Any]:
def _strip_sensitive_keys(cls, d: Mapping[str, object]) -> dict[str, object]:
"""Recursively strip privacy-sensitive keys from a verdict dict."""
out: Final[dict[str, Any]] = {}
out: Final[dict[str, object]] = {}
for key, value in d.items():
if key.startswith("_") or key in cls._REDACTED_LOG_KEYS:
continue
@ -1222,8 +1222,8 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
@staticmethod
def _extract_jsonrpc_error(
inspect_response: dict[str, Any],
) -> dict[str, Any] | None:
inspect_response: Mapping[str, object],
) -> dict[str, object] | None:
"""Detect a JSON-RPC error envelope inside an HTTP 200 response.
The Cisco Inspect API can return ``{"error": {...}}`` (or nest one
@ -1270,7 +1270,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
@staticmethod
def _extract_sanitized_text(
inspect_response: dict[str, Any],
inspect_response: Mapping[str, object],
) -> str | None:
"""Pull ``sanitized_text`` (or camelCase variant) off the verdict."""
for key in ("sanitized_text", "sanitizedText"):
@ -1287,8 +1287,8 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
@staticmethod
def _extract_sanitized_messages(
inspect_response: dict[str, Any],
) -> list[dict[str, Any]] | None:
inspect_response: Mapping[str, object],
) -> list[dict[str, object]] | None:
"""Pull a sanitized OpenAI-format messages array off the verdict.
Cisco can return the rewrite under several keys; we accept any of
@ -1354,7 +1354,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
def _redact_mcp_input(
request_data: dict,
sanitized_text: str | None,
sanitized_mcp_arguments: dict[str, Any] | None,
sanitized_mcp_arguments: dict[str, object] | None,
) -> bool:
"""Rewrite MCP request arguments in all locations the proxy reads."""
if sanitized_mcp_arguments is not None:
@ -1388,7 +1388,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
self,
request_data: dict,
sanitized_text: str | None,
sanitized_messages: list[dict[str, Any]] | None,
sanitized_messages: list[dict[str, object]] | None,
) -> bool:
"""Rewrite chat request input (``messages`` or ``input``)."""
if sanitized_messages and self._extract_tool_definition_text(request_data):
@ -1444,7 +1444,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
cls,
request_data: dict,
sanitized_text: str | None,
sanitized_messages: list[dict[str, Any]] | None,
sanitized_messages: list[dict[str, object]] | None,
) -> bool:
if sanitized_messages:
instruction_text: Final = cls._instruction_text_from_messages(sanitized_messages)
@ -1457,7 +1457,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
return False
@classmethod
def _instruction_text_from_messages(cls, messages: list[dict[str, Any]]) -> str | None:
def _instruction_text_from_messages(cls, messages: list[dict[str, object]]) -> str | None:
for message in messages:
if not isinstance(message, dict):
continue
@ -1468,7 +1468,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
return None
@classmethod
def _non_instruction_messages(cls, messages: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
def _non_instruction_messages(cls, messages: list[dict[str, object]] | None) -> list[dict[str, object]] | None:
if messages is None:
return None
return [
@ -1499,7 +1499,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
self,
response_obj: object,
sanitized_text: str | None,
sanitized_messages: list[dict[str, Any]] | None,
sanitized_messages: list[dict[str, object]] | None,
) -> bool:
"""Rewrite chat response (``ModelResponse`` or ``ResponsesAPIResponse``)."""
if response_obj is None:
@ -1526,7 +1526,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
def _redact_model_response_choices(
choices: list,
sanitized_text: str | None,
sanitized_messages: list[dict[str, Any]] | None,
sanitized_messages: list[dict[str, object]] | None,
) -> bool:
"""Redact every returned choice, including tool-call/reasoning fields."""
if sanitized_messages:
@ -1570,7 +1570,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
def _redact_text_completion_choices(
choices: list,
sanitized_text: str | None,
sanitized_messages: list[dict[str, Any]] | None,
sanitized_messages: list[dict[str, object]] | None,
) -> bool:
"""Rewrite ``/v1/completions`` text choices after Cisco redaction."""
replacement = sanitized_text
@ -1638,7 +1638,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
self,
output_items: list,
sanitized_text: str | None,
sanitized_messages: list[dict[str, Any]] | None,
sanitized_messages: list[dict[str, object]] | None,
) -> bool:
replacement_text: str | None = sanitized_text
if not replacement_text and sanitized_messages:
@ -1672,14 +1672,14 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
@staticmethod
def _sanitized_messages_to_responses_input(
sanitized_messages: list[dict[str, Any]],
) -> list[dict[str, Any]] | None:
sanitized_messages: list[dict[str, object]],
) -> list[dict[str, object]] | None:
"""Convert chat-shape sanitized_messages to Responses API ``input``.
Returns ``None`` if nothing usable could be converted, so the
caller falls back to ``on_flagged_action``.
"""
out: Final[list[dict[str, Any]]] = []
out: Final[list[dict[str, object]]] = []
for m in sanitized_messages:
if not isinstance(m, dict):
continue
@ -1764,7 +1764,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
start_time: datetime | None = None,
surface: str = "chat",
direction: str = "input",
) -> dict[str, Any]:
) -> dict[str, object]:
verbose_proxy_logger.error(
"Cisco AI Defense guardrail (%s): API communication failed: %s",
surface,
@ -2060,7 +2060,7 @@ class CiscoAIDefenseGuardrail(_CiscoAIDefenseMcpMixin, CustomGuardrail):
return getattr(obj, key, None)
@classmethod
def _field_list(cls, obj: object, key: str) -> list[Any]:
def _field_list(cls, obj: object, key: str) -> list[object]:
value: Final = cls._field(obj, key)
return value if isinstance(value, list) else []

View file

@ -148,6 +148,27 @@ def _restore_protected_messages(
]
def _build_compress_failure_detail(status_code: int, body: str) -> dict[str, object]:
"""Build error details for failed /v1/compress responses.
Adds troubleshooting hints for known deployment-related errors while
preserving the upstream status code and response body.
"""
if status_code == 404:
return {
"status_code": status_code,
"body": body,
"hint": (
"The Headroom compression endpoint returned HTTP 404. "
"Verify that the configured Headroom endpoint is correct and that "
"the compression endpoint is available. If you are using a "
"self-hosted deployment, some deployments require enabling remote "
"compression (for example, HEADROOM_COMPRESS_ALLOW_REMOTE=1)."
),
}
return {"status_code": status_code, "body": body}
def extract_hashes_from_messages(messages: list[dict[str, object]]) -> list[str]:
hashes: Final[list[str]] = []
for msg in messages:
@ -417,7 +438,7 @@ class HeadroomGuardrail(CustomGuardrail):
self._handle_compress_failure(
messages,
"Headroom compression service returned an error",
{"status_code": e.response.status_code, "body": e.response.text},
_build_compress_failure_detail(e.response.status_code, e.response.text),
),
False,
{},
@ -449,7 +470,7 @@ class HeadroomGuardrail(CustomGuardrail):
self._handle_compress_failure(
messages,
"Headroom compression service returned an error",
{"status_code": response.status_code, "body": response.text},
_build_compress_failure_detail(response.status_code, response.text),
),
False,
{},

View file

@ -8,8 +8,8 @@ Unified Guardrail, leveraging LiteLLM's /applyGuardrail endpoint
import copy
import json
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Any, Final
from collections.abc import AsyncGenerator, AsyncIterable, Awaitable, Callable, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, Protocol
from fastapi import HTTPException
@ -34,6 +34,9 @@ if TYPE_CHECKING:
# Imported lazily at runtime (inside the streaming hook) to avoid a
# module-level cyclic import with litellm.integrations.custom_guardrail.
from litellm.integrations.custom_guardrail import ModifyResponseException
from litellm.llms.base_llm.guardrail_translation.base_translation import (
BaseTranslation,
)
# Call types that use NDJSON streaming (A2A); guardrail HTTPException is emitted as in-stream error
A2A_CALL_TYPES: Final = (CallTypes.asend_message, CallTypes.send_message)
@ -41,12 +44,35 @@ A2A_CALL_TYPES: Final = (CallTypes.asend_message, CallTypes.send_message)
GUARDRAIL_NAME: Final = "unified_llm_guardrails"
class _EndpointTranslation(Protocol):
@property
def process_input_messages(self) -> "Callable[..., Awaitable[dict[str, object]]]": ...
@property
def process_output_response(self) -> "Callable[..., Awaitable[object]]": ...
@property
def process_output_streaming_response(self) -> "Callable[..., Awaitable[object]]": ...
@property
def build_block_sse_chunks(self) -> "Callable[..., Sequence[bytes] | None]": ...
def _as_endpoint_translation(translation: _EndpointTranslation) -> _EndpointTranslation:
return translation
def _chunk_choices(item: object) -> Sequence[object]:
choices: Final[Sequence[object]] = getattr(item, "choices", None) or []
return choices
class _StreamTerminated(Exception):
"""Internal signal that the incremental transform stream has already emitted
its terminal chunks (block message or in-stream error) and must stop."""
def _get_a2a_request_id(responses_so_far: list[Any], request_data: dict) -> str | None:
def _get_a2a_request_id(responses_so_far: Sequence[object], request_data: dict) -> str | None:
"""Get JSON-RPC request id from first A2A chunk or request body for in-stream error reporting."""
for item in responses_so_far:
if isinstance(item, dict) and "id" in item:
@ -138,7 +164,9 @@ class UnifiedLLMGuardrails(CustomLogger):
except ValueError:
return data # handle unmapped call types
endpoint_translation: Final = endpoint_guardrail_translation_mappings[CallTypes(call_type)]()
endpoint_translation: Final = _as_endpoint_translation(
endpoint_guardrail_translation_mappings[CallTypes(call_type)]()
)
_ensure_litellm_metadata(data, user_api_key_dict)
@ -156,7 +184,7 @@ class UnifiedLLMGuardrails(CustomLogger):
async def async_moderation_hook(
self, data: dict, user_api_key_dict: UserAPIKeyAuth, call_type: CallTypesLiteral
) -> Any:
) -> object:
"""
Runs in parallel to LLM API call
Runs on only Input
@ -187,7 +215,9 @@ class UnifiedLLMGuardrails(CustomLogger):
if call_type is not None and CallTypes(call_type) not in endpoint_guardrail_translation_mappings:
return data
endpoint_translation: Final = endpoint_guardrail_translation_mappings[CallTypes(call_type)]()
endpoint_translation: Final = _as_endpoint_translation(
endpoint_guardrail_translation_mappings[CallTypes(call_type)]()
)
_ensure_litellm_metadata(data, user_api_key_dict)
@ -202,7 +232,7 @@ class UnifiedLLMGuardrails(CustomLogger):
data: dict,
user_api_key_dict: UserAPIKeyAuth,
response,
) -> Any:
) -> object:
"""
Runs on response from LLM API call
@ -271,7 +301,9 @@ class UnifiedLLMGuardrails(CustomLogger):
)
return response
endpoint_translation: Final = endpoint_guardrail_translation_mappings[CallTypes(call_type)]()
endpoint_translation: Final = _as_endpoint_translation(
endpoint_guardrail_translation_mappings[CallTypes(call_type)]()
)
try:
response = await endpoint_translation.process_output_response(
@ -299,10 +331,10 @@ class UnifiedLLMGuardrails(CustomLogger):
async def _handle_streaming_block(
self,
exc: "ModifyResponseException",
endpoint_translation: Any,
endpoint_translation: _EndpointTranslation,
stream_started: bool,
responses_so_far: list[Any],
) -> AsyncGenerator[Any, None]:
responses_so_far: Sequence[object],
) -> AsyncGenerator[object, None]:
"""
Terminate a streamed response cleanly when a guardrail blocks it.
@ -323,7 +355,7 @@ class UnifiedLLMGuardrails(CustomLogger):
@staticmethod
def _resolve_transform_call_type(
user_api_key_dict: UserAPIKeyAuth,
mappings: dict,
mappings: Mapping[CallTypes, type["BaseTranslation"]],
) -> str | None:
"""Resolve the call type for the incremental_diff path, or None if the
route is unresolvable / unsupported.
@ -356,9 +388,9 @@ class UnifiedLLMGuardrails(CustomLogger):
self,
exc: HTTPException,
call_type: str | None,
responses_so_far: list[Any],
responses_so_far: Sequence[object],
request_data: dict,
) -> AsyncGenerator[Any, None]:
) -> AsyncGenerator[object, None]:
"""Surface a mid-stream HTTPException. For A2A (NDJSON) call types the
response has already started, so emit an in-stream JSON-RPC error chunk;
otherwise re-raise so the proxy can report it.
@ -387,7 +419,7 @@ class UnifiedLLMGuardrails(CustomLogger):
def _build_transform_chunk(
self,
*,
reference_chunk: Any,
reference_chunk: object,
mutated_text_per_choice: dict[int, str],
emitted_text_per_choice: dict[int, str],
holdback_per_choice: dict[int, int],
@ -500,18 +532,18 @@ class UnifiedLLMGuardrails(CustomLogger):
async def _emit_transform_round(
self,
*,
endpoint_translation: Any,
endpoint_translation: _EndpointTranslation,
guardrail_to_apply: CustomGuardrail,
request_data: dict,
user_api_key_dict: UserAPIKeyAuth,
call_type: str,
reference_chunk: Any,
responses_so_far: list[Any],
responses_yielded: list[Any],
reference_chunk: object,
responses_so_far: Sequence[object],
responses_yielded: list[object],
emitted_text_per_choice: dict[int, str],
finish_reason_per_choice: dict[int, str | None],
is_final: bool,
) -> AsyncGenerator[Any, None]:
) -> AsyncGenerator[object, None]:
"""Run one guardrail processing round and emit the resulting diff chunk.
Raises ``_StreamTerminated`` (after emitting the terminal block message or
@ -564,14 +596,14 @@ class UnifiedLLMGuardrails(CustomLogger):
self,
*,
guardrail_to_apply: CustomGuardrail,
response: Any,
response: AsyncIterable[object],
request_data: dict,
user_api_key_dict: UserAPIKeyAuth,
call_type: str,
sampling_rate: int,
end_of_stream_only: bool,
mappings: dict,
) -> AsyncGenerator[Any, None]:
mappings: Mapping[CallTypes, type["BaseTranslation"]],
) -> AsyncGenerator[object, None]:
"""Emit guardrail text transformations as new deltas on the stream.
Raw chunks are withheld and accumulated; on each sampled processing round
@ -580,15 +612,15 @@ class UnifiedLLMGuardrails(CustomLogger):
synthetic chunk. A BLOCK terminates the stream via the shared block
handler; an underflow surfaces as an HTTPException.
"""
endpoint_translation: Final = mappings[CallTypes(call_type)]()
responses_so_far: Final[list[Any]] = []
responses_yielded: Final[list[Any]] = []
endpoint_translation: Final = _as_endpoint_translation(mappings[CallTypes(call_type)]())
responses_so_far: Final[list[object]] = []
responses_yielded: Final[list[object]] = []
emitted_text_per_choice: Final[dict[int, str]] = {}
finish_reason_per_choice: Final[dict[int, str | None]] = {}
chunk_counter = 0
last_chunk: Any | None = None
last_chunk: object | None = None
def _round(reference_chunk: Any, is_final: bool) -> AsyncGenerator[Any, None]:
def _round(reference_chunk: object, is_final: bool) -> AsyncGenerator[object, None]:
return self._emit_transform_round(
endpoint_translation=endpoint_translation,
guardrail_to_apply=guardrail_to_apply,
@ -694,13 +726,13 @@ class UnifiedLLMGuardrails(CustomLogger):
async def _inspect_full_response_for_block(
self,
*,
endpoint_translation: Any,
endpoint_translation: _EndpointTranslation,
guardrail_to_apply: CustomGuardrail,
request_data: dict,
user_api_key_dict: UserAPIKeyAuth,
responses_so_far: list[Any],
responses_yielded: list[Any],
) -> AsyncGenerator[Any, None]:
responses_so_far: Sequence[object],
responses_yielded: Sequence[object],
) -> AsyncGenerator[object, None]:
"""Run the block-only guardrail inspection over the full assembled
response (text + tool calls) so nothing bypasses the block decision.
@ -734,17 +766,17 @@ class UnifiedLLMGuardrails(CustomLogger):
raise _StreamTerminated()
@staticmethod
def _chunk_has_tool_calls(item: Any) -> bool:
for choice in getattr(item, "choices", None) or []:
def _chunk_has_tool_calls(item: object) -> bool:
for choice in _chunk_choices(item):
delta = getattr(choice, "delta", None)
if getattr(delta, "tool_calls", None):
return True
return False
@staticmethod
def _chunk_carries_text(item: Any) -> bool:
def _chunk_carries_text(item: object) -> bool:
"""True if any choice in this chunk has non-empty string ``delta.content``."""
for choice in getattr(item, "choices", None) or []:
for choice in _chunk_choices(item):
delta = getattr(choice, "delta", None)
content = getattr(delta, "content", None)
if isinstance(content, str) and content != "":
@ -753,7 +785,7 @@ class UnifiedLLMGuardrails(CustomLogger):
@staticmethod
def _tool_call_passthrough_chunk(
item: Any,
item: object,
finish_reason_per_choice: "dict[int, str | None] | None" = None,
) -> ModelResponseStream:
"""Copy of a chunk carrying tool calls with all text content stripped.
@ -772,7 +804,7 @@ class UnifiedLLMGuardrails(CustomLogger):
redaction purpose.
"""
synthetic_choices: Final[list[StreamingChoices]] = []
for choice in getattr(item, "choices", None) or []:
for choice in _chunk_choices(item):
delta = getattr(choice, "delta", None)
idx = getattr(choice, "index", 0) or 0
original_finish = getattr(choice, "finish_reason", None)
@ -801,15 +833,15 @@ class UnifiedLLMGuardrails(CustomLogger):
)
@staticmethod
def _record_finish_reasons(item: Any, finish_reason_per_choice: dict[int, str | None]) -> None:
for choice in getattr(item, "choices", None) or []:
def _record_finish_reasons(item: object, finish_reason_per_choice: dict[int, str | None]) -> None:
for choice in _chunk_choices(item):
finish_reason = getattr(choice, "finish_reason", None)
if finish_reason is not None:
finish_reason_per_choice[getattr(choice, "index", 0) or 0] = finish_reason
@staticmethod
def _chunk_has_finish_reason(item: Any) -> bool:
choices: Final = getattr(item, "choices", None) or []
def _chunk_has_finish_reason(item: object) -> bool:
choices: Final = _chunk_choices(item)
return any(getattr(choice, "finish_reason", None) is not None for choice in choices)
async def async_post_call_streaming_iterator_hook(
@ -845,22 +877,22 @@ class UnifiedLLMGuardrails(CustomLogger):
# Get streaming configuration. Resolution order (later wins): default
# < guardrail attribute < guardrail_config dict < this callback's
# optional_params.
def _streaming_flag(name: str, default: Any) -> Any:
def _streaming_flag(name: str, default: object) -> Any:
value = default
if guardrail_to_apply is not None:
value = getattr(guardrail_to_apply, name, value)
config: Final = getattr(guardrail_to_apply, "guardrail_config", {})
config: Final[Mapping[str, object]] = getattr(guardrail_to_apply, "guardrail_config", {})
if isinstance(config, dict):
value = config.get(name, value)
return self.optional_params.get(name, value)
sampling_rate: Final = _streaming_flag("streaming_sampling_rate", 5)
sampling_rate: Final[int] = _streaming_flag("streaming_sampling_rate", 5)
# Only apply the guardrail at end of stream (not per chunk).
end_of_stream_only = _streaming_flag("streaming_end_of_stream_only", False)
end_of_stream_only: bool = _streaming_flag("streaming_end_of_stream_only", False)
# "block_only" (default) drops guardrail text rewrites on the streaming
# path; "incremental_diff" emits them as synthetic deltas (see
# _run_incremental_transform_stream).
streaming_transform_mode: Final = _streaming_flag("streaming_transform_mode", "block_only")
streaming_transform_mode: Final[str] = _streaming_flag("streaming_transform_mode", "block_only")
# Withhold every chunk until end-of-stream moderation passes, then
# release the original chunks (clean) or only the block message
# (blocked) -- moderating the whole response *before* any content
@ -868,7 +900,9 @@ class UnifiedLLMGuardrails(CustomLogger):
# release the original chunks are replayed as-is, so a
# content-rewriting guardrail (e.g. PII masking) would leak
# unredacted content. Guarded below via mask_response_content.
buffer_until_moderated = _streaming_flag("streaming_buffer_until_moderated", buffer_until_moderated_default)
buffer_until_moderated: bool = _streaming_flag(
"streaming_buffer_until_moderated", buffer_until_moderated_default
)
if (
buffer_until_moderated
@ -939,9 +973,9 @@ class UnifiedLLMGuardrails(CustomLogger):
# Infer call type from first chunk
call_type = None
chunk_counter = 0
responses_so_far: Final[list[Any]] = []
responses_yielded: Final[list[Any]] = []
pending_end_of_stream_items: Final[list[Any]] = []
responses_so_far: Final[list[object]] = []
responses_yielded: Final[list[object]] = []
pending_end_of_stream_items: Final[list[object]] = []
# Whether any real response chunk has been forwarded to the client.
# Drives how a block terminates the stream: continue the in-progress
# message (True) vs emit a standalone block message (False, buffered).

View file

@ -20,6 +20,7 @@ def initialize_bedrock(litellm_params: LitellmParams, guardrail: Guardrail):
content_filter_threshold=litellm_params.content_filter_threshold,
prompt_attack_threshold=litellm_params.prompt_attack_threshold,
pii_confidence_threshold=litellm_params.pii_confidence_threshold,
chunk_budget_chars=litellm_params.chunk_budget_chars,
default_on=litellm_params.default_on,
disable_exception_on_block=litellm_params.disable_exception_on_block,
mask_request_content=litellm_params.mask_request_content,

View file

@ -26,7 +26,8 @@ Usage:
import base64
import json
from typing import Any, Final
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final
from litellm._logging import verbose_proxy_logger
from litellm.caching.caching import DualCache
@ -36,7 +37,10 @@ from litellm.llms.litellm_proxy.skills.prompt_injection import (
SkillPromptInjectionHandler,
)
from litellm.proxy._types import LiteLLM_SkillsTable, UserAPIKeyAuth
from litellm.types.utils import CallTypes, CallTypesLiteral
from litellm.types.utils import CallTypes, CallTypesLiteral, LLMResponseTypes
if TYPE_CHECKING:
from litellm.llms.litellm_proxy.skills.sandbox_executor import SkillsSandboxExecutor
class SkillsInjectionHook(CustomLogger):
@ -99,7 +103,7 @@ class SkillsInjectionHook(CustomLogger):
verbose_proxy_logger.debug("SkillsInjectionHook: Processing %s skills", len(skills))
litellm_skills: Final[list[LiteLLM_SkillsTable]] = []
anthropic_skills: Final[list[dict[str, Any]]] = []
anthropic_skills: Final[list[dict[str, object]]] = []
# Separate skills by prefix
for skill in skills:
@ -324,9 +328,9 @@ class SkillsInjectionHook(CustomLogger):
async def async_post_call_success_deployment_hook(
self,
request_data: dict,
response: Any,
response: LLMResponseTypes,
call_type: CallTypes | None,
) -> Any | None:
) -> LLMResponseTypes | None:
"""
Post-call hook to handle automatic code execution.
@ -372,7 +376,7 @@ class SkillsInjectionHook(CustomLogger):
# Check if any tool call needs execution (litellm_code_execution or skill tool)
has_executable_tool = False
for tc in tool_calls:
tool_name = tc.get("name", "")
tool_name: str = tc.get("name", "")
# Execute if it's litellm_code_execution OR a skill tool (litellm_skill_xxx)
if tool_name == LiteLLMInternalTools.CODE_EXECUTION.value or tool_name.startswith(LITELLM_SKILL_ID_PREFIX):
has_executable_tool = True
@ -441,7 +445,7 @@ class SkillsInjectionHook(CustomLogger):
data: dict,
response: Any,
skill_files: dict[str, bytes],
) -> Any:
) -> LLMResponseTypes | None:
"""
Execute the code execution loop for messages API (Anthropic format).
@ -466,7 +470,7 @@ class SkillsInjectionHook(CustomLogger):
max_tokens: Final = data.get("max_tokens", 4096)
executor: Final = SkillsSandboxExecutor(timeout=self.sandbox_timeout)
generated_files: Final[list[dict[str, Any]]] = []
generated_files: Final[list[dict[str, object]]] = []
current_response = response
for iteration in range(self.max_iterations):
@ -511,9 +515,9 @@ class SkillsInjectionHook(CustomLogger):
# Process tool calls
tool_results = []
for tc in tool_calls:
tool_name = tc.get("name", "")
tool_name: str = tc.get("name", "")
tool_id = tc.get("id", "")
tool_input = tc.get("input", {})
tool_input: Mapping[str, str] = tc.get("input", {})
# Execute if it's litellm_code_execution OR a skill tool
if tool_name == LiteLLMInternalTools.CODE_EXECUTION.value:
@ -561,8 +565,8 @@ class SkillsInjectionHook(CustomLogger):
self,
code: str,
skill_files: dict[str, bytes],
executor: Any,
generated_files: list[dict[str, Any]],
executor: "SkillsSandboxExecutor",
generated_files: list[dict[str, object]],
) -> str:
"""Execute code in sandbox and return result string."""
try:
@ -574,7 +578,8 @@ class SkillsInjectionHook(CustomLogger):
# Collect generated files
if exec_result.get("files"):
for f in exec_result["files"]:
files: Final[Sequence[Mapping[str, str]]] = exec_result["files"]
for f in files:
generated_files.append(
{
"name": f["name"],
@ -595,10 +600,10 @@ class SkillsInjectionHook(CustomLogger):
async def _execute_skill_tool(
self,
tool_name: str,
tool_input: dict[str, Any],
tool_input: Mapping[str, str],
skill_files: dict[str, bytes],
executor: Any,
generated_files: list[dict[str, Any]],
executor: "SkillsSandboxExecutor",
generated_files: list[dict[str, object]],
) -> str:
"""Execute a skill tool by generating and running code based on skill content."""
# Generate code based on available skill modules
@ -670,7 +675,7 @@ print('No executable skill module found')
data: dict,
response: Any,
skill_files: dict[str, bytes],
) -> Any:
) -> LLMResponseTypes:
"""
Execute the code execution loop until model gives final response.
@ -704,7 +709,7 @@ print('No executable skill module found')
kwargs: Final = {k: v for k, v in data.items() if k not in _EXCLUDED_ACOMPLETION_KEYS}
executor: Final = SkillsSandboxExecutor(timeout=self.sandbox_timeout)
generated_files: Final[list[dict[str, Any]]] = []
generated_files: Final[list[dict[str, object]]] = []
current_response: Any = response
for iteration in range(self.max_iterations):
@ -713,7 +718,7 @@ print('No executable skill module found')
stop_reason = current_response.choices[0].finish_reason
# Build assistant message for conversation history
assistant_msg_dict: dict[str, Any] = {
assistant_msg_dict: dict[str, object] = {
"role": "assistant",
"content": assistant_message.content,
}
@ -781,13 +786,13 @@ print('No executable skill module found')
self,
tool_call: Any,
skill_files: dict[str, bytes],
executor: Any,
generated_files: list[dict[str, Any]],
executor: "SkillsSandboxExecutor",
generated_files: list[dict[str, object]],
) -> str:
"""Execute a litellm_code_execution tool call and return result string."""
try:
args: Final = json.loads(tool_call.function.arguments)
code: Final = args.get("code", "")
code: Final[str] = args.get("code", "")
verbose_proxy_logger.debug("SkillsInjectionHook: Executing code (%s chars)", len(code))
@ -802,7 +807,8 @@ print('No executable skill module found')
# Collect generated files
if exec_result.get("files"):
tool_result += "\n\nGenerated files:"
for f in exec_result["files"]:
files: Final[Sequence[Mapping[str, str]]] = exec_result["files"]
for f in files:
file_content = base64.b64decode(f["content_base64"])
generated_files.append(
{
@ -830,8 +836,8 @@ print('No executable skill module found')
def _attach_files_to_response(
self,
response: Any,
generated_files: list[dict[str, Any]],
) -> Any:
generated_files: list[dict[str, object]],
) -> LLMResponseTypes:
"""
Attach generated files to the response object.
@ -841,11 +847,13 @@ print('No executable skill module found')
if not generated_files:
return response
raw_response: Final = response
# Handle dict response (Anthropic/messages API format)
if isinstance(response, dict):
response["_litellm_generated_files"] = generated_files
verbose_proxy_logger.debug("SkillsInjectionHook: Attached %s files to dict response", len(generated_files))
return response
return raw_response
# Handle object response (OpenAI format)
try:

View file

@ -32,9 +32,12 @@ class _PROXY_MaxBudgetLimiter(CustomLogger):
if max_budget is None or user_id is None:
return
# Personal budget applies only to non-team requests, matching
# the explicit team-key exemption in common_checks section 4.1.
if user_api_key_dict.team_id is not None:
from litellm.proxy.proxy_server import general_settings
if (
user_api_key_dict.team_id is not None
and general_settings.get("apply_user_budget_to_team_keys") is not True
):
return
# The reservation path admits at the strict-`<` boundary and

View file

@ -18,6 +18,7 @@ from litellm.constants import (
INTERNAL_CALL_ORIGIN_METADATA_KEY,
LITELLM_PROXY_MASTER_KEY_ALIAS,
PRE_CALL_EXECUTED_GUARDRAILS_KEY,
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
)
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
@ -226,6 +227,7 @@ _UNTRUSTED_METADATA_CONTROL_FIELDS: Final = (
"applied_policies",
"policy_sources",
"routing_decision",
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
INTERNAL_CALL_ORIGIN_METADATA_KEY,
"standard_logging_object",
"proxy_server_request",

View file

@ -4,8 +4,9 @@ AUTO ROUTER MANAGEMENT ENDPOINTS
POST /auto_router/test_routing - Route one prompt through an unsaved complexity-router config
"""
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, timezone
from types import MappingProxyType
from typing import TYPE_CHECKING, Annotated, Final
from pydantic import BaseModel, TypeAdapter
@ -25,6 +26,7 @@ from litellm.proxy.auth.auth_checks import (
can_key_call_resolved_model,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.db.autorouter_session_rollup import AUTOROUTER_BENCHMARKS_SQL
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.repositories.team_repository import TeamRepository
from litellm.router_strategy.complexity_router import ComplexityRouter
@ -260,6 +262,7 @@ async def preview_auto_router_routing(
class _SessionAggRow(BaseModel):
router_name: str
router_type: str
tier_turns: Mapping[str, int]
sessions: int
turns: int
unordered_turns: int
@ -283,35 +286,6 @@ class _SessionAggRow(BaseModel):
_SESSION_AGG_ROWS: Final = TypeAdapter(list[_SessionAggRow])
_BENCHMARKS_SQL: Final = """
SELECT
router_name,
router_type,
COUNT(*)::int AS sessions,
COALESCE(SUM(turns), 0)::int AS turns,
COALESCE(SUM(unordered_turns), 0)::int AS unordered_turns,
COALESCE(SUM(covered_turns), 0)::int AS covered_turns,
COALESCE(SUM(cache_hits), 0)::int AS cache_hits,
COALESCE(SUM(same_model_turns), 0)::int AS same_model_turns,
COALESCE(SUM(same_model_hits), 0)::int AS same_model_hits,
COALESCE(SUM(first_visit_turns), 0)::int AS first_visit_turns,
COALESCE(SUM(first_visit_hits), 0)::int AS first_visit_hits,
COALESCE(SUM(return_turns), 0)::int AS return_turns,
COALESCE(SUM(return_hits), 0)::int AS return_hits,
COALESCE(SUM(return_expired_misses), 0)::int AS return_expired_misses,
COALESCE(SUM(return_within_ttl_misses), 0)::int AS return_within_ttl_misses,
COALESCE(SUM(ttl_5m_turns), 0)::int AS ttl_5m_turns,
COALESCE(SUM(ttl_1h_turns), 0)::int AS ttl_1h_turns,
COALESCE(SUM(total_tokens), 0)::bigint AS total_tokens,
COALESCE(SUM(spend), 0)::float8 AS spend,
COALESCE(SUM(saved_spend), 0)::float8 AS saved_spend,
COALESCE(SUM(EXTRACT(EPOCH FROM (last_turn_at - first_turn_at))), 0)::float8 AS session_seconds
FROM "LiteLLM_AutoRouterSession"
WHERE last_turn_at >= $1::timestamp AND first_turn_at < $2::timestamp
GROUP BY router_name, router_type
ORDER BY SUM(spend) DESC
"""
def _parse_benchmark_day(value: str) -> datetime:
try:
@ -366,6 +340,7 @@ def _summed_agg_row(rows: Sequence[_SessionAggRow]) -> _SessionAggRow:
return _SessionAggRow(
router_name="",
router_type="",
tier_turns=MappingProxyType({}),
sessions=sum(row.sessions for row in rows),
turns=sum(row.turns for row in rows),
unordered_turns=sum(row.unordered_turns for row in rows),
@ -434,7 +409,7 @@ async def get_auto_router_benchmarks(
raise HTTPException(status_code=400, detail="end_date must not be earlier than start_date")
raw_rows: Final = await prisma_client.db.query_raw(
_BENCHMARKS_SQL,
AUTOROUTER_BENCHMARKS_SQL,
start_day.isoformat(),
(end_day + timedelta(days=1)).isoformat(),
)
@ -443,6 +418,7 @@ async def get_auto_router_benchmarks(
AutoRouterBenchmarkGroup(
router_name=row.router_name,
router_type=row.router_type,
tier_turns=row.tier_turns,
**_benchmark_totals(row).model_dump(),
)
for row in rows

View file

@ -17,7 +17,7 @@ import json
import traceback
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from typing import Any, Final, cast
from typing import Any, Final, Literal, cast
import fastapi
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status

View file

@ -18,7 +18,7 @@ import os
import re
import secrets
import traceback
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Awaitable, Callable, Mapping, Sequence
from datetime import datetime, timedelta, timezone
from typing import Any, Final, Literal, Optional, Protocol, TypeVar, cast
@ -171,8 +171,12 @@ class _PrismaTableActions(Protocol[_PrismaRowT]):
async def count(self, *, where: Mapping[str, object] | None = None) -> int: ...
async def create(self, *, data: Mapping[str, object]) -> _PrismaRowT: ...
async def create_many(self, *, data: Sequence[Mapping[str, object]]) -> int: ...
async def delete_many(self, *, where: Mapping[str, object] | None = None) -> int: ...
async def update(
self,
*,
@ -181,6 +185,10 @@ class _PrismaTableActions(Protocol[_PrismaRowT]):
) -> _PrismaRowT | None: ...
class _TxTables(Protocol):
litellm_proxymodeltable: _PrismaTableActions[object]
def _prisma_table(
repository: BaseRepository[_RepositoryModelT],
) -> _PrismaTableActions[_RepositoryModelT]:
@ -1650,9 +1658,12 @@ async def generate_key_fn(
detail={"error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}"},
)
if user_custom_key_generate is not None:
if inspect.iscoroutinefunction(user_custom_key_generate):
result: Final = await user_custom_key_generate(data)
custom_key_generate_hook: Final[Callable[..., Awaitable[Mapping[str, object]]] | None] = (
user_custom_key_generate
)
if custom_key_generate_hook is not None:
if inspect.iscoroutinefunction(custom_key_generate_hook):
result: Final = await custom_key_generate_hook(data)
else:
raise ValueError("user_custom_key_generate must be a coroutine")
decision: Final = result.get("decision", True)
@ -1847,9 +1858,10 @@ async def generate_service_account_key_fn(
verbose_proxy_logger.debug("entered /key/generate")
if user_custom_key_generate is not None:
if inspect.iscoroutinefunction(user_custom_key_generate):
result: Final = await user_custom_key_generate(data)
custom_key_generate_hook: Final[Callable[..., Awaitable[Mapping[str, object]]] | None] = user_custom_key_generate
if custom_key_generate_hook is not None:
if inspect.iscoroutinefunction(custom_key_generate_hook):
result: Final = await custom_key_generate_hook(data)
else:
raise ValueError("user_custom_key_generate must be a coroutine")
decision: Final = result.get("decision", True)
@ -1918,7 +1930,7 @@ def prepare_metadata_fields(data: BaseModel, non_default_values: dict, existing_
)
casted_metadata[reserved_field] = existing_value
data_json: Final = data.model_dump(exclude_unset=True, exclude_none=True)
data_json: Final[Mapping[str, object]] = data.model_dump(exclude_unset=True, exclude_none=True)
try:
for k, v in data_json.items():
@ -2179,7 +2191,7 @@ async def _process_single_key_update(
llm_router: Router | None,
user_custom_key_update: Callable | None = None,
existing_key_row: LiteLLM_VerificationToken | None = None,
) -> dict[str, Any]:
) -> dict[str, object]:
"""
Process a single key update with all validations and checks.
@ -2722,9 +2734,10 @@ async def update_key_fn(
)
# Custom key update hook
if user_custom_key_update is not None:
if inspect.iscoroutinefunction(user_custom_key_update):
result: Final = await user_custom_key_update(data)
custom_key_update_hook: Final[Callable[..., Awaitable[Mapping[str, object]]] | None] = user_custom_key_update
if custom_key_update_hook is not None:
if inspect.iscoroutinefunction(custom_key_update_hook):
result: Final = await custom_key_update_hook(data)
else:
raise ValueError("user_custom_key_update must be a coroutine")
decision: Final = result.get("decision", True)
@ -3528,11 +3541,34 @@ async def info_key_fn(
):
"""
Retrieve information about a key.
Parameters:
key: Optional[str] = Query parameter representing the key in the request
user_api_key_dict: UserAPIKeyAuth = Dependency representing the user's API key
- key: str | None (query parameter) - The key to look up. Accepts the plaintext key or its hash.
Defaults to the key in the Authorization header.
Returns:
Dict containing the key and its associated information
- key: str - The key that was looked up, echoed back as it was passed in
- info: dict - The key's row, minus the hashed token
- key_alias: str | None - User-friendly key alias
- spend: float - Amount spent by the key. When budget_duration is set this covers only the
current budget window, not the key's lifetime
- max_budget: float | None - Max budget for the key, enforced against spend
- budget_duration: str | None - Budget reset period ("30d", "1h", etc.)
- budget_reset_at: datetime | None - When the current budget window ends and spend is next
reset to 0, not when it was last reset. Reset times snap to standard boundaries in the
configured timezone (30d and 1mo land on the 1st of the month, 7d on Monday, 1h on the
hour), so subtracting budget_duration from it does not give the window's start
- model_max_budget: dict - Per-model budgets, e.g. {"gpt-4": {"budget_limit": 0.0005, "time_period": "30d"}}
- model_max_budget_usage: dict | None - Current-window spend per model, present only when
the key has per-model budgets
- models: list - Model_name's the key is allowed to call
- tpm_limit / rpm_limit: int | None - Tokens and requests per minute limits
- metadata: dict - Metadata for the key, e.g. {"team": "core-infra"}
- blocked: bool | None - Whether the key is blocked
- expires: datetime | None - When the key stops authenticating requests
- last_active: datetime | None - When the key was last used
- object_permission: dict | None - Resolved vector store / MCP permissions when the key has
an object_permission_id
Example Curl:
```
@ -4066,10 +4102,11 @@ async def delete_verification_tokens(
failed_tokens: list = []
try:
if prisma_client:
tokens = [_hash_token_if_needed(token=key) for key in tokens]
_keys_being_deleted: Final[list[LiteLLM_VerificationToken]] = await VerificationTokenRepository(
prisma_client
).table.find_many(where={"token": {"in": tokens}})
hashed_tokens: Final[list[str]] = [_hash_token_if_needed(token=key) for key in tokens]
tokens = hashed_tokens
_keys_being_deleted: Final[list[LiteLLM_VerificationToken]] = await _prisma_table(
VerificationTokenRepository(prisma_client)
).find_many(where={"token": {"in": hashed_tokens}})
if len(_keys_being_deleted) == 0:
raise HTTPException(
@ -4268,7 +4305,7 @@ async def _rotate_master_key(
if models:
decrypted_models: Final = proxy_config.decrypt_model_list_from_db(new_models=models)
verbose_proxy_logger.debug("ABLE TO DECRYPT MODELS - len(decrypted_models): %s", len(decrypted_models))
new_models: Final = []
new_models: Final[list[dict[str, object]]] = []
for model in decrypted_models:
new_model = await _add_model_to_db(
model_params=Deployment(**model),
@ -4283,7 +4320,8 @@ async def _rotate_master_key(
_dumped["model_info"] = prisma.Json(_dumped["model_info"])
new_models.append(_dumped)
verbose_proxy_logger.debug("Resetting proxy model table")
async with prisma_client.db.tx() as tx:
async with prisma_client.db.tx() as tx_ctx:
tx: Final[_TxTables] = tx_ctx
await tx.litellm_proxymodeltable.delete_many()
verbose_proxy_logger.debug("Creating %s models", len(new_models))
await tx.litellm_proxymodeltable.create_many(
@ -4607,7 +4645,7 @@ async def _execute_virtual_key_regeneration(
_validate_key_alias_format(key_alias=new_key_alias)
verbose_proxy_logger.debug("non_default_values: %s", non_default_values)
update_data.update(non_default_values)
update_data = prisma_client.jsonify_object(data=update_data)
jsonified_update_data: Final[Mapping[str, object]] = prisma_client.jsonify_object(data=update_data)
# If grace period set, insert deprecated key so old key remains valid
await _insert_deprecated_key(
@ -4619,9 +4657,9 @@ async def _execute_virtual_key_regeneration(
updated_token: Final = await VerificationTokenRepository(prisma_client).table.update(
where={"token": hashed_api_key},
data=update_data,
data=jsonified_update_data,
)
updated_token_dict: Final = dict(updated_token) if updated_token is not None else {}
updated_token_dict: Final[dict[str, object]] = dict(updated_token) if updated_token is not None else {}
updated_token_dict["key"] = new_token
updated_token_dict["token_id"] = updated_token_dict.pop("token")
@ -5566,7 +5604,7 @@ async def key_aliases(
where_sql: Final = " AND ".join(where_parts)
count_sql: Final = f'SELECT COUNT(*) AS count FROM "LiteLLM_VerificationToken" WHERE {where_sql}'
count_rows: Final = await prisma_client.db.query_raw(count_sql, *query_params)
count_rows: Final[Sequence[Mapping[str, int]]] = await prisma_client.db.query_raw(count_sql, *query_params)
total_count: Final = int(count_rows[0]["count"]) if count_rows else 0
aliases_params: Final = query_params + [size, (page - 1) * size]
@ -5579,7 +5617,7 @@ async def key_aliases(
f" ORDER BY key_alias ASC"
f" LIMIT ${limit_idx} OFFSET ${offset_idx}"
)
alias_rows: Final = await prisma_client.db.query_raw(aliases_sql, *aliases_params)
alias_rows: Final[Sequence[Mapping[str, str]]] = await prisma_client.db.query_raw(aliases_sql, *aliases_params)
aliases: Final[list[str]] = [row["key_alias"] for row in alias_rows if row.get("key_alias")]
total_pages: Final = -(-total_count // size) if total_count > 0 else 0
@ -5672,7 +5710,7 @@ def _build_key_filter_conditions(
agent_id: str | None = None,
use_substring_matching: bool = False,
expires_filter: str | None = None,
) -> dict[str, str | dict[str, Any] | list[dict[str, Any]]]:
) -> Mapping[str, object]:
"""Build filter conditions for key listing.
Visibility rules:
@ -5684,14 +5722,14 @@ def _build_key_filter_conditions(
so former members cannot see service accounts they created after leaving.
"""
# Prepare filter conditions
where: dict[str, str | dict[str, Any] | list[dict[str, Any]]] = {}
where: dict[str, object] = {}
where.update(_get_condition_to_filter_out_ui_session_tokens())
# Build the OR conditions for user's keys and admin team keys
or_conditions: Final[list[dict[str, Any]]] = []
or_conditions: Final[list[dict[str, object]]] = []
# Base conditions for user's own keys
user_condition: Final[dict[str, Any]] = {}
user_condition: Final[dict[str, object]] = {}
if user_id and isinstance(user_id, str):
if use_substring_matching:
user_condition["user_id"] = {
@ -5761,7 +5799,7 @@ def _build_key_filter_conditions(
# Apply team_id, project_id and access_group_id as global AND filters so they
# narrow results across all visibility conditions (own keys, team keys, etc.)
global_filters: tuple[dict[str, Any], ...] = (
global_filters: Final[tuple[dict[str, object], ...]] = (
*(
(
{"key_alias": {"contains": key_alias, "mode": "insensitive"}}
@ -5782,7 +5820,7 @@ def _build_key_filter_conditions(
else ()
),
)
combined_where = {"AND": [where, *global_filters]} if global_filters else where
combined_where: Final[Mapping[str, object]] = {"AND": [where, *global_filters]} if global_filters else where
verbose_proxy_logger.debug("Filter conditions: %s", combined_where)
return combined_where
@ -5963,7 +6001,7 @@ async def _list_key_helper(
)
def _get_condition_to_filter_out_ui_session_tokens() -> dict[str, Any]:
def _get_condition_to_filter_out_ui_session_tokens() -> Mapping[str, object]:
"""
Condition to filter out UI session tokens
"""
@ -6372,7 +6410,7 @@ async def _can_user_query_key_info(
async def test_key_logging(
user_api_key_dict: UserAPIKeyAuth,
request: Request,
key_logging: list[dict[str, Any]],
key_logging: Sequence[Mapping[str, str]],
) -> LoggingCallbackStatus:
"""
Test the key-based logging

View file

@ -13,9 +13,9 @@ model/{model_id}/update - PATCH endpoint for model update.
import asyncio
import datetime
import json
from collections.abc import Mapping, Sequence
from collections.abc import Awaitable, Mapping, Sequence
from json import JSONDecodeError
from typing import Any, Final, Literal, cast
from typing import Final, Literal, Protocol, cast
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, Field, ValidationError
@ -78,9 +78,7 @@ from litellm.types.proxy.management_endpoints.model_management_endpoints import
from litellm.types.router import (
SPECIAL_MODEL_INFO_PARAMS,
Deployment,
DeploymentTypedDict,
GenericLiteLLMParams,
LiteLLMParamsTypedDict,
updateDeployment,
)
from litellm.utils import get_utc_datetime
@ -104,10 +102,80 @@ class UpdatePublicModelGroupsRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
class _ProxyModelRow(Protocol):
model_id: str
model_name: str
model_info: Mapping[str, object] | None
def model_dump_json(self, *, exclude_none: bool = False) -> str: ...
class _ProxyModelTable(Protocol):
def find_unique(self, *, where: Mapping[str, object]) -> Awaitable[_ProxyModelRow | None]: ...
def find_many(self, *, where: Mapping[str, object]) -> Awaitable[Sequence[_ProxyModelRow]]: ...
def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> Awaitable[_ProxyModelRow]: ...
def delete(self, *, where: Mapping[str, object]) -> Awaitable[_ProxyModelRow | None]: ...
def delete_many(self, *, where: Mapping[str, object]) -> Awaitable[int]: ...
class _TxModelTables(Protocol):
litellm_proxymodeltable: _ProxyModelTable
class _TeamRow(Protocol):
models: Sequence[str]
def model_dump(self) -> Mapping[str, object]: ...
class _TeamTable(Protocol):
def find_unique(self, *, where: Mapping[str, object]) -> Awaitable[_TeamRow | None]: ...
def update(
self, *, where: Mapping[str, object], data: Mapping[str, object], include: Mapping[str, bool]
) -> Awaitable[LiteLLM_TeamTable]: ...
class _TeamIdRef(Protocol):
team_id: str
class _ModelAliasRow(Protocol):
id: int
model_aliases: dict[str, str]
team: _TeamIdRef | None
class _ModelAliasTable(Protocol):
def find_many(self, *, include: Mapping[str, bool]) -> Awaitable[Sequence[_ModelAliasRow]]: ...
def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> Awaitable[object]: ...
def _proxy_model_table(prisma_client: PrismaClient) -> _ProxyModelTable:
return ModelRepository(prisma_client).table
def _repo_team_table(prisma_client: PrismaClient) -> _TeamTable:
return TeamRepository(prisma_client).table
def _db_team_table(prisma_client: PrismaClient) -> _TeamTable:
return prisma_client.db.litellm_teamtable
def _model_alias_table(prisma_client: PrismaClient) -> _ModelAliasTable:
return ModelTableRepository(prisma_client).table
async def get_db_model(model_id: str, prisma_client: PrismaClient) -> Deployment | None:
db_model: Final = cast(
BaseModel | None,
await ModelRepository(prisma_client).table.find_unique(where={"model_id": model_id}),
await _proxy_model_table(prisma_client).find_unique(where={"model_id": model_id}),
)
if not db_model:
@ -166,14 +234,9 @@ def _raise_on_strategy_router_write_violation(
def update_db_model(db_model: Deployment, updated_patch: updateDeployment) -> PrismaCompatibleUpdateDBModel:
merged_deployment_dict: Final = DeploymentTypedDict(
model_name=db_model.model_name,
litellm_params=LiteLLMParamsTypedDict(**db_model.litellm_params.model_dump(exclude_none=True)),
model_info=db_model.model_info.model_dump(exclude_none=True),
)
# update model name
if updated_patch.model_name:
merged_deployment_dict["model_name"] = updated_patch.model_name
merged_model_name: Final = updated_patch.model_name or db_model.model_name
merged_litellm_params: Final = db_model.litellm_params.model_dump(exclude_none=True)
merged_model_info: Final = db_model.model_info.model_dump(exclude_none=True)
# update litellm params
if updated_patch.litellm_params:
@ -182,13 +245,11 @@ def update_db_model(db_model: Deployment, updated_patch: updateDeployment) -> Pr
k: encrypt_value_helper(v) for k, v in updated_patch.litellm_params.model_dump(exclude_none=True).items()
}
merged_deployment_dict["litellm_params"].update(encrypted_params)
merged_litellm_params.update(encrypted_params)
# update model info
if updated_patch.model_info:
if "model_info" not in merged_deployment_dict:
merged_deployment_dict["model_info"] = {}
merged_deployment_dict["model_info"].update(updated_patch.model_info.model_dump(exclude_none=True))
merged_model_info.update(updated_patch.model_info.model_dump(exclude_none=True))
# Honor explicit-null clears LAST, after both merges, so a model_info blob the UI
# passes through (which today re-sends the OLD pricing on every save) cannot
@ -202,29 +263,25 @@ def update_db_model(db_model: Deployment, updated_patch: updateDeployment) -> Pr
if updated_patch.litellm_params:
for field in updated_patch.litellm_params.model_fields_set:
if field in SPECIAL_MODEL_INFO_PARAMS and getattr(updated_patch.litellm_params, field) is None:
merged_deployment_dict["litellm_params"].pop(field, None)
merged_deployment_dict.get("model_info", {}).pop(field, None)
merged_litellm_params.pop(field, None)
merged_model_info.pop(field, None)
if updated_patch.model_info:
for field in updated_patch.model_info.model_fields_set:
if field in SPECIAL_MODEL_INFO_PARAMS and getattr(updated_patch.model_info, field) is None:
merged_deployment_dict["model_info"].pop(field, None)
merged_deployment_dict.get("litellm_params", {}).pop(field, None)
merged_model_info.pop(field, None)
merged_litellm_params.pop(field, None)
# convert to prisma compatible format
prisma_compatible_model_dict: Final = PrismaCompatibleUpdateDBModel()
if "model_name" in merged_deployment_dict:
prisma_compatible_model_dict["model_name"] = merged_deployment_dict["model_name"]
for key, value in merged_model_info.items():
if isinstance(value, datetime.datetime):
merged_model_info[key] = value.isoformat()
if "litellm_params" in merged_deployment_dict:
prisma_compatible_model_dict["litellm_params"] = json.dumps(merged_deployment_dict["litellm_params"])
if "model_info" in merged_deployment_dict:
model_info: Final = merged_deployment_dict["model_info"]
for key, value in model_info.items():
if isinstance(value, datetime.datetime):
model_info[key] = value.isoformat()
prisma_compatible_model_dict["model_info"] = json.dumps(model_info)
prisma_compatible_model_dict: Final = PrismaCompatibleUpdateDBModel(
model_name=merged_model_name,
litellm_params=json.dumps(merged_litellm_params),
model_info=json.dumps(merged_model_info),
)
if updated_patch.blocked is not None:
prisma_compatible_model_dict["blocked"] = updated_patch.blocked
@ -338,7 +395,7 @@ async def patch_model(
update_data["updated_at"] = cast(str, get_utc_datetime())
# Perform partial update
updated_model: Final = await ModelRepository(prisma_client).table.update(
updated_model: Final = await _proxy_model_table(prisma_client).update(
where={"model_id": model_id},
data=update_data,
)
@ -769,8 +826,8 @@ async def _setup_new_team_model_assignment(
async def _get_team_deployments(
team_id: str, prisma_client: PrismaClient, table: Any | None = None
) -> list[LiteLLM_ProxyModelTable]:
team_id: str, prisma_client: PrismaClient, table: _ProxyModelTable | None = None
) -> Sequence[_ProxyModelRow]:
"""
Fetch all deployments for a given team_id from the database.
@ -785,7 +842,7 @@ async def _get_team_deployments(
existing transaction.
"""
prefix: Final = f"model_name_{team_id}_"
table = table or ModelRepository(prisma_client).table
table = table or _proxy_model_table(prisma_client)
response: Final = await table.find_many(
where={
"model_name": {"startswith": prefix},
@ -806,7 +863,7 @@ async def _get_team_deployments(
async def delete_team_models(
team_ids: list[str],
prisma_client: PrismaClient,
llm_router: Any | None,
llm_router: Router | None,
) -> list[str]:
"""
Delete every BYOK model owned by the given teams, from the DB and the router.
@ -820,7 +877,8 @@ async def delete_team_models(
Returns the model_ids that were deleted.
"""
deleted_model_ids: Final[list[str]] = []
async with prisma_client.db.tx() as tx:
async with prisma_client.db.tx() as tx_ctx:
tx: Final[_TxModelTables] = tx_ctx
for team_id in team_ids:
rows = await _get_team_deployments(team_id, prisma_client, table=tx.litellm_proxymodeltable)
model_ids = [row.model_id for row in rows]
@ -920,11 +978,11 @@ async def _remove_unbacked_team_models(
if not names_to_remove:
return
existing_team_row: Final = await prisma_client.db.litellm_teamtable.find_unique(where={"team_id": team_id})
existing_team_row: Final = await _db_team_table(prisma_client).find_unique(where={"team_id": team_id})
if existing_team_row is None:
return
updated_team_row: Final[LiteLLM_TeamTable] = await prisma_client.db.litellm_teamtable.update(
updated_team_row: Final[LiteLLM_TeamTable] = await _db_team_table(prisma_client).update(
where={"team_id": team_id},
data={"models": [model for model in existing_team_row.models if model not in names_to_remove]},
include={"object_permission": True},
@ -953,7 +1011,7 @@ async def _update_existing_team_model_assignment(
"""
def _get_team_public_model_name(
model_info: dict | str | None,
model_info: object,
) -> str | None:
parsed: Final = model_info_as_mapping(model_info)
if parsed is None:
@ -1062,7 +1120,7 @@ class ModelManagementAuthChecks:
detail={"error": CommonProxyErrors.not_premium_user.value},
)
_existing_team_row: Final = await TeamRepository(prisma_client).table.find_unique(
_existing_team_row: Final = await _repo_team_table(prisma_client).find_unique(
where={"team_id": model_params.model_info.team_id}
)
@ -1091,7 +1149,7 @@ class ModelManagementAuthChecks:
) -> Literal[True]:
## Check team model auth
if model_params.model_info is not None and model_params.model_info.team_id is not None:
team_obj_row: Final = await TeamRepository(prisma_client).table.find_unique(
team_obj_row: Final = await _repo_team_table(prisma_client).find_unique(
where={"team_id": model_params.model_info.team_id}
)
if team_obj_row is None:
@ -1192,7 +1250,7 @@ async def delete_model(
- store keys separately
"""
# encrypt litellm params #
result: Final = await ModelRepository(prisma_client).table.delete(where={"model_id": model_info.id})
result: Final = await _proxy_model_table(prisma_client).delete(where={"model_id": model_info.id})
if result is None:
raise HTTPException(
@ -1265,9 +1323,9 @@ async def delete_team_model_alias(
Returns:
- List of team id + model alias pairs that were removed
"""
team_model_aliases: Final = await ModelTableRepository(prisma_client).table.find_many(include={"team": True})
team_model_aliases: Final = await _model_alias_table(prisma_client).find_many(include={"team": True})
tasks: Final = []
removed_model_aliases: Final = []
removed_model_aliases: Final[list[tuple[str, str]]] = []
for team_model_alias in team_model_aliases:
model_aliases = team_model_alias.model_aliases # {"alias": "public model name"}
id = team_model_alias.id
@ -1278,7 +1336,7 @@ async def delete_team_model_alias(
removed_model_aliases.append((team_model_alias.team.team_id, key))
del model_aliases[key]
tasks.append(
ModelTableRepository(prisma_client).table.update(
_model_alias_table(prisma_client).update(
where={"id": id},
data={"model_aliases": json.dumps(model_aliases)},
)
@ -1492,7 +1550,7 @@ async def update_model(
},
)
_model_id = None
_model_id: str | None = None
_model_info: Final = getattr(model_params, "model_info", None)
if _model_info is None:
raise Exception("model_info not provided")
@ -1551,11 +1609,11 @@ async def update_model(
else:
pass
_data: Final[dict] = {
_data: Final[dict[str, str]] = {
"litellm_params": json.dumps(merged_dictionary),
"updated_by": user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME,
}
model_response: Final = await ModelRepository(prisma_client).table.update(
model_response: Final = await _proxy_model_table(prisma_client).update(
where={"model_id": _model_id},
data=_data,
)

View file

@ -15,11 +15,11 @@ import math
import traceback
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from typing import Annotated, Final, Protocol, TypeVar, cast
from typing import Annotated, Final, Protocol, TypedDict, TypeVar, cast
import fastapi
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from pydantic import BaseModel
from pydantic import BaseModel, JsonValue
import litellm
from litellm._logging import verbose_proxy_logger
@ -29,6 +29,7 @@ from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.proxy._types import (
UI_TEAM_ID,
BlockTeamRequest,
BudgetNewRequest,
CommonProxyErrors,
DeleteTeamRequest,
LiteLLM_AccessGroupTable,
@ -57,6 +58,7 @@ from litellm.proxy._types import (
SpecialManagementEndpointEnums,
SpecialModelNames,
SpecialProxyStrings,
TeamAccessGroupModelGrant,
TeamAddMemberResponse,
TeamInfoResponseObject,
TeamInfoResponseObjectTeamTable,
@ -155,6 +157,15 @@ router: Final = APIRouter()
_DbRecordT = TypeVar("_DbRecordT")
class _TeamIdKeyCount(TypedDict):
team_id: int
class _TeamIdGroupRow(TypedDict):
team_id: str
_count: _TeamIdKeyCount
class _PrismaTableActions(Protocol[_DbRecordT]):
async def find_unique(
self,
@ -219,59 +230,127 @@ class _PrismaTableActions(Protocol[_DbRecordT]):
where: Mapping[str, object] | None = None,
) -> int: ...
async def group_by(
self,
by: Sequence[str],
where: Mapping[str, object] | None = None,
count: Mapping[str, bool] | None = None,
) -> Sequence[_TeamIdGroupRow]: ...
class _HasTableActions(Protocol[_DbRecordT]):
@property
def table(self) -> "_PrismaTableActions[_DbRecordT]": ...
def _typed_table(
repo: "_HasTableActions[_DbRecordT]", record_type: type[_DbRecordT]
) -> "_PrismaTableActions[_DbRecordT]":
return repo.table
def _as_object(value: object) -> object:
return value
def _nullable(value: _DbRecordT | None) -> _DbRecordT | None:
return value
class _UserIdRow(Protocol):
@property
def user_id(self) -> str | None: ...
class _HasUserIdTable(Protocol):
@property
def table(self) -> "_PrismaTableActions[_UserIdRow]": ...
def _user_id_rows_db(repo: "_HasUserIdTable") -> "_PrismaTableActions[_UserIdRow]":
return repo.table
class _RawTeamRow(Protocol):
@property
def members_with_roles(self) -> Sequence[Mapping[str, object]] | None: ...
class _HasRawTeamTable(Protocol):
@property
def table(self) -> "_PrismaTableActions[_RawTeamRow]": ...
def _raw_team_db(repo: "_HasRawTeamTable") -> "_PrismaTableActions[_RawTeamRow]":
return repo.table
class _BudgetWriteCall(Protocol):
async def __call__(
self, budget_obj: BudgetNewRequest, user_api_key_dict: UserAPIKeyAuth
) -> LiteLLM_BudgetTableFull: ...
def _as_budget_write(fn: "_BudgetWriteCall") -> "_BudgetWriteCall":
return fn
class _TeamFindManyArgs(TypedDict, total=False):
take: int
skip: int
order: Mapping[str, str]
cursor: Mapping[str, object]
class _TeamUiViewFilters(TypedDict, total=False):
team_id: Mapping[str, str]
team_alias: Mapping[str, str]
class _TeamIdInFilter(TypedDict, total=False):
team_id: Mapping[str, Sequence[str]]
def _team_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_TeamTable]":
team_table: Final[_PrismaTableActions[LiteLLM_TeamTable]] = TeamRepository(prisma_client).table
return team_table
return _typed_table(TeamRepository(prisma_client), LiteLLM_TeamTable)
def _team_membership_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_TeamMembership]":
membership_table: Final[_PrismaTableActions[LiteLLM_TeamMembership]] = TeamMembershipRepository(prisma_client).table
return membership_table
return _typed_table(TeamMembershipRepository(prisma_client), LiteLLM_TeamMembership)
def _user_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_UserTable]":
user_table: Final[_PrismaTableActions[LiteLLM_UserTable]] = UserRepository(prisma_client).table
return user_table
return _typed_table(UserRepository(prisma_client), LiteLLM_UserTable)
def _model_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_ModelTable]":
model_table: Final[_PrismaTableActions[LiteLLM_ModelTable]] = ModelTableRepository(prisma_client).table
return model_table
return _typed_table(ModelTableRepository(prisma_client), LiteLLM_ModelTable)
def _org_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_OrganizationTable]":
org_table: Final[_PrismaTableActions[LiteLLM_OrganizationTable]] = OrganizationRepository(prisma_client).table
return org_table
return _typed_table(OrganizationRepository(prisma_client), LiteLLM_OrganizationTable)
def _org_membership_db(
prisma_client: PrismaClient | None,
) -> "_PrismaTableActions[LiteLLM_OrganizationMembershipTable]":
org_membership_table: _PrismaTableActions[LiteLLM_OrganizationMembershipTable] = OrganizationMembershipRepository(
prisma_client
).table
return org_membership_table
return _typed_table(OrganizationMembershipRepository(prisma_client), LiteLLM_OrganizationMembershipTable)
def _budget_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_BudgetTableFull]":
budget_table: Final[_PrismaTableActions[LiteLLM_BudgetTableFull]] = BudgetRepository(prisma_client).table
return budget_table
return _typed_table(BudgetRepository(prisma_client), LiteLLM_BudgetTableFull)
def _deleted_team_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_DeletedTeamTable]":
deleted_team_table: _PrismaTableActions[LiteLLM_DeletedTeamTable] = DeletedTeamRepository(prisma_client).table
return deleted_team_table
return _typed_table(DeletedTeamRepository(prisma_client), LiteLLM_DeletedTeamTable)
def _access_group_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_AccessGroupTable]":
access_group_table: _PrismaTableActions[LiteLLM_AccessGroupTable] = AccessGroupRepository(prisma_client).table
return access_group_table
return _typed_table(AccessGroupRepository(prisma_client), LiteLLM_AccessGroupTable)
def _tokens_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_VerificationToken]":
tokens_table: _PrismaTableActions[LiteLLM_VerificationToken] = VerificationTokenRepository(prisma_client).table
return tokens_table
return _typed_table(VerificationTokenRepository(prisma_client), LiteLLM_VerificationToken)
def _sanitize_for_log(value: object) -> str:
@ -407,7 +486,7 @@ class TeamMemberBudgetHandler:
if team_member_budget_duration is not None:
budget_request.budget_duration = team_member_budget_duration
team_member_budget_table: Final = await new_budget(
team_member_budget_table: Final = await _as_budget_write(new_budget)(
budget_obj=budget_request,
user_api_key_dict=user_api_key_dict,
)
@ -455,7 +534,7 @@ class TeamMemberBudgetHandler:
if team_member_budget_duration is not None:
budget_request.budget_duration = team_member_budget_duration
budget_row: Final = await update_budget(
budget_row: Final = await _as_budget_write(update_budget)(
budget_obj=budget_request,
user_api_key_dict=user_api_key_dict,
)
@ -570,7 +649,7 @@ class TeamMemberBudgetHandler:
)
if missing:
await TeamMembershipRepository(prisma_client).table.create_many(
await _team_membership_db(prisma_client).create_many(
data=missing,
skip_duplicates=True, # safety net against concurrent races
)
@ -1406,9 +1485,10 @@ async def new_team(
complete_team_data_dict["metadata"] = encrypt_callback_vars(complete_team_data_dict["metadata"])
complete_team_data_dict = prisma_client.jsonify_team_object(db_data=complete_team_data_dict)
team_creation_data: Final[Mapping[str, object]] = complete_team_data_dict
team_row: Final[LiteLLM_TeamTable] = await TeamRepository(prisma_client).table.create(
data=complete_team_data_dict,
team_row: Final[LiteLLM_TeamTable] = await _team_db(prisma_client).create(
data=team_creation_data,
include={"litellm_model_table": True},
)
@ -1855,7 +1935,7 @@ async def update_team(
detail={"error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}"},
)
existing_team_row = await TeamRepository(prisma_client).table.find_unique(where={"team_id": data.team_id})
existing_team_row = await _team_db(prisma_client).find_unique(where={"team_id": data.team_id})
if existing_team_row is None:
raise HTTPException(
@ -1883,7 +1963,7 @@ async def update_team(
)
if data.max_budget is not None:
existing_soft_budget: Final = getattr(existing_team_row, "soft_budget", None)
existing_soft_budget: Final[object] = _as_object(getattr(existing_team_row, "soft_budget", None))
soft_budget_to_check: Final = data.soft_budget if data.soft_budget is not None else existing_soft_budget
if soft_budget_to_check is not None and isinstance(soft_budget_to_check, (int, float)):
if data.max_budget <= soft_budget_to_check:
@ -1942,7 +2022,7 @@ async def update_team(
data.organization_id = None
# check org team limits - if updating team that belongs to an org
org_id_to_check: Final = (
org_id_to_check: Final[object] = _as_object(
data.organization_id if data.organization_id is not None else existing_team_row.organization_id
)
if org_id_to_check is not None and isinstance(org_id_to_check, str) and prisma_client is not None:
@ -1975,7 +2055,7 @@ async def update_team(
TeamMemberBudgetHandler.strip_system_managed_metadata_keys(updated_kv["metadata"])
if "metadata" in updated_kv:
stored_metadata: Final = (
stored_metadata: Final[Mapping[str, JsonValue] | None] = (
{ # mutable-ok: the validator payload's isinstance guard requires a plain dict
key: value
for key, value in existing_team_row.metadata.items()
@ -2078,16 +2158,19 @@ async def update_team(
updated_kv["router_settings"] = safe_dumps(updated_kv["router_settings"])
updated_kv = prisma_client.jsonify_team_object(db_data=updated_kv)
team_row: Final[LiteLLM_TeamTable | None] = await TeamRepository(prisma_client).table.update(
where={"team_id": data.team_id},
data=updated_kv,
# `object_permission` is included so `_refresh_cached_team`
# doesn't write a cached team with the relation nulled out —
# see team_model_add for the full rationale.
include={
"litellm_model_table": True,
"object_permission": True,
},
team_update_data: Final[Mapping[str, object]] = updated_kv
team_row: Final[LiteLLM_TeamTable | None] = _nullable(
await _team_db(prisma_client).update(
where={"team_id": data.team_id},
data=team_update_data,
# `object_permission` is included so `_refresh_cached_team`
# doesn't write a cached team with the relation nulled out —
# see team_model_add for the full rationale.
include={
"litellm_model_table": True,
"object_permission": True,
},
)
)
if team_row is None or team_row.team_id is None:
@ -2602,7 +2685,7 @@ async def _resolve_existing_member_user_ids(
if not requested_user_ids:
return frozenset()
found: Final = await UserRepository(prisma_client).table.find_many(
found: Final = await _user_id_rows_db(UserRepository(prisma_client)).find_many(
where={ # mutable-ok: Prisma query filters are dict-shaped
"user_id": { # mutable-ok: Prisma query filters are dict-shaped
"in": sorted(requested_user_ids)
@ -3097,7 +3180,9 @@ async def team_member_delete(
key_val["user_id"] = data.user_id
elif data.user_email is not None:
key_val["user_email"] = data.user_email
existing_user_rows: Final = await UserRepository(prisma_client).table.find_many(where=key_val)
existing_user_rows: Final[Sequence[LiteLLM_UserTable] | None] = await UserRepository(prisma_client).table.find_many(
where=key_val
)
if existing_user_rows is not None and (isinstance(existing_user_rows, list) and len(existing_user_rows) > 0):
for existing_user in existing_user_rows:
@ -3105,7 +3190,7 @@ async def team_member_delete(
if data.team_id in existing_user.teams:
team_list = existing_user.teams
team_list.remove(data.team_id)
await UserRepository(prisma_client).table.update(
await _user_db(prisma_client).update(
where={
"user_id": existing_user.user_id,
},
@ -3113,7 +3198,7 @@ async def team_member_delete(
)
# Also clean up any existing team membership rows for this user and team
user_ids_to_delete: Final = set()
user_ids_to_delete: Final = set[str]()
if data.user_id is not None:
user_ids_to_delete.add(data.user_id)
if existing_user_rows is not None and isinstance(existing_user_rows, list):
@ -3122,9 +3207,7 @@ async def team_member_delete(
user_ids_to_delete.add(existing_user.user_id)
for _uid in user_ids_to_delete:
await TeamMembershipRepository(prisma_client).table.delete_many(
where={"team_id": data.team_id, "user_id": _uid}
)
await _team_membership_db(prisma_client).delete_many(where={"team_id": data.team_id, "user_id": _uid})
## DELETE KEYS CREATED BY USER FOR THIS TEAM
if user_ids_to_delete:
@ -3133,9 +3216,7 @@ async def team_member_delete(
)
# Fetch keys before deletion to persist them
keys_to_delete: Final[list[LiteLLM_VerificationToken]] = await VerificationTokenRepository(
prisma_client
).table.find_many(
keys_to_delete: Final[list[LiteLLM_VerificationToken]] = await _tokens_db(prisma_client).find_many(
where={
"user_id": {"in": list(user_ids_to_delete)},
"team_id": data.team_id,
@ -3150,7 +3231,7 @@ async def team_member_delete(
litellm_changed_by=None,
)
await VerificationTokenRepository(prisma_client).table.delete_many(
await _tokens_db(prisma_client).delete_many(
where={
"user_id": {"in": list(user_ids_to_delete)},
"team_id": data.team_id,
@ -3310,7 +3391,7 @@ async def team_member_update(
### upsert new budget
budget_patch: Final = _build_member_budget_patch(data)
async with prisma_client.db.tx() as tx:
async with prisma_client.tx() as tx:
await _upsert_budget_and_membership(
tx=tx,
team_id=data.team_id,
@ -3653,7 +3734,7 @@ async def delete_team(
_persist_deleted_verification_tokens,
)
keys_to_delete: list[LiteLLM_VerificationToken] = await VerificationTokenRepository(prisma_client).table.find_many(
keys_to_delete: list[LiteLLM_VerificationToken] = await _tokens_db(prisma_client).find_many(
where={"team_id": {"in": data.team_ids}}
)
@ -3829,7 +3910,7 @@ async def _add_team_member_budget_table(
) -> TeamInfoResponseObjectTeamTable:
try:
team_budget: Final = await _budget_db(prisma_client).find_unique(where={"budget_id": team_member_budget_id})
team_info_response_object.team_member_budget_table = team_budget
return team_info_response_object.model_copy(update={"team_member_budget_table": team_budget})
except Exception:
verbose_proxy_logger.info(
"Team member budget table not found, passed team_member_budget_id=%s", team_member_budget_id
@ -3838,21 +3919,34 @@ async def _add_team_member_budget_table(
return team_info_response_object
async def _resolve_team_access_group_resources(_team_info: TeamInfoResponseObjectTeamTable) -> None:
"""Populate access_group_models / mcp_server_ids / agent_ids on the team
info response by resolving inherited resources from its access groups."""
async def _resolve_team_access_group_resources(
_team_info: TeamInfoResponseObjectTeamTable,
) -> TeamInfoResponseObjectTeamTable:
"""Return a copy of the team info with access_group_models / mcp_server_ids /
agent_ids / details resolved from its access groups."""
if not _team_info.access_group_ids:
return
return _team_info
ag_lookup: Final = await _batch_resolve_access_group_resources(_team_info.access_group_ids)
models, mcp_ids, agent_ids = set(), set(), set()
for ag_id in _team_info.access_group_ids:
if ag_id in ag_lookup:
models.update(ag_lookup[ag_id]["models"])
mcp_ids.update(ag_lookup[ag_id]["mcp_server_ids"])
agent_ids.update(ag_lookup[ag_id]["agent_ids"])
_team_info.access_group_models = list(models)
_team_info.access_group_mcp_server_ids = list(mcp_ids)
_team_info.access_group_agent_ids = list(agent_ids)
resolved_groups: Final = tuple(
ag_lookup[ag_id] for ag_id in dict.fromkeys(_team_info.access_group_ids) if ag_id in ag_lookup
)
return _team_info.model_copy(
update={
"access_group_models": list({m for group in resolved_groups for m in (group.access_model_names or [])}),
"access_group_mcp_server_ids": list(
{s for group in resolved_groups for s in (group.access_mcp_server_ids or [])}
),
"access_group_agent_ids": list({a for group in resolved_groups for a in (group.access_agent_ids or [])}),
"access_group_details": tuple(
TeamAccessGroupModelGrant(
access_group_id=group.access_group_id,
access_group_name=group.access_group_name,
models=tuple(group.access_model_names or ()),
)
for group in resolved_groups
),
}
)
@router.get("/team/info", tags=["team management"], dependencies=[Depends(user_api_key_auth)])
@ -3958,11 +4052,11 @@ async def team_info(
)
# Resolve resources inherited from access groups
await _resolve_team_access_group_resources(_team_info)
resolved_team_info: Final = await _resolve_team_access_group_resources(_team_info)
response_object: Final = TeamInfoResponseObject(
team_id=team_id,
team_info=_team_info,
team_info=resolved_team_info,
keys=keys,
team_memberships=returned_tm,
)
@ -4391,32 +4485,21 @@ async def _build_team_list_where_conditions(
async def _batch_resolve_access_group_resources(
all_access_group_ids: list[str],
) -> dict[str, dict[str, list[str]]]:
) -> dict[str, LiteLLM_AccessGroupTable]:
"""
Batch-fetch access groups in a single DB query and return a per-group
resource map.
Returns {ag_id: {"models": [...], "mcp_server_ids": [...], "agent_ids": [...]}}.
Missing/invalid groups are silently omitted.
Batch-fetch access groups in a single DB query and return them keyed by
access_group_id. Missing/invalid groups are silently omitted.
"""
from litellm.proxy.proxy_server import prisma_client as _prisma_client
if not all_access_group_ids or _prisma_client is None:
return {}
unique_ids: Final = list(set(all_access_group_ids))
unique_ids: Final = tuple(frozenset(all_access_group_ids))
rows: Final = await _access_group_db(_prisma_client).find_many(
where={"access_group_id": {"in": unique_ids}},
)
result: Final[dict[str, dict[str, list[str]]]] = {}
for row in rows:
result[row.access_group_id] = {
"models": list(row.access_model_names or []),
"mcp_server_ids": list(row.access_mcp_server_ids or []),
"agent_ids": list(row.access_agent_ids or []),
}
return result
return {row.access_group_id: row for row in rows}
def _convert_teams_to_response_models(
@ -4466,7 +4549,7 @@ async def _get_keys_count_by_team(
if not page_team_ids:
return {}
grouped: Final = await VerificationTokenRepository(prisma_client).table.group_by(
grouped: Final = await _tokens_db(prisma_client).group_by(
by=["team_id"],
where={"team_id": {"in": page_team_ids}},
count={"team_id": True},
@ -4710,15 +4793,18 @@ async def list_team_v2(
all_ag_ids: Final = [ag_id for t in team_items_with_ag for ag_id in (t.access_group_ids or [])]
ag_lookup: Final = await _batch_resolve_access_group_resources(all_ag_ids)
for team_item in team_items_with_ag:
models, mcp_ids, agent_ids = set(), set(), set()
for ag_id in team_item.access_group_ids or []:
if ag_id in ag_lookup:
models.update(ag_lookup[ag_id]["models"])
mcp_ids.update(ag_lookup[ag_id]["mcp_server_ids"])
agent_ids.update(ag_lookup[ag_id]["agent_ids"])
team_item.access_group_models = list(models)
team_item.access_group_mcp_server_ids = list(mcp_ids)
team_item.access_group_agent_ids = list(agent_ids)
team_groups = tuple(
ag_lookup[ag_id] for ag_id in (team_item.access_group_ids or []) if ag_id in ag_lookup
)
team_item.access_group_models = list(
{m for group in team_groups for m in (group.access_model_names or [])}
)
team_item.access_group_mcp_server_ids = list(
{s for group in team_groups for s in (group.access_mcp_server_ids or [])}
)
team_item.access_group_agent_ids = list(
{a for group in team_groups for a in (group.access_agent_ids or [])}
)
return {
"teams": team_list,
@ -4780,7 +4866,7 @@ async def _authorize_and_filter_teams(
if allowed_org_ids is not None:
# Org admin: query DB for teams in their orgs
org_teams: Final = await TeamRepository(prisma_client).table.find_many(
org_teams: Final = await _raw_team_db(TeamRepository(prisma_client)).find_many(
where={"organization_id": {"in": allowed_org_ids}},
include={"litellm_model_table": True},
)
@ -4794,7 +4880,9 @@ async def _authorize_and_filter_teams(
]
elif user_id:
# Regular user: fetch all and filter by membership (Prisma can't filter JSON arrays)
response: Final = await TeamRepository(prisma_client).table.find_many(include={"litellm_model_table": True})
response: Final = await _raw_team_db(TeamRepository(prisma_client)).find_many(
include={"litellm_model_table": True}
)
return [
team
for team in response
@ -4802,7 +4890,7 @@ async def _authorize_and_filter_teams(
]
else:
# Proxy admin: all teams
return list(await TeamRepository(prisma_client).table.find_many(include={"litellm_model_table": True}))
return list(await _raw_team_db(TeamRepository(prisma_client)).find_many(include={"litellm_model_table": True}))
@router.get("/team/list", tags=["team management"], dependencies=[Depends(user_api_key_auth)])
@ -4854,7 +4942,7 @@ async def list_team(
_team_memberships.append(tm)
# add all keys that belong to the team
keys = await VerificationTokenRepository(prisma_client).table.find_many(where={"team_id": team.team_id})
keys = await _tokens_db(prisma_client).find_many(where={"team_id": team.team_id})
try:
returned_responses.append(
@ -4905,7 +4993,7 @@ async def get_paginated_teams(
total_count: Final = await _team_db(prisma_client).count()
# Get paginated teams
teams: Final = await TeamRepository(prisma_client).table.find_many(
teams: Final = await _team_db(prisma_client).find_many(
skip=skip,
take=page_size,
order={"team_alias": "asc"}, # Sort by team_alias
@ -4955,7 +5043,7 @@ async def ui_view_teams(
skip: Final = (page - 1) * page_size
# Build where conditions based on provided parameters
where_conditions: Final = {}
where_conditions: Final[_TeamUiViewFilters] = {}
if team_id:
where_conditions["team_id"] = {
@ -4970,7 +5058,7 @@ async def ui_view_teams(
}
# Query users with pagination and filters
teams: Final = await TeamRepository(prisma_client).table.find_many(
teams: Final = await _team_db(prisma_client).find_many(
where=where_conditions,
skip=skip,
take=page_size,
@ -5160,13 +5248,13 @@ async def team_model_delete(
)
# Get current models list
current_models: Final = team_obj.models or []
current_models: Final[Sequence[str]] = team_obj.models or []
# Remove specified models
updated_models: Final = [m for m in current_models if m not in data.models]
# Update team. See team_model_add for the rationale on `include`.
updated_team: Final = await TeamRepository(prisma_client).table.update(
updated_team: Final = await _team_db(prisma_client).update(
where={"team_id": data.team_id},
data={"models": updated_models},
include={"object_permission": True},
@ -5419,7 +5507,7 @@ async def _append_permissions_to_all_teams(prisma_client: PrismaClient, permissi
BATCH_SIZE: Final = 500
while True:
find_args: dict = {
find_args: _TeamFindManyArgs = {
"take": BATCH_SIZE,
"order": {"team_id": "asc"},
}
@ -5427,7 +5515,7 @@ async def _append_permissions_to_all_teams(prisma_client: PrismaClient, permissi
find_args["cursor"] = {"team_id": cursor}
find_args["skip"] = 1
teams = await TeamRepository(prisma_client).table.find_many(**find_args)
teams = await _team_db(prisma_client).find_many(**find_args)
if not teams:
break
@ -5522,11 +5610,11 @@ async def get_team_daily_activity(
)
## Fetch team aliases and check team admin status
where_condition: Final = {}
where_condition: Final[_TeamIdInFilter] = {}
if team_ids_list:
where_condition["team_id"] = {"in": list(team_ids_list)}
team_aliases: Final = await TeamRepository(prisma_client).table.find_many(where=where_condition)
team_alias_metadata: Final = {t.team_id: {"team_alias": t.team_alias} for t in team_aliases}
team_aliases: Final = await _team_db(prisma_client).find_many(where=where_condition)
team_alias_metadata: Final = {t.team_id: {"team_alias": _as_object(t.team_alias)} for t in team_aliases}
# Check if user is team admin or has /team/daily/activity permission
# If not, filter by user's API keys.

View file

@ -16,9 +16,22 @@ import json
import os
import re
import secrets
from collections.abc import Mapping, Sequence
from copy import deepcopy
from html import escape
from typing import TYPE_CHECKING, Any, Final, Literal, NoReturn, Optional, Union, cast
from typing import (
TYPE_CHECKING,
Any,
Final,
Literal,
NoReturn,
Optional,
Protocol,
TypeVar,
Union,
cast,
overload,
)
from urllib.parse import parse_qs, urlencode, urlparse
if TYPE_CHECKING:
@ -155,6 +168,102 @@ _CLI_SSO_SECRET_KEY_FRAGMENTS: Final = frozenset(
}
)
_DbRecordT: Final = TypeVar("_DbRecordT", covariant=True)
class _PrismaTableActions(Protocol[_DbRecordT]):
async def find_unique(
self,
where: Mapping[str, object],
) -> _DbRecordT | None: ...
async def find_first(
self,
where: Mapping[str, object] | None = None,
) -> _DbRecordT | None: ...
async def find_many(
self,
where: Mapping[str, object] | None = None,
) -> Sequence[_DbRecordT]: ...
async def update(
self,
where: Mapping[str, object],
data: Mapping[str, object],
) -> _DbRecordT: ...
async def update_many(
self,
where: Mapping[str, object],
data: Mapping[str, object],
) -> int: ...
class _UserMetadataRow(Protocol):
@property
def metadata(self) -> Mapping[str, object] | None: ...
class _HasUserMetadataTable(Protocol):
@property
def table(self) -> "_PrismaTableActions[_UserMetadataRow]": ...
def _user_meta_db(repo: "_HasUserMetadataTable") -> "_PrismaTableActions[_UserMetadataRow]":
return repo.table
class _SsoConfigRow(Protocol):
@property
def sso_settings(self) -> Mapping[str, object] | None: ...
class _HasSsoConfigTable(Protocol):
@property
def table(self) -> "_PrismaTableActions[_SsoConfigRow]": ...
def _sso_config_db(repo: "_HasSsoConfigTable") -> "_PrismaTableActions[_SsoConfigRow]":
return repo.table
class _TeamDetailRow(Protocol):
def model_dump(self) -> Mapping[str, object]: ...
class _HasTeamDetailTable(Protocol):
@property
def table(self) -> "_PrismaTableActions[_TeamDetailRow]": ...
def _team_detail_db(repo: "_HasTeamDetailTable") -> "_PrismaTableActions[_TeamDetailRow]":
return repo.table
class _CustomSsoCall(Protocol):
async def __call__(self, sso_response: object) -> SSOUserDefinedValues | None: ...
class _ServicePrincipalAssignment(Protocol):
def get(self, key: str) -> str: ...
class _ServicePrincipalPage(Protocol):
@overload
def get(
self,
key: Literal["value"],
default: Sequence["_ServicePrincipalAssignment"],
) -> Sequence["_ServicePrincipalAssignment"]: ...
@overload
def get(self, key: Literal["@odata.nextLink"]) -> str | None: ...
def _as_object(value: object) -> object:
return value
def _hash_cli_sso_secret(secret: str) -> str:
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
@ -256,7 +365,7 @@ def _get_cli_sso_flow_or_raise(login_id: str | None, cache: DualCache) -> dict:
flow = cache.get_cache(key=cache_key)
if isinstance(flow, str):
try:
flow = json.loads(flow)
flow = _as_object(json.loads(flow))
except ValueError:
flow = None
if not isinstance(flow, dict) or "poll_secret_hash" not in flow:
@ -421,7 +530,7 @@ def _flatten_cli_sso_metadata_for_poll(
def build_cli_sso_attribution_metadata(
result: CustomOpenID | OpenID | dict,
) -> dict[str, Any]:
) -> dict[str, object]:
"""
Build allowlisted, non-secret scalar attribution metadata from an SSO result.
@ -432,7 +541,7 @@ def build_cli_sso_attribution_metadata(
if not claim_map:
return {}
metadata: Final[dict[str, Any]] = {}
metadata: Final[dict[str, object]] = {}
for source_claim, dest_key in claim_map:
if not _is_safe_cli_sso_metadata_dest_key(dest_key):
verbose_proxy_logger.debug("Skipping unsafe CLI SSO metadata destination key: %s", dest_key)
@ -474,14 +583,14 @@ def _merge_cli_sso_attribution_metadata(
async def _persist_cli_sso_user_metadata(
prisma_client: PrismaClient,
user_id: str,
attribution_metadata: dict[str, Any],
attribution_metadata: dict[str, object],
) -> None:
if not attribution_metadata:
return
try:
user_row: Final = await UserRepository(prisma_client).table.find_unique(where={"user_id": user_id})
existing_metadata: dict[str, Any] = {}
user_row: Final = await _user_meta_db(UserRepository(prisma_client)).find_unique(where={"user_id": user_id})
existing_metadata: dict[str, object] = {}
if user_row is not None:
row_metadata: Final = user_row.metadata
if isinstance(row_metadata, dict):
@ -491,7 +600,7 @@ async def _persist_cli_sso_user_metadata(
existing_metadata=existing_metadata,
attribution_metadata=attribution_metadata,
)
await UserRepository(prisma_client).table.update_many(
await _user_meta_db(UserRepository(prisma_client)).update_many(
where={"user_id": user_id},
data={"metadata": merged_metadata},
)
@ -1104,7 +1213,7 @@ def generic_response_convertor(
)
# Build extra_fields dict from GENERIC_USER_EXTRA_ATTRIBUTES if specified
extra_fields: dict[str, Any] | None = None
extra_fields: dict[str, object] | None = None
if generic_user_extra_attributes:
extra_fields = {}
for attr_name in generic_user_extra_attributes.split(","):
@ -1193,7 +1302,9 @@ async def _setup_team_mappings() -> Optional["TeamMappings"]:
prisma_client: Final = get_prisma_client_or_throw("Prisma client is None, connect a database to your proxy")
sso_db_record: Final = await SSOConfigRepository(prisma_client).table.find_unique(where={"id": "sso_config"})
sso_db_record: Final = await _sso_config_db(SSOConfigRepository(prisma_client)).find_unique(
where={"id": "sso_config"}
)
if sso_db_record and sso_db_record.sso_settings:
sso_settings_dict: Final = dict(sso_db_record.sso_settings)
@ -1225,7 +1336,9 @@ async def _setup_role_mappings() -> Optional["RoleMappings"]:
prisma_client: Final = get_prisma_client_or_throw("Prisma client is None, connect a database to your proxy")
sso_db_record: Final = await SSOConfigRepository(prisma_client).table.find_unique(where={"id": "sso_config"})
sso_db_record: Final = await _sso_config_db(SSOConfigRepository(prisma_client)).find_unique(
where={"id": "sso_config"}
)
if sso_db_record and sso_db_record.sso_settings:
sso_settings_dict: Final = dict(sso_db_record.sso_settings)
@ -1273,7 +1386,7 @@ async def _setup_role_mappings() -> Optional["RoleMappings"]:
return role_mappings
def _parse_generic_sso_headers() -> dict:
def _parse_generic_sso_headers() -> dict[str, str]:
"""Parse comma-separated GENERIC_SSO_HEADERS env var into a dict."""
raw: Final = os.getenv("GENERIC_SSO_HEADERS", None)
if raw is None:
@ -1677,7 +1790,7 @@ def _build_sso_user_update_data(
result: Union["CustomOpenID", OpenID, dict] | None,
user_email: str | None,
user_id: str | None,
) -> dict:
) -> dict[str, object]:
"""
Build the update data dictionary for SSO user upsert.
@ -1689,7 +1802,7 @@ def _build_sso_user_update_data(
Returns:
dict: Update data containing user_email and optionally user_role if valid
"""
update_data: Final[dict] = {"user_email": normalize_email(user_email)}
update_data: Final[dict[str, object]] = {"user_email": normalize_email(user_email)}
# Get SSO role from result and include if valid
sso_role: Final = getattr(result, "user_role", None)
@ -1740,7 +1853,7 @@ async def _sync_user_role_from_jwt_role_map(
# Update existing DB record if role differs
if user_info is not None and user_info.user_role != mapped_role.value:
await UserRepository(prisma_client).table.update(
await _user_meta_db(UserRepository(prisma_client)).update(
where={"user_id": user_info.user_id},
data={"user_role": mapped_role.value},
)
@ -1796,7 +1909,7 @@ async def check_and_update_if_proxy_admin_id(user_role: str, user_id: str, prism
return user_role
if prisma_client:
await UserRepository(prisma_client).table.update(
await _user_meta_db(UserRepository(prisma_client)).update(
where={"user_id": user_id},
data={"user_role": LitellmUserRoles.PROXY_ADMIN.value},
)
@ -2016,10 +2129,11 @@ async def _build_cli_sso_user_defined_values(
) -> SSOUserDefinedValues | None:
from litellm.proxy.proxy_server import user_custom_sso
custom_sso_handler: Final[_CustomSsoCall | None] = user_custom_sso
user_id: Final = parsed_openid_result.get("user_id")
if user_custom_sso is not None:
if inspect.iscoroutinefunction(user_custom_sso):
return await user_custom_sso(result)
if custom_sso_handler is not None:
if inspect.iscoroutinefunction(custom_sso_handler):
return await custom_sso_handler(result)
raise ValueError("user_custom_sso must be a coroutine function")
if user_id is None:
return None
@ -2035,12 +2149,14 @@ async def _build_cli_sso_user_defined_values(
async def _fetch_cli_sso_team_details(
prisma_client: PrismaClient,
teams: list[str],
) -> list[dict[str, Any]]:
team_details: Final[list[dict[str, Any]]] = []
teams: Sequence[str],
) -> list[dict[str, object]]:
team_details: Final[list[dict[str, object]]] = []
try:
if teams:
prisma_teams: Final = await TeamRepository(prisma_client).table.find_many(where={"team_id": {"in": teams}})
prisma_teams: Final = await _team_detail_db(TeamRepository(prisma_client)).find_many(
where={"team_id": {"in": teams}}
)
for team_row in prisma_teams:
team_dict = team_row.model_dump()
team_details.append(
@ -2257,12 +2373,12 @@ async def cli_poll_key(
verbose_proxy_logger.info("Returning teams list for user %s to select from: %s", user_id, user_teams)
# Best-effort construction of team_details if it wasn't
# already cached for some reason.
team_details_response: list[dict[str, Any]] | None = None
team_details_response: list[dict[str, object]] | None = None
if isinstance(user_team_details, list) and user_team_details:
team_details_response = user_team_details
elif user_teams:
team_details_response = [{"team_id": t, "team_alias": None} for t in user_teams]
poll_response: dict[str, Any] = {
poll_response: dict[str, object] = {
"status": "ready",
"user_id": user_id,
"teams": user_teams,
@ -2997,7 +3113,9 @@ class SSOAuthenticationHandler:
user_id=user_id,
)
await UserRepository(prisma_client).table.update_many(where={"user_id": user_id}, data=update_data)
await _user_meta_db(UserRepository(prisma_client)).update_many(
where={"user_id": user_id}, data=update_data
)
else:
verbose_proxy_logger.info("user not in DB, inserting user into LiteLLM DB")
# user not in DB, insert User into LiteLLM DB
@ -3089,7 +3207,9 @@ class SSOAuthenticationHandler:
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
try:
team_obj: Final = await TeamRepository(prisma_client).table.find_first(where={"team_id": litellm_team_id})
team_obj: Final = await _team_detail_db(TeamRepository(prisma_client)).find_first(
where={"team_id": litellm_team_id}
)
verbose_proxy_logger.debug("Team object: %s", team_obj)
# only create a new team if it doesn't exist
@ -3278,9 +3398,10 @@ class SSOAuthenticationHandler:
# But if it is, we want their models preferences
user_defined_values: SSOUserDefinedValues | None = None
if user_custom_sso is not None:
if inspect.iscoroutinefunction(user_custom_sso):
user_defined_values = await user_custom_sso(result)
custom_sso_handler: Final[_CustomSsoCall | None] = user_custom_sso
if custom_sso_handler is not None:
if inspect.iscoroutinefunction(custom_sso_handler):
user_defined_values = await custom_sso_handler(result)
else:
raise ValueError("user_custom_sso must be a coroutine function")
elif user_id is not None:
@ -3448,7 +3569,7 @@ class SSOAuthenticationHandler:
dict: Token exchange parameters
"""
# Prepare token exchange parameters (may add code_verifier: str later)
token_params: Final[dict[str, Any]] = {"include_client_id": generic_include_client_id}
token_params: Final[dict[str, object]] = {"include_client_id": generic_include_client_id}
# Retrieve PKCE code_verifier if PKCE was used in authorization.
# Gate on GENERIC_CLIENT_USE_PKCE to avoid an unnecessary Redis round-trip
@ -3663,7 +3784,7 @@ class SSOAuthenticationHandler:
access_token string. Raises ProxyException on any validation failure.
"""
try:
token_response_raw: Final = response.json()
token_response_raw: Final[object] = _as_object(response.json())
except Exception as json_err:
verbose_proxy_logger.error(
"Failed to parse token response as JSON: %s. Body: %s",
@ -4253,7 +4374,7 @@ class MicrosoftSSOHandler:
while next_link is not None and page_count < MicrosoftSSOHandler.MAX_GRAPH_API_PAGES:
response = await async_client.get(next_link, headers=headers)
response_json = response.json()
response_json: _ServicePrincipalPage = response.json()
verbose_proxy_logger.debug("Response from service principal app role assigned to: %s", response_json)
for _object in response_json.get("value", []):

View file

@ -1,9 +1,10 @@
import base64
import mimetypes
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, Literal, Optional
from typing import TYPE_CHECKING, Final, Literal, Optional, Protocol, runtime_checkable
from litellm.repositories.table_repositories import (
ManagedFileRepository,
@ -16,10 +17,26 @@ if TYPE_CHECKING:
from prisma.models import LiteLLM_ManagedObjectTable
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.utils import PrismaClient
from litellm.router import Router
from litellm.types.utils import LiteLLMBatch
@runtime_checkable
class ManagedResourceAccessChecker(Protocol):
async def can_user_call_unified_file_id(
self,
unified_file_id: str,
user_api_key_dict: "UserAPIKeyAuth",
) -> bool: ...
async def can_user_call_unified_object_id(
self,
unified_object_id: str,
user_api_key_dict: "UserAPIKeyAuth",
) -> bool: ...
def _is_base64_encoded_unified_file_id(b64_uid: str) -> str | Literal[False]:
# Ensure b64_uid is a string and not a mock object
if not isinstance(b64_uid, str):
@ -879,6 +896,65 @@ def validate_managed_files_requirement(
)
async def validate_managed_id_requirement(
resource_id: str | None,
resource_kind: Literal["file", "batch", "fine-tuning job"],
user_api_key_dict: "UserAPIKeyAuth",
managed_files_obj: object | None,
) -> None:
"""
Enforce proxy-level managed resources on every route that accepts a provider-issued id
when ``litellm.require_managed_files`` is enabled, and authenticate managed ids against
the caller's stored ownership record.
Ownership is only recorded for LiteLLM managed ids, so a raw provider id is forwarded to the
provider under shared credentials without any tenant check; knowing another tenant's provider
id would be enough to read, reuse, or destroy the object behind it.
Raises:
HTTPException: 400 for a raw id, 403 for an inaccessible managed id, or 500 when
ownership validation is unavailable.
"""
from fastapi import HTTPException
import litellm
if litellm.require_managed_files is not True:
return
if not resource_id:
return
if not _is_base64_encoded_unified_file_id(resource_id):
raise HTTPException(
status_code=400,
detail=(
f"Raw provider {resource_kind} ids cannot be used when require_managed_files is enabled in "
f"litellm_settings. Use the LiteLLM managed {resource_kind} id returned when the "
f"{resource_kind} was created."
),
)
if not isinstance(managed_files_obj, ManagedResourceAccessChecker):
raise HTTPException(
status_code=500,
detail="Managed resource ownership validation is unavailable.",
)
can_access: Final = (
await managed_files_obj.can_user_call_unified_file_id(resource_id, user_api_key_dict)
if resource_kind == "file"
else await managed_files_obj.can_user_call_unified_object_id(resource_id, user_api_key_dict)
)
if can_access:
return
raise HTTPException(
status_code=403,
detail=f"The caller does not have access to this managed {resource_kind} id.",
)
def _extract_model_param(request: "Request", request_body: dict) -> str | None:
"""
Extract model parameter from request.
@ -1002,6 +1078,34 @@ async def resolve_output_file_ids_to_unified(response, prisma_client) -> None:
pass
async def map_raw_file_ids_to_unified(
raw_file_ids: frozenset[str], prisma_client: "PrismaClient | None"
) -> Mapping[str, str]:
if not raw_file_ids or not prisma_client:
return MappingProxyType({})
managed_files: Final = await ManagedFileRepository(prisma_client).table.find_many(
where={"flat_model_file_ids": {"hasSome": sorted(raw_file_ids)}} # mutable-ok: prisma where is a plain dict
)
return MappingProxyType(
{
raw_id: managed_file.unified_file_id
for managed_file in managed_files
for raw_id in managed_file.flat_model_file_ids
if raw_id in raw_file_ids
}
)
def apply_unified_file_ids(response: "LiteLLMBatch", unified_id_by_raw_id: Mapping[str, str]) -> None:
for file_attr, raw_id in (
("input_file_id", getattr(response, "input_file_id", None)),
("output_file_id", getattr(response, "output_file_id", None)),
("error_file_id", getattr(response, "error_file_id", None)),
):
if isinstance(raw_id, str) and raw_id in unified_id_by_raw_id:
setattr(response, file_attr, unified_id_by_raw_id[raw_id])
async def ensure_batch_response_managed_file_ids(
response,
managed_files_obj,

View file

@ -50,6 +50,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import (
handle_model_based_routing,
prepare_data_with_credentials,
validate_managed_files_requirement,
validate_managed_id_requirement,
)
from litellm.proxy.utils import ProxyLogging, is_known_model
from litellm.repositories.table_repositories import ManagedFileRepository
@ -612,6 +613,13 @@ async def get_file_content(
data: dict = {"file_id": file_id}
try:
await validate_managed_id_requirement(
resource_id=file_id,
resource_kind="file",
user_api_key_dict=user_api_key_dict,
managed_files_obj=proxy_logging_obj.get_proxy_hook("managed_files"),
)
# Include original request and headers in the data
base_llm_response_processor: Final = ProxyBaseLLMRequestProcessing(data=data)
(
@ -908,6 +916,13 @@ async def get_file(
data: dict = {"file_id": file_id}
try:
await validate_managed_id_requirement(
resource_id=file_id,
resource_kind="file",
user_api_key_dict=user_api_key_dict,
managed_files_obj=proxy_logging_obj.get_proxy_hook("managed_files"),
)
custom_llm_provider: Final = (
provider
or get_custom_llm_provider_from_request_headers(request=request)
@ -1098,6 +1113,13 @@ async def delete_file(
data: dict = {"file_id": file_id}
try:
await validate_managed_id_requirement(
resource_id=file_id,
resource_kind="file",
user_api_key_dict=user_api_key_dict,
managed_files_obj=proxy_logging_obj.get_proxy_hook("managed_files"),
)
custom_llm_provider: Final = (
provider
or get_custom_llm_provider_from_request_headers(request=request)

View file

@ -17,7 +17,7 @@ import traceback
import warnings
from collections.abc import AsyncGenerator, Callable, Mapping
from datetime import datetime, timedelta, timezone
from types import UnionType
from types import MappingProxyType, UnionType
from typing import (
TYPE_CHECKING,
Any,
@ -297,6 +297,9 @@ from litellm.proxy.common_request_processing import (
_should_return_raw_model_name,
create_response,
)
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
AuthCacheInvalidationSubscriber,
)
from litellm.proxy.common_utils.callback_utils import initialize_callbacks_on_proxy
from litellm.proxy.common_utils.config_sync_pubsub import ConfigSyncSubscriber
from litellm.proxy.common_utils.debug_utils import init_verbose_loggers
@ -868,7 +871,7 @@ async def proxy_shutdown_event():
async def _initialize_shared_aiohttp_session():
"""Initialize shared aiohttp session for connection reuse with connection limits."""
try:
from aiohttp import ClientSession, TCPConnector
from aiohttp import ClientSession, DummyCookieJar, TCPConnector
from litellm.llms.custom_httpx.http_handler import (
_build_aiohttp_keepalive_socket_factory,
@ -889,7 +892,7 @@ async def _initialize_shared_aiohttp_session():
connector_kwargs["socket_factory"] = socket_factory
connector: Final = TCPConnector(**connector_kwargs)
session: Final = ClientSession(connector=connector)
session: Final = ClientSession(connector=connector, cookie_jar=DummyCookieJar())
verbose_proxy_logger.info(
"SESSION REUSE: Created shared aiohttp session for connection pooling (ID: %s, limit=%s, limit_per_host=%s)",
@ -1013,6 +1016,37 @@ async def proxy_startup_event(app: FastAPI):
asyncio.create_task(_run_pw_migration())
async def _run_agent_grant_id_migration() -> None:
from litellm.proxy.agent_endpoints.agent_registry import (
global_agent_registry,
object_permission_table,
)
for attempt in range(3):
try:
result = await global_agent_registry.migrate_legacy_grant_ids(
table=object_permission_table(prisma_client)
)
if result.rewritten:
verbose_proxy_logger.info(
"Rewrote %s object_permission rows from legacy config agent ids", result.rewritten
)
if result.missed == 0:
return
verbose_proxy_logger.warning(
"Legacy agent grant id migration attempt %s/3 left %s rows unmigrated",
attempt + 1,
result.missed,
)
except Exception as e: # noqa: BLE001 # startup task must survive any DB error and retry
verbose_proxy_logger.warning(
"Legacy agent grant id migration attempt %s/3 failed: %s", attempt + 1, e
)
if attempt < 2:
await asyncio.sleep(5)
asyncio.create_task(_run_agent_grant_id_migration())
## A coordination_redis block saved from the admin UI lives in the database,
## which is only reachable once the prisma client exists. Apply it here, before
## the coordination Redis is published to its consumers below.
@ -1193,6 +1227,8 @@ async def proxy_startup_event(app: FastAPI):
await proxy_config.stop_config_sync_subscriber()
await proxy_config.stop_auth_cache_invalidation_subscriber()
await proxy_shutdown_event()
@ -3904,6 +3940,7 @@ class ProxyConfig:
self._last_hashicorp_vault_config: dict[str, Any] | None = None
self.worker_registry: list[WorkerRegistryEntry] = []
self.config_sync_subscriber: ConfigSyncSubscriber | None = None
self.auth_cache_invalidation_subscriber: AuthCacheInvalidationSubscriber | None = None
from litellm.litellm_core_utils.get_model_cost_map import (
get_model_cost_map_loaded_at,
)
@ -6090,6 +6127,15 @@ class ProxyConfig:
else:
general_settings["disable_auto_add_proxy_admin_to_teams"] = value if value is None else bool(value)
if "apply_user_budget_to_team_keys" in _general_settings and (
"apply_user_budget_to_team_keys" not in self._yaml_general_settings_keys
):
db_value: Final = _general_settings["apply_user_budget_to_team_keys"]
if isinstance(db_value, str):
general_settings["apply_user_budget_to_team_keys"] = db_value.lower() == "true"
else:
general_settings["apply_user_budget_to_team_keys"] = db_value if db_value is None else bool(db_value)
## STORE MODEL IN DB ##
if "store_model_in_db" in _general_settings:
value = _general_settings["store_model_in_db"]
@ -6391,6 +6437,30 @@ class ProxyConfig:
except Exception as e:
verbose_proxy_logger.error("Error stopping config sync subscriber: %s", e)
def start_auth_cache_invalidation_subscriber(
self,
redis_cache: RedisCache | None,
user_api_key_cache: UserApiKeyCache,
) -> None:
if redis_cache is None or self.auth_cache_invalidation_subscriber is not None:
return
subscriber: Final = AuthCacheInvalidationSubscriber(
redis_cache=redis_cache,
user_api_key_cache=user_api_key_cache,
)
self.auth_cache_invalidation_subscriber = subscriber
subscriber.start()
async def stop_auth_cache_invalidation_subscriber(self) -> None:
subscriber: Final = self.auth_cache_invalidation_subscriber
if subscriber is None:
return
self.auth_cache_invalidation_subscriber = None
try:
await subscriber.stop()
except Exception as e: # noqa: BLE001 # best-effort: a failing stop must not break proxy shutdown
verbose_proxy_logger.error("Error stopping auth cache invalidation subscriber: %s", e)
async def _init_non_llm_objects_in_db(self, prisma_client: PrismaClient):
"""
Use this to read non-llm objects from the db and initialize them
@ -8330,6 +8400,11 @@ class ProxyStartupEvent:
misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME,
)
proxy_config.start_auth_cache_invalidation_subscriber(
redis_cache=redis_usage_cache,
user_api_key_cache=user_api_key_cache,
)
if store_model_in_db is True:
### GET STORED CREDENTIALS ###
scheduler.add_job(
@ -14947,6 +15022,29 @@ Keep it more precise, to prevent overwrite other values unintentially
_PLUGIN_KEY_REDACTED: Final = "***"
_GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingProxyType(
{
"max_parallel_requests": "Integer",
"global_max_parallel_requests": "Integer",
"max_request_size_mb": "Integer",
"max_response_size_mb": "Integer",
"proxy_config_reload_interval_seconds": "Integer",
"pass_through_endpoints": "PydanticModel",
"store_model_in_db": "Boolean",
"store_prompts_in_spend_logs": "Boolean",
"maximum_spend_logs_retention_period": "String",
"mcp_internal_ip_ranges": "List",
"mcp_trusted_proxy_ranges": "List",
"mcp_xff_num_trusted_hops": "Integer",
"always_include_stream_usage": "Boolean",
"forward_client_headers_to_llm_api": "Boolean",
"mcp_required_fields": "List",
"cancel_on_disconnect": "Boolean",
"disable_auto_add_proxy_admin_to_teams": "Boolean",
"apply_user_budget_to_team_keys": "Boolean",
}
)
def _preserve_redacted_plugin_keys(incoming: object, existing: object) -> object:
"""Restore real plugin_key values the client never sees.
@ -15445,25 +15543,7 @@ async def get_config_list(
else:
db_general_settings_dict = {}
allowed_args: Final = {
"max_parallel_requests": {"type": "Integer"},
"global_max_parallel_requests": {"type": "Integer"},
"max_request_size_mb": {"type": "Integer"},
"max_response_size_mb": {"type": "Integer"},
"proxy_config_reload_interval_seconds": {"type": "Integer"},
"pass_through_endpoints": {"type": "PydanticModel"},
"store_model_in_db": {"type": "Boolean"},
"store_prompts_in_spend_logs": {"type": "Boolean"},
"maximum_spend_logs_retention_period": {"type": "String"},
"mcp_internal_ip_ranges": {"type": "List"},
"mcp_trusted_proxy_ranges": {"type": "List"},
"mcp_xff_num_trusted_hops": {"type": "Integer"},
"always_include_stream_usage": {"type": "Boolean"},
"forward_client_headers_to_llm_api": {"type": "Boolean"},
"mcp_required_fields": {"type": "List"},
"cancel_on_disconnect": {"type": "Boolean"},
"disable_auto_add_proxy_admin_to_teams": {"type": "Boolean"},
}
allowed_args: Final = _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES
return_val: Final = []
@ -15471,7 +15551,7 @@ async def get_config_list(
if field_name in allowed_args:
## HANDLE TYPED DICT
typed_dict_type = allowed_args[field_name]["type"]
typed_dict_type = allowed_args[field_name]
if typed_dict_type == "PydanticModel":
if field_name == "pass_through_endpoints":
@ -15513,7 +15593,7 @@ async def get_config_list(
_response_obj = ConfigList(
field_name=field_name,
field_type=allowed_args[field_name]["type"],
field_type=allowed_args[field_name],
field_description=field_info.description or "",
field_value=_redact_general_setting_value(
field_name,
@ -15541,7 +15621,7 @@ async def get_config_list(
_response_obj = ConfigList(
field_name=field_name,
field_type=allowed_args[field_name]["type"],
field_type=allowed_args[field_name],
field_description=field_info.description or "",
field_value=_redact_general_setting_value(field_name, _field_value, is_full_admin),
stored_in_db=_stored_in_db,

View file

@ -220,7 +220,7 @@ async def get_agents(request: Request):
"url": get_custom_url(str(request.base_url), route=f"a2a/{agent.agent_id}"),
}
for agent in agents
if agent.agent_id in litellm.public_agent_groups
if not global_agent_registry.ids_for_agent(agent.agent_id).isdisjoint(litellm.public_agent_groups)
]

View file

@ -1439,6 +1439,7 @@ model LiteLLM_AutoRouterSession {
total_tokens BigInt @default(0)
spend Float @default(0)
saved_spend Float @default(0)
tier_turns Json @default("{}")
@@id([api_key, session_id, router_name])
@@index([last_turn_at], map: "idx_autorouter_session_last_turn")

View file

@ -156,6 +156,7 @@ async def reserve_budget_for_request(
proxy_logging_obj: ProxyLogging,
end_user_id: str | None = None,
end_user_object: Any | None = None,
apply_user_budget_to_team_keys: bool = False,
fail_closed_budget_enforcement: bool = False,
) -> dict | None:
if valid_token is None or not RouteChecks.is_llm_api_route(route=route):
@ -175,6 +176,7 @@ async def reserve_budget_for_request(
proxy_logging_obj=proxy_logging_obj,
end_user_id=end_user_id,
end_user_object=end_user_object,
apply_user_budget_to_team_keys=apply_user_budget_to_team_keys,
)
if not counters:
return None
@ -332,6 +334,7 @@ async def _get_budget_counters(
proxy_logging_obj: ProxyLogging,
end_user_id: str | None = None,
end_user_object: Any | None = None,
apply_user_budget_to_team_keys: bool = False,
) -> list[_BudgetCounter]:
counters: Final[list[_BudgetCounter]] = []
@ -380,8 +383,9 @@ async def _get_budget_counters(
)
)
is_team_key: Final = team_object is not None and team_object.team_id is not None
if (
(team_object is None or team_object.team_id is None)
(not is_team_key or apply_user_budget_to_team_keys)
and user_object is not None
and user_object.user_id is not None
and user_object.max_budget is not None

View file

@ -4,11 +4,11 @@ import json
import os
from collections import Counter
from collections.abc import Mapping
from typing import Any, Final
from typing import Any, Final, Protocol, TypeVar
from urllib.parse import urlparse
from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile
from pydantic import ConfigDict, ValidationError, create_model
from pydantic import ConfigDict, JsonValue, ValidationError, create_model
from pydantic.fields import FieldInfo
import litellm
@ -36,6 +36,73 @@ from litellm.types.proxy.management_endpoints.ui_sso import (
router: Final = APIRouter()
_DbRecordT: Final = TypeVar("_DbRecordT", covariant=True)
class _PrismaTableActions(Protocol[_DbRecordT]):
async def find_unique(self, where: Mapping[str, object]) -> _DbRecordT | None: ...
async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> _DbRecordT: ...
async def upsert(self, where: Mapping[str, object], data: Mapping[str, object]) -> _DbRecordT: ...
class _SsoSettingsMappingRow(Protocol):
@property
def sso_settings(self) -> Mapping[str, object] | None: ...
class _HasSsoSettingsMappingTable(Protocol):
@property
def table(self) -> _PrismaTableActions[_SsoSettingsMappingRow]: ...
def _sso_settings_mapping_db(repo: _HasSsoSettingsMappingTable) -> _PrismaTableActions[_SsoSettingsMappingRow]:
return repo.table
class _StoredSsoSettingsRow(Protocol):
@property
def sso_settings(self) -> object: ...
class _HasStoredSsoSettingsTable(Protocol):
@property
def table(self) -> _PrismaTableActions[_StoredSsoSettingsRow]: ...
def _stored_sso_settings_db(repo: _HasStoredSsoSettingsTable) -> _PrismaTableActions[_StoredSsoSettingsRow]:
return repo.table
class _UiSettingsRow(Protocol):
@property
def ui_settings(self) -> str | Mapping[str, JsonValue] | None: ...
class _HasUiSettingsTable(Protocol):
@property
def table(self) -> _PrismaTableActions[_UiSettingsRow]: ...
def _ui_settings_db(repo: _HasUiSettingsTable) -> _PrismaTableActions[_UiSettingsRow]:
return repo.table
class _ConfigParamRow(Protocol):
@property
def param_value(self) -> str | Mapping[str, object] | None: ...
class _HasConfigParamTable(Protocol):
@property
def table(self) -> _PrismaTableActions[_ConfigParamRow]: ...
def _config_param_db(repo: _HasConfigParamTable) -> _PrismaTableActions[_ConfigParamRow]:
return repo.table
# Maps each UIThemeConfig field to the env var the UI branding path reads it
# from. /update/ui_theme_settings writes both the stored ui_theme_config and
# these env vars, so /get/ui_theme_settings resolves the same env vars to
@ -54,7 +121,7 @@ def _is_public_http_url(value: str | None) -> bool:
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def _resolve_ui_theme_field(stored_values: Mapping[str, Any], field_name: str) -> str | None:
def _resolve_ui_theme_field(stored_values: Mapping[str, object], field_name: str) -> str | None:
"""Resolve one UI theme field to the value the branding path actually uses.
The stored ui_theme_config wins; a field absent or blank there falls back to
@ -263,7 +330,7 @@ _RUNTIME_GENERAL_SETTINGS_FLAGS: Final = [
# include generics like ``Optional[int]`` / ``List[str]`` that are not
# instances of ``type`` — so tightening this to ``type`` would reject
# valid inputs.
_EXTRA_UI_SETTINGS_FIELDS: Final[dict[str, tuple[Any, FieldInfo]]] = {}
_EXTRA_UI_SETTINGS_FIELDS: Final[dict[str, tuple[object, FieldInfo]]] = {}
# Settings OSS knows about as enterprise-gated. If a caller sends one of
# these keys and no extension package has registered it, the PATCH
@ -275,7 +342,7 @@ _ENTERPRISE_ONLY_UI_SETTINGS: Final[set[str]] = {"enable_projects_ui"}
_EFFECTIVE_UI_SETTINGS_CLASS: type[UISettings] | None = None
def register_extra_ui_setting(name: str, annotation: Any, field: FieldInfo) -> None:
def register_extra_ui_setting(name: str, annotation: object, field: FieldInfo) -> None:
"""Register an additional UI settings field contributed by an extension package.
``field`` must be a ``FieldInfo`` instance construct it directly
@ -470,7 +537,7 @@ async def delete_allowed_ip(
async def _get_settings_with_schema(
settings_key: str,
settings_class: Any,
settings_class: type[BaseModel],
config: dict,
) -> dict:
"""
@ -842,7 +909,9 @@ async def get_sso_settings():
# Resolve the effective SSO config: the stored row wins, else the process
# environment, else each field's default. Unlike the legacy read path this
# does not write os.environ; a GET has no business mutating the environment.
sso_db_record: Final = await SSOConfigRepository(prisma_client).table.find_unique(where={"id": "sso_config"})
sso_db_record: Final = await _sso_settings_mapping_db(SSOConfigRepository(prisma_client)).find_unique(
where={"id": "sso_config"}
)
sso_db_settings: Final = dict(sso_db_record.sso_settings) if sso_db_record and sso_db_record.sso_settings else None
resolved: Final = resolve_sso_config(sso_db_settings, os.environ)
@ -914,8 +983,10 @@ async def update_sso_settings(
# before-snapshot has the same shape as after_value, and rely on
# create_config_audit_log's secret-name redaction to mask the
# *_client_secret fields before the audit row is written.
existing_sso_record: Final = await SSOConfigRepository(prisma_client).table.find_unique(where={"id": "sso_config"})
before_sso_data: dict[str, Any] | None = None
existing_sso_record: Final = await _stored_sso_settings_db(SSOConfigRepository(prisma_client)).find_unique(
where={"id": "sso_config"}
)
before_sso_data: dict[str, JsonValue] | None = None
if existing_sso_record and existing_sso_record.sso_settings:
stored = existing_sso_record.sso_settings
if isinstance(stored, str):
@ -948,7 +1019,7 @@ async def update_sso_settings(
encrypted_sso_data: Final = proxy_config._encrypt_env_variables(environment_variables=sso_data)
# Save to dedicated SSO table
await SSOConfigRepository(prisma_client).table.upsert(
await _stored_sso_settings_db(SSOConfigRepository(prisma_client)).upsert(
where={"id": "sso_config"},
data={
"create": {
@ -974,7 +1045,7 @@ async def update_sso_settings(
# Remove SSO-related env vars from config.environment_variables
try:
env_var_entry: Final = await ConfigRepository(prisma_client).table.find_unique(
env_var_entry: Final = await _config_param_db(ConfigRepository(prisma_client)).find_unique(
where={"param_name": "environment_variables"}
)
@ -982,7 +1053,7 @@ async def update_sso_settings(
if env_var_entry is not None:
if env_var_entry.param_value is not None:
if isinstance(env_var_entry.param_value, str):
environment_variables = json.loads(env_var_entry.param_value)
environment_variables: Mapping[str, object] = json.loads(env_var_entry.param_value)
else:
environment_variables = dict(env_var_entry.param_value)
else:
@ -993,7 +1064,7 @@ async def update_sso_settings(
key: value for key, value in environment_variables.items() if key not in env_vars_to_remove
}
await ConfigRepository(prisma_client).table.update(
await _config_param_db(ConfigRepository(prisma_client)).update(
where={"param_name": "environment_variables"},
data={
"param_value": json.dumps(filtered_env_vars, default=str),
@ -1239,8 +1310,10 @@ async def get_ui_settings_cached() -> dict[str, Any]:
if prisma_client is None:
return {}
db_record: Final = await UISettingsRepository(prisma_client).table.find_unique(where={"id": "ui_settings"})
ui_settings: dict[str, Any] = {}
db_record: Final = await _ui_settings_db(UISettingsRepository(prisma_client)).find_unique(
where={"id": "ui_settings"}
)
ui_settings: dict[str, JsonValue] = {}
if db_record and db_record.ui_settings:
raw: Final = db_record.ui_settings
ui_settings = json.loads(raw) if isinstance(raw, str) else dict(raw)
@ -1272,9 +1345,11 @@ async def get_ui_settings():
detail={"error": "Database not connected. Please connect a database."},
)
ui_settings: dict[str, Any] = {}
ui_settings: Mapping[str, JsonValue] = {}
db_record: Final = await UISettingsRepository(prisma_client).table.find_unique(where={"id": "ui_settings"})
db_record: Final = await _ui_settings_db(UISettingsRepository(prisma_client)).find_unique(
where={"id": "ui_settings"}
)
if db_record and db_record.ui_settings:
ui_settings_json: Final = db_record.ui_settings
@ -1300,7 +1375,7 @@ async def get_ui_settings():
await user_api_key_cache.async_set_cache(key=UI_SETTINGS_CACHE_KEY, value=ui_settings, ttl=UI_SETTINGS_CACHE_TTL)
# Build config-like object for schema helper
config: Final[dict[str, Any]] = {"litellm_settings": {"ui_settings": ui_settings}}
config: Final[dict[str, object]] = {"litellm_settings": {"ui_settings": ui_settings}}
return await _get_settings_with_schema(
settings_key="ui_settings",
@ -1315,7 +1390,7 @@ async def get_ui_settings():
dependencies=[Depends(user_api_key_auth)],
)
async def update_ui_settings(
settings_body: dict[str, Any] = Body(...),
settings_body: dict[str, object] = Body(...),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
@ -1352,7 +1427,7 @@ async def update_ui_settings(
raise HTTPException(status_code=422, detail=e.errors())
# Only include fields the caller actually sent (not Pydantic defaults).
settings_dict: Final = settings.model_dump(exclude_unset=True)
settings_dict: Final[Mapping[str, JsonValue]] = settings.model_dump(exclude_unset=True)
# Reject enterprise-only settings up front so the caller gets a clear
# signal instead of a silent drop.
@ -1373,15 +1448,17 @@ async def update_ui_settings(
# Merge with existing persisted settings so a partial PATCH doesn't
# overwrite fields the caller didn't send.
existing: dict = {}
db_existing: Final = await UISettingsRepository(prisma_client).table.find_unique(where={"id": "ui_settings"})
existing: dict[str, JsonValue] = {}
db_existing: Final = await _ui_settings_db(UISettingsRepository(prisma_client)).find_unique(
where={"id": "ui_settings"}
)
if db_existing and db_existing.ui_settings:
raw: Final = db_existing.ui_settings
existing = json.loads(raw) if isinstance(raw, str) else dict(raw)
ui_settings: Final = {**existing, **incoming}
await UISettingsRepository(prisma_client).table.upsert(
await _ui_settings_db(UISettingsRepository(prisma_client)).upsert(
where={"id": "ui_settings"},
data={
"create": {

View file

@ -165,6 +165,7 @@ if TYPE_CHECKING:
from prisma.client import TransactionManager
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.models.team import LiteLLM_TeamTableCachedObj
from litellm.proxy.db.autorouter_session_rollup import AutoRouterTurnTransaction
from litellm.proxy.db.spend_log_tool_index import ToolUsageTransaction
@ -3000,6 +3001,13 @@ class PrismaClient:
] = [] # mutable-ok: drained queue, mirrors tool_usage_transactions
_autorouter_turn_transactions_lock = asyncio.Lock()
# How long a health probe failure waits for an in-flight planned engine
# replacement to settle before deciding whether to report itself. Generous
# against a replacement that takes well under a second, and far short of the
# reconnect budget an outage-hung `connect()` runs under, so a real outage
# is never waited out.
PLANNED_ENGINE_REPLACEMENT_SETTLE_SECONDS: ClassVar[float] = 5.0
def __init__(
self,
database_url: str,
@ -4970,6 +4978,101 @@ class PrismaClient:
else:
verbose_proxy_logger.debug("Prisma DB health watchdog observed non-DB error: %s", e)
def _probe_target_wrapper(self) -> PrismaWrapper:
"""The Prisma wrapper a `SELECT 1` health probe actually reaches.
`health_check()` issues `query_raw`, which `RoutingPrismaWrapper` sends
to the reader unless the reader is degraded. The writer's engine state
therefore says nothing about a probe that failed against the reader, so
the gate has to follow the same routing rule the probe did.
"""
if isinstance(self.db, RoutingPrismaWrapper):
return self.db.writer if self.db.reader_unavailable else self.db.reader
return self.db
async def _run_health_probe(self, wrapper: PrismaWrapper) -> object:
"""Issue the `SELECT 1` a health check is made of, against `wrapper`.
Takes the wrapper rather than re-reading `self.db`, because routing is
re-resolved on every attribute access: a reader that recovers between
the caller picking its target and the query going out would send the
probe to a different engine than the one whose generation the caller is
about to check, and attribute the failure to the wrong replacement.
"""
sql_query: Final = "SELECT 1"
response: Final = await wrapper.query_raw(sql_query)
return response
async def _probe_answers_now(self, wrapper: PrismaWrapper) -> bool:
try:
await self._run_health_probe(wrapper)
except Exception as probe_error: # noqa: BLE001 # any failure means the database is not answering
verbose_proxy_logger.debug("Prisma health_check() confirmation probe failed: %s", probe_error)
return False
return True
async def _planned_engine_replacement_absorbed(
self,
e: Exception,
wrapper: PrismaWrapper,
generation_before: int,
) -> bool:
"""True iff `e` is a connection-class probe failure that a completed
planned query-engine replacement explains.
Planned replacements (RDS IAM token refresh, guarded reconnect) kill the
running query engine and spawn a new one. A `SELECT 1` probe that races
that sub-second window fails with a transport error against the engine's
local HTTP port even though nothing is wrong with the database, and
reporting it drives a false-positive `db_exceptions` alert on every
replacement.
Two things must both hold, because neither is sufficient alone. The
engine generation must have moved, which says a replacement completed
rather than merely being attempted: reconnect attempts during a real
outage hold the same lock for tens of seconds, so gating on an in-flight
replacement would swallow most of an outage's alerts. And a fresh probe
must succeed, because `Prisma.connect()` polls the query engine's own
`/status` endpoint rather than round-tripping to the database, so a
future engine that binds before it validates its connection pool would
let the generation advance with the database still unreachable.
Waiting for an in-flight replacement to settle is what makes the
generation check meaningful, since the generation has not moved yet at
the instant the probe fails. The wait is generous against a replacement
that takes well under a second and short enough that an outage-hung
reconnect is not waited out; a replacement that has not settled by then
reports rather than stays silent.
"""
if not PrismaDBExceptionHandler.is_database_connection_error(e):
return False
await wrapper.wait_for_planned_engine_replacement(self.PLANNED_ENGINE_REPLACEMENT_SETTLE_SECONDS)
if wrapper.engine_generation == generation_before:
return False
return await self._probe_answers_now(wrapper)
async def _report_health_check_failure(
self,
e: Exception,
duration: float,
traceback_str: str,
wrapper: PrismaWrapper,
generation_before: int,
) -> None:
if await self._planned_engine_replacement_absorbed(e, wrapper, generation_before):
verbose_proxy_logger.info(
"Prisma health_check() connection error raced a planned query-engine replacement; "
"not reporting it as a DB exception: %s",
e,
)
return
await self.proxy_logging_obj.failure_handler(
original_exception=e,
duration=duration,
call_type="health_check",
traceback_str=traceback_str,
)
@backoff.on_exception(
backoff.expo,
Exception,
@ -4982,13 +5085,10 @@ class PrismaClient:
Health check endpoint for the prisma client
"""
start_time: Final = time.time()
probe_wrapper: Final = self._probe_target_wrapper()
generation_before: Final = probe_wrapper.engine_generation
try:
sql_query: Final = "SELECT 1"
# Execute the raw query
# The asterisk before `user_id_list` unpacks the list into separate arguments
response: Final = await self.db.query_raw(sql_query)
return response
return await self._run_health_probe(probe_wrapper)
except Exception as e:
import traceback
@ -4998,11 +5098,12 @@ class PrismaClient:
end_time: Final = time.time()
_duration: Final = end_time - start_time
asyncio.create_task(
self.proxy_logging_obj.failure_handler(
original_exception=e,
self._report_health_check_failure(
e=e,
duration=_duration,
call_type="health_check",
traceback_str=error_traceback,
wrapper=probe_wrapper,
generation_before=generation_before,
)
)
raise e
@ -6319,6 +6420,74 @@ def construct_database_url_from_env_vars() -> str | None:
return None
async def _get_validated_team_object(
user_api_key_dict: "UserAPIKeyAuth",
team_id: str,
prisma_client: "PrismaClient",
user_api_key_cache: "UserApiKeyCache",
proxy_logging_obj: "ProxyLogging",
) -> "LiteLLM_TeamTableCachedObj":
from litellm.proxy.auth.auth_checks import get_team_object
from litellm.proxy.management_endpoints.team_endpoints import validate_membership
team_object: Final = await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
await validate_membership(user_api_key_dict=user_api_key_dict, team_table=team_object)
return team_object
async def _get_team_object_for_access_groups(
team_id: str | None,
prisma_client: Optional["PrismaClient"],
user_api_key_cache: Optional["UserApiKeyCache"],
proxy_logging_obj: Optional["ProxyLogging"],
) -> Optional["LiteLLM_TeamTableCachedObj"]:
from litellm.proxy.auth.auth_checks import get_team_object
if team_id is None or prisma_client is None or user_api_key_cache is None or proxy_logging_obj is None:
return None
try:
return await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
except HTTPException:
verbose_proxy_logger.debug("Could not fetch team %s while listing models", team_id)
return None
async def _get_access_group_models(
user_api_key_dict: "UserAPIKeyAuth",
team_object: Optional["LiteLLM_TeamTableCachedObj"],
prisma_client: Optional["PrismaClient"],
user_api_key_cache: Optional["UserApiKeyCache"],
proxy_logging_obj: Optional["ProxyLogging"],
) -> tuple[str, ...]:
from litellm.proxy.auth.auth_checks import (
_get_models_from_access_groups,
get_authorized_resources_from_key_access_groups,
)
team_group_models: Final = await _get_models_from_access_groups(
access_group_ids=(team_object.access_group_ids or ()) if team_object is not None else (),
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
key_group_models: Final = await get_authorized_resources_from_key_access_groups(
valid_token=user_api_key_dict,
team_object=team_object,
resource_field="access_model_names",
)
return tuple(dict.fromkeys((*team_group_models, *key_group_models)))
async def get_available_models_for_user(
user_api_key_dict: "UserAPIKeyAuth",
llm_router: Optional["Router"],
@ -6350,13 +6519,11 @@ async def get_available_models_for_user(
Returns:
List of model names available to the user
"""
from litellm.proxy.auth.auth_checks import get_team_object
from litellm.proxy.auth.model_checks import (
get_complete_model_list,
get_key_models,
get_team_models,
)
from litellm.proxy.management_endpoints.team_endpoints import validate_membership
# Get proxy model list and access groups
if llm_router is None:
@ -6366,31 +6533,33 @@ async def get_available_models_for_user(
proxy_model_list = llm_router.get_model_names()
model_access_groups = llm_router.get_model_access_groups()
# Get key models
key_models = get_key_models(
user_api_key_dict=user_api_key_dict,
proxy_model_list=proxy_model_list,
model_access_groups=model_access_groups,
include_model_access_groups=include_model_access_groups,
)
# Get team models
team_models: list[str] = user_api_key_dict.team_models
# If specific team_id is provided, validate and get team models
if team_id and prisma_client and proxy_logging_obj and user_api_key_cache:
key_models = []
team_object: Final = await get_team_object(
requested_team_object: Final = (
await _get_validated_team_object(
user_api_key_dict=user_api_key_dict,
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
await validate_membership(user_api_key_dict=user_api_key_dict, team_table=team_object)
team_models = team_object.models
if team_id and prisma_client and proxy_logging_obj and user_api_key_cache
else None
)
team_models = get_team_models(
team_models=team_models,
key_models: Final[Sequence[str]] = (
()
if requested_team_object is not None
else get_key_models(
user_api_key_dict=user_api_key_dict,
proxy_model_list=proxy_model_list,
model_access_groups=model_access_groups,
include_model_access_groups=include_model_access_groups,
)
)
team_models: Final = get_team_models(
team_models=(
requested_team_object.models if requested_team_object is not None else user_api_key_dict.team_models
),
proxy_model_list=proxy_model_list,
model_access_groups=model_access_groups,
include_model_access_groups=include_model_access_groups,
@ -6398,10 +6567,31 @@ async def get_available_models_for_user(
effective_team_id: Final = team_id or user_api_key_dict.team_id
access_group_models: Final = (
await _get_access_group_models(
user_api_key_dict=user_api_key_dict,
team_object=requested_team_object
or await _get_team_object_for_access_groups(
team_id=effective_team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
),
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
if key_models or team_models
else ()
)
granted_key_models: Final = (*key_models, *access_group_models) if key_models else key_models
granted_team_models: Final = (*team_models, *access_group_models) if team_models else team_models
# Get complete model list
all_models: Final = get_complete_model_list(
key_models=key_models,
team_models=team_models,
key_models=granted_key_models,
team_models=granted_team_models,
proxy_model_list=proxy_model_list,
user_model=user_model,
infer_model_from_keys=general_settings.get("infer_model_from_keys", False),

View file

@ -10,10 +10,17 @@ All /vector_store management endpoints
import copy
import json
from typing import Any, Final
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Final, Protocol
from fastapi import APIRouter, Depends, HTTPException
if TYPE_CHECKING:
from prisma.models import LiteLLM_ManagedVectorStoresTable as _VectorStoreRow
from litellm.proxy.utils import PrismaClient
from litellm.router import Router
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.caching.in_memory_cache import InMemoryCache
@ -43,6 +50,25 @@ from litellm.vector_stores.vector_store_registry import VectorStoreRegistry
router: Final = APIRouter()
class _VectorStoreTableActions(Protocol):
async def find_unique(self, where: Mapping[str, str]) -> "_VectorStoreRow | None": ...
async def create(self, data: Mapping[str, object]) -> "_VectorStoreRow": ...
async def update(self, where: Mapping[str, str], data: Mapping[str, object]) -> "_VectorStoreRow": ...
async def delete(self, where: Mapping[str, str]) -> "_VectorStoreRow | None": ...
def _vector_store_table(prisma_client: "PrismaClient") -> _VectorStoreTableActions:
return ManagedVectorStoresRepository(prisma_client).table
def _row_to_vector_store(row: "_VectorStoreRow") -> LiteLLM_ManagedVectorStore:
return LiteLLM_ManagedVectorStore(**row.model_dump())
_LITELLM_PARAMS_MASKER: Final = SensitiveDataMasker()
@ -117,22 +143,20 @@ def _redact_sensitive_litellm_params(litellm_params: Any, _depth: int = 0) -> An
async def _fetch_and_authorize_vector_store(
vector_store_id: str,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: Any,
prisma_client: "PrismaClient",
) -> "LiteLLM_ManagedVectorStore":
"""
Look up a vector store by id and confirm the caller can access it.
Raises HTTPException(404) on miss and HTTPException(403) on access
denial.
"""
row: Final = await ManagedVectorStoresRepository(prisma_client).table.find_unique(
where={"vector_store_id": vector_store_id}
)
row: Final = await _vector_store_table(prisma_client).find_unique(where={"vector_store_id": vector_store_id})
if row is None:
raise HTTPException(
status_code=404,
detail=f"Vector store with ID {vector_store_id} not found",
)
typed: Final = LiteLLM_ManagedVectorStore(**row.model_dump())
typed: Final = _row_to_vector_store(row)
if not await _check_vector_store_access(typed, user_api_key_dict):
raise HTTPException(
status_code=403,
@ -141,7 +165,7 @@ async def _fetch_and_authorize_vector_store(
return typed
def _resolve_embedding_config_from_router(embedding_model: str, llm_router) -> dict[str, Any] | None:
def _resolve_embedding_config_from_router(embedding_model: str, llm_router) -> dict[str, object] | None:
"""
Resolve embedding config from router's config-defined models.
@ -177,7 +201,7 @@ def _resolve_embedding_config_from_router(embedding_model: str, llm_router) -> d
litellm_params = deployment.litellm_params
# Build embedding config from model params
embedding_config: dict[str, Any] = {}
embedding_config: dict[str, object] = {}
# Extract api_key
api_key = getattr(litellm_params, "api_key", None)
@ -217,7 +241,9 @@ def _resolve_embedding_config_from_router(embedding_model: str, llm_router) -> d
return None
async def _resolve_embedding_config_from_db(embedding_model: str, prisma_client) -> dict[str, Any] | None:
async def _resolve_embedding_config_from_db(
embedding_model: str, prisma_client: "PrismaClient"
) -> dict[str, object] | None:
"""
Resolve embedding config from database model configuration.
@ -307,7 +333,9 @@ async def _resolve_embedding_config_from_db(embedding_model: str, prisma_client)
return None
async def _resolve_embedding_config(embedding_model: str, prisma_client, llm_router=None) -> dict[str, Any] | None:
async def _resolve_embedding_config(
embedding_model: str, prisma_client: "PrismaClient | None", llm_router: "Router | None" = None
) -> dict[str, object] | None:
"""
Resolve embedding config from either router (config-defined) or database models.
@ -388,7 +416,7 @@ async def _check_vector_store_access(
async def create_vector_store_in_db(
vector_store_id: str,
custom_llm_provider: str,
prisma_client,
prisma_client: "PrismaClient | None",
vector_store_name: str | None = None,
vector_store_description: str | None = None,
vector_store_metadata: dict | None = None,
@ -417,7 +445,7 @@ async def create_vector_store_in_db(
raise HTTPException(status_code=500, detail="Database not connected")
# Check if vector store already exists
existing_vector_store: Final = await ManagedVectorStoresRepository(prisma_client).table.find_unique(
existing_vector_store: Final = await _vector_store_table(prisma_client).find_unique(
where={"vector_store_id": vector_store_id}
)
if existing_vector_store is not None:
@ -427,7 +455,7 @@ async def create_vector_store_in_db(
)
# Prepare data for database
data_to_create: Final[dict[str, Any]] = {
data_to_create: Final[dict[str, object]] = {
"vector_store_id": vector_store_id,
"custom_llm_provider": custom_llm_provider,
}
@ -463,9 +491,9 @@ async def create_vector_store_in_db(
data_to_create["litellm_params"] = safe_dumps({})
# Create in database
_new_vector_store: Final = await ManagedVectorStoresRepository(prisma_client).table.create(data=data_to_create)
_new_vector_store: Final = await _vector_store_table(prisma_client).create(data=data_to_create)
new_vector_store: Final[LiteLLM_ManagedVectorStore] = LiteLLM_ManagedVectorStore(**_new_vector_store.model_dump())
new_vector_store: Final[LiteLLM_ManagedVectorStore] = _row_to_vector_store(_new_vector_store)
# Add vector store to registry
if litellm.vector_store_registry is not None:
@ -682,12 +710,12 @@ async def delete_vector_store(
memory_vector_store_exists = False
vector_store_to_check = None
existing_vector_store: Final = await ManagedVectorStoresRepository(prisma_client).table.find_unique(
existing_vector_store: Final = await _vector_store_table(prisma_client).find_unique(
where={"vector_store_id": data.vector_store_id}
)
if existing_vector_store is not None:
db_vector_store_exists = True
vector_store_to_check = LiteLLM_ManagedVectorStore(**existing_vector_store.model_dump())
vector_store_to_check = _row_to_vector_store(existing_vector_store)
# Check in-memory registry
if litellm.vector_store_registry is not None:
@ -715,9 +743,7 @@ async def delete_vector_store(
# Delete from database if exists
if db_vector_store_exists:
await ManagedVectorStoresRepository(prisma_client).table.delete(
where={"vector_store_id": data.vector_store_id}
)
await _vector_store_table(prisma_client).delete(where={"vector_store_id": data.vector_store_id})
# Delete from in-memory registry if exists
if memory_vector_store_exists and litellm.vector_store_registry is not None:
@ -829,7 +855,7 @@ async def update_vector_store(
try:
update_data: Final = data.model_dump(exclude_unset=True)
vector_store_id: Final = update_data.pop("vector_store_id")
vector_store_id: Final[str] = update_data.pop("vector_store_id")
# Per-store access control: anyone authenticated who passes the
# premium-feature gate could otherwise update *any* vector store —
@ -857,12 +883,12 @@ async def update_vector_store(
update_data["litellm_params"] = safe_dumps(litellm_params_dict)
# Update in database
updated: Final = await ManagedVectorStoresRepository(prisma_client).table.update(
updated: Final = await _vector_store_table(prisma_client).update(
where={"vector_store_id": vector_store_id},
data=update_data,
)
updated_vs: Final = LiteLLM_ManagedVectorStore(**updated.model_dump())
updated_vs: Final = _row_to_vector_store(updated)
# Immediately update in-memory registry to keep it in sync
if litellm.vector_store_registry is not None:

View file

@ -30,10 +30,12 @@ if TYPE_CHECKING:
router: Final = APIRouter()
def _update_request_data_with_managed_file_id(
async def _update_request_data_with_managed_file_id(
data: dict,
file_id: str,
request: Request,
user_api_key_dict: UserAPIKeyAuth,
managed_files_obj: object | None,
llm_router: Optional["Router"] = None,
) -> tuple[dict, str | None]:
"""
@ -65,6 +67,16 @@ def _update_request_data_with_managed_file_id(
is_base64_encoded_unified_id,
parse_unified_id,
)
from litellm.proxy.openai_files_endpoints.common_utils import (
validate_managed_id_requirement,
)
await validate_managed_id_requirement(
resource_id=file_id,
resource_kind="file",
user_api_key_dict=user_api_key_dict,
managed_files_obj=managed_files_obj,
)
# First, check if this is a unified managed file ID (base64 encoded)
decoded_id: Final = is_base64_encoded_unified_id(file_id)
@ -509,8 +521,13 @@ async def vector_store_file_create(
# Handle managed file IDs if present in request body
original_managed_file_id = None
if "file_id" in data:
data, original_managed_file_id = _update_request_data_with_managed_file_id(
data=data, file_id=data["file_id"], request=request, llm_router=llm_router
data, original_managed_file_id = await _update_request_data_with_managed_file_id(
data=data,
file_id=data["file_id"],
request=request,
user_api_key_dict=user_api_key_dict,
managed_files_obj=proxy_logging_obj.get_proxy_hook("managed_files"),
llm_router=llm_router,
)
# Then handle managed vector store IDs
@ -707,8 +724,13 @@ async def vector_store_file_retrieve(
)
# Handle managed file IDs first
data, original_managed_file_id = _update_request_data_with_managed_file_id(
data=data, file_id=file_id, request=request, llm_router=llm_router
data, original_managed_file_id = await _update_request_data_with_managed_file_id(
data=data,
file_id=file_id,
request=request,
user_api_key_dict=user_api_key_dict,
managed_files_obj=proxy_logging_obj.get_proxy_hook("managed_files"),
llm_router=llm_router,
)
# Then handle managed vector store IDs
@ -809,8 +831,13 @@ async def vector_store_file_content(
)
# Handle managed file IDs first
data, original_managed_file_id = _update_request_data_with_managed_file_id(
data=data, file_id=file_id, request=request, llm_router=llm_router
data, original_managed_file_id = await _update_request_data_with_managed_file_id(
data=data,
file_id=file_id,
request=request,
user_api_key_dict=user_api_key_dict,
managed_files_obj=proxy_logging_obj.get_proxy_hook("managed_files"),
llm_router=llm_router,
)
# Then handle managed vector store IDs
@ -911,8 +938,13 @@ async def vector_store_file_update(
)
# Handle managed file IDs first
data, original_managed_file_id = _update_request_data_with_managed_file_id(
data=data, file_id=file_id, request=request, llm_router=llm_router
data, original_managed_file_id = await _update_request_data_with_managed_file_id(
data=data,
file_id=file_id,
request=request,
user_api_key_dict=user_api_key_dict,
managed_files_obj=proxy_logging_obj.get_proxy_hook("managed_files"),
llm_router=llm_router,
)
# Then handle managed vector store IDs
@ -1013,8 +1045,13 @@ async def vector_store_file_delete(
)
# Handle managed file IDs first
data, original_managed_file_id = _update_request_data_with_managed_file_id(
data=data, file_id=file_id, request=request, llm_router=llm_router
data, original_managed_file_id = await _update_request_data_with_managed_file_id(
data=data,
file_id=file_id,
request=request,
user_api_key_dict=user_api_key_dict,
managed_files_obj=proxy_logging_obj.get_proxy_hook("managed_files"),
llm_router=llm_router,
)
# Then handle managed vector store IDs

View file

@ -42,6 +42,10 @@ class AgentsRepository(PrismaTableRepository):
table_name = "litellm_agentstable"
class ObjectPermissionRepository(PrismaTableRepository):
table_name = "litellm_objectpermissiontable"
class GuardrailsRepository(PrismaTableRepository):
table_name = "litellm_guardrailstable"

View file

@ -4,8 +4,8 @@ Handles transforming from Responses API -> LiteLLM completion (Chat Completion
import json
import re
from collections.abc import Sequence
from typing import Any, Final, Literal, cast
from collections.abc import Iterator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, cast, runtime_checkable
from openai.types.chat.chat_completion_named_tool_choice_param import (
ChatCompletionNamedToolChoiceParam,
@ -16,6 +16,7 @@ from openai.types.chat.chat_completion_named_tool_choice_param import (
from openai.types.responses import ResponseFunctionToolCall
from openai.types.responses.response_create_params import ResponseInputParam
from openai.types.responses.tool_param import FunctionToolParam
from pydantic import TypeAdapter
from typing_extensions import TypedDict
from litellm._logging import verbose_logger
@ -78,9 +79,35 @@ from .custom_tools import (
unwrap_custom_tool_arguments,
)
if TYPE_CHECKING:
from openai.types.responses.response_apply_patch_tool_call import (
ResponseApplyPatchToolCall,
)
########### Initialize Classes used for Responses API ###########
TOOL_CALLS_CACHE: Final = InMemoryCache()
_ANY_KEY_DICT_ADAPTER: Final = TypeAdapter(dict[object, object])
_STR_KEY_DICT_ADAPTER: Final = TypeAdapter(dict[str, object])
_OBJECT_LIST_ADAPTER: Final = TypeAdapter(list[object])
_DICT_ITEMS_LIST_ADAPTER: Final = TypeAdapter(list[dict[object, object]])
_TEXT_ADAPTER: Final = TypeAdapter(str)
@runtime_checkable
class _SupportsIter(Protocol):
def __iter__(self) -> Iterator[object]: ...
@runtime_checkable
class _HasToolCalls(Protocol):
tool_calls: object
@runtime_checkable
class _HasId(Protocol):
id: object
class ChatCompletionSession(TypedDict, total=False):
messages: list[
@ -205,7 +232,7 @@ class LiteLLMCompletionResponsesConfig:
responses_api_request: ResponsesAPIOptionalRequestParams,
custom_llm_provider: str | None = None,
stream: bool | None = None,
extra_headers: dict[str, Any] | None = None,
extra_headers: Mapping[str, object] | None = None,
**kwargs,
) -> dict:
"""
@ -462,7 +489,9 @@ class LiteLLMCompletionResponsesConfig:
if not chat_completion_messages:
continue
deduped_in_place: list[Any] = []
deduped_in_place: list[
AllMessageValues | GenericChatCompletionMessage | ChatCompletionResponseMessage
] = []
for m in chat_completion_messages:
role = ""
if isinstance(m, dict):
@ -472,7 +501,7 @@ class LiteLLMCompletionResponsesConfig:
# Drop assistant tool_calls wrappers if we already have this call_id
if role == "assistant":
tool_calls: Any = (
tool_calls: object = (
m.get("tool_calls") if isinstance(m, dict) else getattr(m, "tool_calls", None)
)
call_id = ""
@ -534,7 +563,7 @@ class LiteLLMCompletionResponsesConfig:
call_id = ""
if role == "assistant":
tool_calls: Any = None
tool_calls: object = None
if isinstance(tool_call_message, dict):
tool_calls = tool_call_message.get("tool_calls")
else:
@ -578,7 +607,16 @@ class LiteLLMCompletionResponsesConfig:
return False
@staticmethod
def _find_previous_assistant_idx(messages: list[Any], current_idx: int) -> int | None:
def _find_previous_assistant_idx(
messages: Sequence[
AllMessageValues
| GenericChatCompletionMessage
| ChatCompletionResponseMessage
| ChatCompletionMessageToolCall
| Message
],
current_idx: int,
) -> int | None:
"""Find the index of the previous assistant message."""
for j in range(current_idx - 1, -1, -1):
if messages[j].get("role") == "assistant":
@ -586,7 +624,18 @@ class LiteLLMCompletionResponsesConfig:
return None
@staticmethod
def _recover_tool_call_id_from_assistant(assistant_message: Any, message: Any) -> str:
def _recover_tool_call_id_from_assistant(
assistant_message: AllMessageValues
| GenericChatCompletionMessage
| ChatCompletionResponseMessage
| ChatCompletionMessageToolCall
| Message,
message: AllMessageValues
| GenericChatCompletionMessage
| ChatCompletionResponseMessage
| ChatCompletionMessageToolCall
| Message,
) -> str:
"""Try to recover empty tool_call_id from assistant message's tool_calls."""
tool_calls_raw: Final = (
assistant_message.get("tool_calls")
@ -594,17 +643,23 @@ class LiteLLMCompletionResponsesConfig:
else getattr(assistant_message, "tool_calls", None)
)
if tool_calls_raw and isinstance(tool_calls_raw, list) and len(tool_calls_raw) > 0:
first_tool_call: Final = tool_calls_raw[0]
first_tool_call: Final = _OBJECT_LIST_ADAPTER.validate_python(tool_calls_raw)[0]
if isinstance(first_tool_call, dict):
tool_call_id_raw = first_tool_call.get("id", "")
tool_call_id_raw = _ANY_KEY_DICT_ADAPTER.validate_python(first_tool_call).get("id", "")
return str(tool_call_id_raw) if tool_call_id_raw is not None else ""
elif hasattr(first_tool_call, "id"):
tool_call_id_raw = getattr(first_tool_call, "id", None)
elif isinstance(first_tool_call, _HasId):
tool_call_id_raw = first_tool_call.id
return str(tool_call_id_raw) if tool_call_id_raw is not None else ""
return ""
@staticmethod
def _get_tool_calls_list(assistant_message: Any) -> list[Any]:
def _get_tool_calls_list(
assistant_message: AllMessageValues
| GenericChatCompletionMessage
| ChatCompletionResponseMessage
| ChatCompletionMessageToolCall
| Message,
) -> Sequence[object]:
"""Extract tool_calls as a list from assistant message."""
tool_calls_raw: Final = (
assistant_message.get("tool_calls")
@ -614,18 +669,18 @@ class LiteLLMCompletionResponsesConfig:
if tool_calls_raw is None:
return []
if isinstance(tool_calls_raw, list):
return tool_calls_raw
if hasattr(tool_calls_raw, "__iter__") and not isinstance(tool_calls_raw, (str, bytes)):
return _OBJECT_LIST_ADAPTER.validate_python(tool_calls_raw)
if isinstance(tool_calls_raw, _SupportsIter) and not isinstance(tool_calls_raw, (str, bytes)):
return list(tool_calls_raw)
return []
@staticmethod
def _check_tool_call_exists(tool_calls: list[Any], tool_call_id: str) -> bool:
def _check_tool_call_exists(tool_calls: Sequence[object], tool_call_id: str) -> bool:
"""Check if a tool_call with the given ID exists in the list."""
for tool_call in tool_calls:
tool_call_id_to_check: str | None = None
tool_call_id_to_check: object = None
if isinstance(tool_call, dict):
tool_call_id_to_check = tool_call.get("id")
tool_call_id_to_check = _ANY_KEY_DICT_ADAPTER.validate_python(tool_call).get("id")
elif hasattr(tool_call, "id"):
tool_call_id_to_check = getattr(tool_call, "id", None)
if tool_call_id_to_check == tool_call_id:
@ -633,12 +688,13 @@ class LiteLLMCompletionResponsesConfig:
return False
@staticmethod
def _reconstruct_tool_call_from_tools(tool_call_id: str, tools: list[Any]) -> dict[str, Any] | None:
def _reconstruct_tool_call_from_tools(tool_call_id: str, tools: Sequence[object]) -> dict[str, object] | None:
"""Reconstruct a minimal tool_call definition from tools list."""
for tool in tools:
if isinstance(tool, dict):
tool_function = tool.get("function") or {}
tool_name = tool_function.get("name") or tool.get("name") or ""
tool_map = _ANY_KEY_DICT_ADAPTER.validate_python(tool)
tool_function = _ANY_KEY_DICT_ADAPTER.validate_python(tool_map.get("function") or {})
tool_name = tool_function.get("name") or tool_map.get("name") or ""
if tool_name:
return {
"id": tool_call_id,
@ -651,7 +707,7 @@ class LiteLLMCompletionResponsesConfig:
return None
@staticmethod
def _get_mapping_or_attr_value(obj: Any, key: str, default: Any = None) -> Any:
def _get_mapping_or_attr_value(obj: object, key: str, default: object = None) -> object:
"""
Safely read a field from dict-like or attribute-based objects.
"""
@ -659,7 +715,7 @@ class LiteLLMCompletionResponsesConfig:
return default
if isinstance(obj, dict):
return obj.get(key, default)
return _ANY_KEY_DICT_ADAPTER.validate_python(obj).get(key, default)
getter: Final = getattr(obj, "get", None)
if callable(getter):
@ -672,13 +728,13 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def _create_tool_call_chunk(
tool_use_definition: dict[str, Any], tool_call_id: str, index: int
tool_use_definition: Mapping[object, object], tool_call_id: str, index: int
) -> ChatCompletionToolCallChunk:
"""Create a ChatCompletionToolCallChunk from tool_use_definition."""
function_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(tool_use_definition, "function")
function_name_raw: Final = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(function_raw, "name")
function_arguments_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(function_raw, "arguments")
function: Final[dict[str, Any]] = {
function: Final[dict[str, object]] = {
"name": function_name_raw or "",
"arguments": function_arguments_raw or "{}",
}
@ -697,7 +753,7 @@ class LiteLLMCompletionResponsesConfig:
)
@staticmethod
def _normalize_tool_use_definition(tool_use_definition: Any, tool_call_id: str) -> dict[str, Any] | None:
def _normalize_tool_use_definition(tool_use_definition: object, tool_call_id: str) -> dict[object, object] | None:
"""
Normalize cached tool_call definitions to a dict-like shape consumed by _create_tool_call_chunk.
"""
@ -705,7 +761,7 @@ class LiteLLMCompletionResponsesConfig:
return None
if isinstance(tool_use_definition, dict):
normalized_definition: dict[str, Any] = dict(tool_use_definition)
normalized_definition: dict[object, object] = _ANY_KEY_DICT_ADAPTER.validate_python(tool_use_definition)
else:
tool_use_id_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(tool_use_definition, "id")
tool_use_type_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(tool_use_definition, "type")
@ -738,7 +794,7 @@ class LiteLLMCompletionResponsesConfig:
return normalized_definition
@staticmethod
def _add_tool_call_to_assistant(assistant_message: Any, tool_call_chunk: ChatCompletionToolCallChunk) -> None:
def _add_tool_call_to_assistant(assistant_message: object, tool_call_chunk: ChatCompletionToolCallChunk) -> None:
"""Add a tool_call to an assistant message."""
if isinstance(assistant_message, dict):
prev_assistant_dict: Final = cast(dict[str, Any], assistant_message)
@ -747,7 +803,7 @@ class LiteLLMCompletionResponsesConfig:
tool_calls_list: Final = prev_assistant_dict["tool_calls"]
if isinstance(tool_calls_list, list):
tool_calls_list.append(tool_call_chunk)
elif hasattr(assistant_message, "tool_calls"):
elif isinstance(assistant_message, _HasToolCalls):
if assistant_message.tool_calls is None:
assistant_message.tool_calls = []
if isinstance(assistant_message.tool_calls, list):
@ -762,7 +818,7 @@ class LiteLLMCompletionResponsesConfig:
| ChatCompletionMessageToolCall
| Message
],
tools: list[Any] | None = None,
tools: Sequence[object] | None = None,
) -> list[
AllMessageValues
| GenericChatCompletionMessage
@ -851,7 +907,7 @@ class LiteLLMCompletionResponsesConfig:
tool_calls = LiteLLMCompletionResponsesConfig._get_tool_calls_list(prev_assistant)
if not LiteLLMCompletionResponsesConfig._check_tool_call_exists(tool_calls, tool_call_id):
_tool_use_definition = TOOL_CALLS_CACHE.get_cache(key=tool_call_id)
_tool_use_definition: object = TOOL_CALLS_CACHE.get_cache(key=tool_call_id)
if not _tool_use_definition and tools:
_tool_use_definition = LiteLLMCompletionResponsesConfig._reconstruct_tool_call_from_tools(
@ -908,7 +964,7 @@ class LiteLLMCompletionResponsesConfig:
function_call=input_item
)
else:
content: Final = input_item.get("content")
content: Final[object] = input_item.get("content")
# Handle None content: Responses API allows None content, but GenericChatCompletionMessage requires content
# Since guardrails skip None content anyway, we return empty list to exclude it from structured messages
if content is None:
@ -923,7 +979,7 @@ class LiteLLMCompletionResponsesConfig:
]
@staticmethod
def _is_input_item_tool_call_output(input_item: Any) -> bool:
def _is_input_item_tool_call_output(input_item: Mapping[str, object]) -> bool:
"""
Check if the input item is a tool call output
"""
@ -936,7 +992,7 @@ class LiteLLMCompletionResponsesConfig:
]
@staticmethod
def _is_input_item_function_call(input_item: Any) -> bool:
def _is_input_item_function_call(input_item: Mapping[str, object]) -> bool:
"""
Check if the input item is a function call or custom tool call.
Both need to be reconstructed as assistant tool_calls for Chat
@ -946,7 +1002,7 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def _transform_responses_api_tool_call_output_to_chat_completion_message(
tool_call_output: dict[str, Any],
tool_call_output: Mapping[str, object],
) -> list[AllMessageValues | GenericChatCompletionMessage | ChatCompletionResponseMessage]:
"""
ChatCompletionToolMessage is used to indicate the output from a tool call
@ -958,7 +1014,7 @@ class LiteLLMCompletionResponsesConfig:
return []
def _normalize_function_call_output_to_tool_content(
output: Any,
output: object,
) -> Any:
"""
Normalize Responses API function_call_output.output into a shape that downstream
@ -981,7 +1037,7 @@ class LiteLLMCompletionResponsesConfig:
# Some adapters represent tool output as a list of "input_*" parts
if isinstance(output, list):
normalized_blocks: Final[list[dict[str, Any]]] = []
normalized_blocks: Final[list[dict[str, object]]] = []
text_acc: Final[list[str]] = []
for part in output:
if not isinstance(part, dict):
@ -1082,7 +1138,7 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def _transform_responses_api_function_call_to_chat_completion_message(
function_call: dict[str, Any],
function_call: Mapping[str, str],
) -> list[AllMessageValues | GenericChatCompletionMessage | ChatCompletionResponseMessage]:
"""
Transform a Responses API function_call into a Chat Completion message with tool calls
@ -1127,7 +1183,7 @@ class LiteLLMCompletionResponsesConfig:
return [chat_completion_response_message]
@staticmethod
def _resolve_file_id(item: dict[str, Any]) -> str | None:
def _resolve_file_id(item: Mapping[str, object]) -> object:
"""
Return the effective file_id for a Responses API input_file item.
Explicit file_id takes precedence; file_url is used as fallback so
@ -1136,7 +1192,7 @@ class LiteLLMCompletionResponsesConfig:
return item.get("file_id") or item.get("file_url") or None
@staticmethod
def _transform_input_file_item_to_file_item(item: dict[str, Any]) -> dict[str, Any]:
def _transform_input_file_item_to_file_item(item: Mapping[str, object]) -> dict[str, object]:
"""
Transform a Responses API input_file item to a Chat Completion file item
@ -1146,21 +1202,21 @@ class LiteLLMCompletionResponsesConfig:
Returns:
Dictionary with transformed file structure for Chat Completion
"""
file_dict: Final[dict[str, Any]] = {}
file_dict: Final[dict[str, object]] = {}
file_id: Final = LiteLLMCompletionResponsesConfig._resolve_file_id(item)
if file_id:
file_dict["file_id"] = file_id
if item.get("file_data"):
file_dict["file_data"] = item["file_data"]
new_item: Final[dict[str, Any]] = {"type": "file", "file": file_dict}
new_item: Final[dict[str, object]] = {"type": "file", "file": file_dict}
if "cache_control" in item:
new_item["cache_control"] = item["cache_control"]
return new_item
@staticmethod
def _transform_input_image_item_to_image_item(
item: dict[str, Any],
item: Mapping[str, str],
) -> ChatCompletionImageObject:
"""
Transform a Responses API input_image item to a Chat Completion image item
@ -1173,8 +1229,8 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def _transform_responses_api_content_to_chat_completion_content(
content: Any,
) -> str | list[str | dict[str, Any]]:
content: object,
) -> str | list[str | dict[str, object]]:
"""
Transform a Responses API content into a Chat Completion content
@ -1188,7 +1244,7 @@ class LiteLLMCompletionResponsesConfig:
elif isinstance(content, str):
return content
elif isinstance(content, list):
content_list: Final[list[str | dict[str, Any]]] = []
content_list: Final[list[str | dict[str, object]]] = []
for item in content:
if isinstance(item, str):
content_list.append(item)
@ -1198,8 +1254,8 @@ class LiteLLMCompletionResponsesConfig:
LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item(item)
)
elif item.get("type") == "input_image":
image_block = dict(
LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item(item)
image_block = _STR_KEY_DICT_ADAPTER.validate_python(
dict(LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item(item))
)
if "cache_control" in item:
image_block["cache_control"] = item["cache_control"]
@ -1209,7 +1265,7 @@ class LiteLLMCompletionResponsesConfig:
text_value = item.get("text")
if text_value is None:
continue
content_block: dict[str, Any] = {
content_block: dict[str, object] = {
"type": LiteLLMCompletionResponsesConfig._get_chat_completion_request_content_type(
item.get("type") or "text"
),
@ -1299,7 +1355,7 @@ class LiteLLMCompletionResponsesConfig:
parameters = dict(typed_tool.get("parameters", {}) or {})
if not parameters or "type" not in parameters:
parameters["type"] = "object"
chat_completion_tool: dict[str, Any] = {
chat_completion_tool: dict[str, object] = {
"type": "function",
"function": {
"name": typed_tool.get("name") or "",
@ -1340,7 +1396,7 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def transform_chat_completion_tool_params_to_responses_api_tools(
chat_completion_tools: list[ChatCompletionToolParam | OpenAIMcpServerTool] | None,
) -> list[dict[str, Any]]:
) -> list[dict[str, object]]:
"""
Transform Chat Completion tool params (e.g. from guardrail output) back to
Responses API request tool format. Inverse of
@ -1348,7 +1404,7 @@ class LiteLLMCompletionResponsesConfig:
"""
if chat_completion_tools is None or not chat_completion_tools:
return []
result: Final[list[dict[str, Any]]] = []
result: Final[list[dict[str, object]]] = []
for tool in chat_completion_tools:
if not isinstance(tool, dict):
result.append(tool)
@ -1358,7 +1414,7 @@ class LiteLLMCompletionResponsesConfig:
parameters = dict(fn.get("parameters", {}) or {})
if not parameters or "type" not in parameters:
parameters["type"] = "object"
responses_tool: dict[str, Any] = {
responses_tool: dict[str, object] = {
"type": "function",
"name": fn.get("name") or "",
"description": fn.get("description") or "",
@ -1510,7 +1566,7 @@ class LiteLLMCompletionResponsesConfig:
def convert_response_function_tool_call_to_chat_completion_tool_call(
tool_call_item: Any,
index: int = 0,
) -> dict[str, Any]:
) -> dict[str, object]:
"""
Convert ResponseFunctionToolCall to ChatCompletionToolCallChunk format.
@ -1536,7 +1592,7 @@ class LiteLLMCompletionResponsesConfig:
else (dict(provider_fields) if hasattr(provider_fields, "__dict__") else {})
)
function_dict: Final[dict[str, Any]] = {
function_dict: Final[dict[str, object]] = {
"name": tool_call_item.name,
"arguments": tool_call_item.arguments,
}
@ -1544,7 +1600,7 @@ class LiteLLMCompletionResponsesConfig:
if provider_specific_fields:
function_dict["provider_specific_fields"] = provider_specific_fields
tool_call_dict: Final[dict[str, Any]] = {
tool_call_dict: Final[dict[str, object]] = {
"id": LiteLLMCompletionResponsesConfig._tool_call_id_from_responses_item(
getattr(tool_call_item, "id", None),
getattr(tool_call_item, "call_id", None),
@ -1561,9 +1617,9 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def convert_apply_patch_tool_call_to_chat_completion_tool_call(
tool_call_item: Any,
tool_call_item: "ResponseApplyPatchToolCall",
index: int = 0,
) -> dict[str, Any]:
) -> dict[str, object]:
"""
Convert ResponseApplyPatchToolCall to ChatCompletionToolCallChunk format.
@ -1581,7 +1637,7 @@ class LiteLLMCompletionResponsesConfig:
import json
operation_dict: Final = tool_call_item.operation.model_dump()
tool_call_dict: Final[dict[str, Any]] = {
tool_call_dict: Final[dict[str, object]] = {
"id": tool_call_item.call_id,
"function": {
"name": "apply_patch",
@ -1795,9 +1851,11 @@ class LiteLLMCompletionResponsesConfig:
if not images:
return image_generation_items
for idx, image_item in enumerate(images):
for idx, image_item in enumerate(_DICT_ITEMS_LIST_ADAPTER.validate_python(images)):
# Extract base64 from data URL
image_url = image_item.get("image_url", {}).get("url", "")
image_url = _TEXT_ADAPTER.validate_python(
_ANY_KEY_DICT_ADAPTER.validate_python(image_item.get("image_url", {})).get("url", "")
)
base64_data = LiteLLMCompletionResponsesConfig._extract_base64_from_data_url(image_url)
if base64_data:
@ -2048,8 +2106,8 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def _transform_text_format_to_response_format(
text_param: dict[str, Any] | Any,
) -> dict[str, Any] | None:
text_param: object,
) -> dict[str, object] | None:
"""
Transform Responses API text.format parameter to Chat Completion response_format parameter.

View file

@ -1064,6 +1064,7 @@ def responses(
extra_headers=extra_headers,
extra_body=extra_body,
timeout=timeout if timeout is not None else request_timeout,
allowed_openai_params=allowed_openai_params,
**kwargs,
)

View file

@ -5,7 +5,7 @@ import json
import time
import traceback
import uuid
from collections.abc import Mapping
from collections.abc import Awaitable, Callable, Mapping
from datetime import datetime
from functools import lru_cache
from types import MappingProxyType
@ -33,6 +33,7 @@ from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfi
from litellm.responses.utils import ResponseAPILoggingUtils, ResponsesAPIRequestUtils
from litellm.types.llms.openai import (
PART_UNION_TYPES,
ResponseAPIUsage,
ResponsesAPIResponse,
ResponsesAPIStreamEvents,
ResponsesAPIStreamingResponse,
@ -112,7 +113,7 @@ _ERROR_CODE_HTTP_STATUS: Final[Mapping[str, int]] = MappingProxyType(
def _error_event_fields(error_obj: object) -> tuple[str, str | None, str | None]:
if isinstance(error_obj, dict):
if _is_json_object(error_obj):
raw_message = error_obj.get("message")
raw_type = error_obj.get("type")
raw_code = error_obj.get("code")
@ -243,7 +244,9 @@ class BaseResponsesAPIStreamingIterator:
# Using getattr(..., "response") alone is unsafe with Mocks: they synthesize a
# truthy child Mock for any attribute, which breaks tests and is wrong on stream.
if "response" in parsed_chunk:
response_object: Final = getattr(openai_responses_api_chunk, "response", None)
response_object: Final[ResponsesAPIResponse | None] = getattr(
openai_responses_api_chunk, "response", None
)
if response_object is not None:
response: Final = ResponsesAPIRequestUtils._update_responses_api_response_id_with_model_id(
responses_api_response=response_object,
@ -279,7 +282,9 @@ class BaseResponsesAPIStreamingIterator:
model_id=_stream_model_id,
)
elif _event_type == ResponsesAPIStreamEvents.CONTENT_PART_DONE:
_part: Final = getattr(openai_responses_api_chunk, "part", None)
_part: Final[PART_UNION_TYPES | Mapping[str, object] | None] = getattr(
openai_responses_api_chunk, "part", None
)
if _part is not None:
if isinstance(_part, dict):
ResponsesAPIRequestUtils._encode_container_ids_in_annotations(
@ -302,7 +307,7 @@ class BaseResponsesAPIStreamingIterator:
openai_types.ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED,
openai_types.ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE,
):
item: Final = getattr(openai_responses_api_chunk, "item", None)
item: Final[object | None] = getattr(openai_responses_api_chunk, "item", None)
if item:
encrypted_content: Final = getattr(item, "encrypted_content", None)
if encrypted_content and isinstance(encrypted_content, str):
@ -324,9 +329,11 @@ class BaseResponsesAPIStreamingIterator:
self.completed_response = openai_responses_api_chunk
# Add cost to usage object if include_cost_in_streaming_usage is True
if litellm.include_cost_in_streaming_usage and self.logging_obj is not None:
response_obj: Final[Any | None] = getattr(openai_responses_api_chunk, "response", None)
response_obj: Final[ResponsesAPIResponse | None] = getattr(
openai_responses_api_chunk, "response", None
)
if response_obj:
usage_obj: Final[Any | None] = getattr(response_obj, "usage", None)
usage_obj: Final[ResponseAPIUsage | None] = getattr(response_obj, "usage", None)
if usage_obj is not None:
try:
cost: float | None = self.logging_obj._response_cost_calculator(result=response_obj)
@ -414,7 +421,9 @@ class BaseResponsesAPIStreamingIterator:
async_failure_handler / failure_handler so logging integrations correctly
record the call as failed.
"""
response_obj: Final = getattr(self.completed_response, "response", None) if self.completed_response else None
response_obj: Final[ResponsesAPIResponse | None] = (
getattr(self.completed_response, "response", None) if self.completed_response else None
)
error_info: Final = getattr(response_obj, "error", None) if response_obj else None
error_message, error_type, error_code = _error_event_fields(error_info)
self._record_failed_response_usage(response_obj)
@ -429,7 +438,7 @@ class BaseResponsesAPIStreamingIterator:
def _record_failed_response_usage(self, response_obj: ResponsesAPIResponse | None) -> None:
if response_obj is None or self.logging_obj is None:
return
usage_obj: Final = getattr(response_obj, "usage", None)
usage_obj: Final[ResponseAPIUsage | None] = getattr(response_obj, "usage", None)
if usage_obj is None:
return
try:
@ -506,7 +515,7 @@ class BaseResponsesAPIStreamingIterator:
return
request_kwargs = getattr(caching_handler, "request_kwargs", None)
if not isinstance(request_kwargs, dict) or request_kwargs.get("stream") is not True:
if not _is_json_object(request_kwargs) or request_kwargs.get("stream") is not True:
return
request_kwargs = request_kwargs.copy()
preset_cache_key = getattr(caching_handler, "preset_cache_key", None)
@ -606,7 +615,7 @@ class BaseResponsesAPIStreamingIterator:
if self.completed_response is None:
return
request_payload: Final[dict[str, Any]] = {}
request_payload: Final[dict[str, object]] = {}
if isinstance(self.request_data, dict):
request_payload.update(self.request_data)
try:
@ -695,11 +704,15 @@ class BaseResponsesAPIStreamingIterator:
pass
async def call_post_streaming_hooks_for_testing(iterator, chunk):
async def call_post_streaming_hooks_for_testing(
iterator: object, chunk: ResponsesAPIStreamingResponse
) -> ResponsesAPIStreamingResponse:
"""
Module-level helper for tests to ensure hooks can be invoked even if the iterator is wrapped.
"""
hook_fn: Final = getattr(iterator, "_call_post_streaming_deployment_hook", None)
hook_fn: Final[Callable[[ResponsesAPIStreamingResponse], Awaitable[ResponsesAPIStreamingResponse]] | None] = (
getattr(iterator, "_call_post_streaming_deployment_hook", None)
)
if hook_fn is None:
return chunk
return await hook_fn(chunk)
@ -1019,7 +1032,7 @@ class CachedResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator):
def _dump_response_object(obj: Any) -> dict[str, Any]:
if hasattr(obj, "model_dump"):
return obj.model_dump()
if isinstance(obj, dict):
if _is_json_object(obj):
return obj
return {}
@ -1684,7 +1697,7 @@ class ResponsesWebSocketStreaming:
return response_str
try:
evt_obj: Final = json.loads(response_str)
evt_obj: Final[Mapping[str, object]] = json.loads(response_str)
except (json.JSONDecodeError, TypeError):
return response_str
@ -1925,7 +1938,7 @@ class ManagedResponsesWebSocketHandler:
@staticmethod
def _extract_output_messages(
completed_event: dict[str, Any],
completed_event: dict[str, object],
) -> list[dict[str, object]]:
"""
Convert the output items in a ``response.completed`` event into
@ -2065,7 +2078,7 @@ class ManagedResponsesWebSocketHandler:
Flat: {"type": "response.create", "input": [...], "model": "...", ...}
"""
nested: Final = msg_obj.get("response")
response_params: Final[dict[str, Any]] = (
response_params: Final[dict[str, object]] = (
nested if _is_json_object(nested) and nested else {k: v for k, v in msg_obj.items() if k != "type"}
)
return {
@ -2076,7 +2089,7 @@ class ManagedResponsesWebSocketHandler:
def _apply_history(
self,
call_kwargs: dict[str, Any],
call_kwargs: dict[str, object],
previous_response_id: str | None,
current_messages: list[dict[str, object]],
prior_history: list[dict[str, object]],
@ -2129,7 +2142,7 @@ class ManagedResponsesWebSocketHandler:
return False
return event_provider == self._connection_provider
def _inject_credentials(self, call_kwargs: dict[str, Any], model: str | None = None) -> None:
def _inject_credentials(self, call_kwargs: dict[str, object], model: str | None = None) -> None:
"""Inject connection-level credentials and metadata into call_kwargs."""
if self.api_key is not None:
call_kwargs["api_key"] = self.api_key

View file

@ -46,6 +46,7 @@ from litellm.constants import (
DEFAULT_HEALTH_CHECK_INTERVAL,
DEFAULT_HEALTH_CHECK_STALENESS_MULTIPLIER,
DEFAULT_MAX_LRU_CACHE_SIZE,
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
)
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.asyncify import run_async_function
@ -109,6 +110,7 @@ from litellm.router_utils.common_utils import (
filter_team_based_models,
filter_web_search_deployments,
resolve_model_group_alias,
truncate_fallback_error_detail,
)
from litellm.router_utils.cooldown_cache import CooldownCache
from litellm.router_utils.cooldown_handlers import (
@ -134,6 +136,7 @@ from litellm.router_utils.handle_error import (
from litellm.router_utils.health_state_cache import DeploymentHealthCache
from litellm.router_utils.pre_call_checks.deployment_affinity_check import (
DeploymentAffinityCheck,
warn_on_unknown_model_group_affinity_flags,
)
from litellm.router_utils.pre_call_checks.io_token_rate_limit_check import (
build_io_token_rate_limit_headers,
@ -342,6 +345,12 @@ def _replay_live_router_model_cost() -> None:
set_live_deployment_replay(_replay_live_router_model_cost)
# Kwargs that log_retry must not copy into a retry breadcrumb. The breadcrumbs reach spend
# logs and logging callbacks, and these carry either the request payload or router-internal
# walk state rather than anything that identifies the failed attempt.
RETRY_BREADCRUMB_EXCLUDED_KWARGS: Final = frozenset(("messages", "original_function", "attempted_targets"))
class Router:
model_names: set = set()
cache_responses: bool | None = False
@ -596,6 +605,10 @@ class Router:
# ``litellm.proxy.auth.auth_checks._is_model_cost_zero``.
self._zero_cost_cache: dict[str, bool] = {}
self.deployment_affinity_ttl_seconds = deployment_affinity_ttl_seconds
self.model_group_affinity_config = model_group_affinity_config
warn_on_unknown_model_group_affinity_flags(model_group_affinity_config)
if model_list is not None:
# set_model_list will build indices automatically
self.set_model_list(model_list)
@ -737,7 +750,6 @@ class Router:
litellm.failure_callback = [self.deployment_callback_on_failure]
self.routing_strategy_args = routing_strategy_args
self.provider_budget_config = provider_budget_config
self.deployment_affinity_ttl_seconds = deployment_affinity_ttl_seconds
self.router_budget_logger: RouterBudgetLimiting | None = None
if RouterBudgetLimiting.should_init_router_budget_limiter(
model_list=model_list, provider_budget_config=self.provider_budget_config
@ -759,7 +771,6 @@ class Router:
)
self.model_group_retry_policy: dict[str, RetryPolicy] | None = model_group_retry_policy
self.model_group_affinity_config: dict[str, list[str]] | None = model_group_affinity_config
self.allowed_fails_policy: AllowedFailsPolicy | None = None
if allowed_fails_policy is not None:
@ -782,21 +793,8 @@ class Router:
# If model_group_affinity_config is set but no global affinity checks were
# enabled, we still need the DeploymentAffinityCheck callback (with global
# flags all False) so per-group config can activate affinity per model group.
if self.model_group_affinity_config and not any(
isinstance(cb, DeploymentAffinityCheck) for cb in (self.optional_callbacks or [])
):
if self.optional_callbacks is None:
self.optional_callbacks = []
affinity_callback: Final = DeploymentAffinityCheck(
cache=self.cache,
ttl_seconds=self.deployment_affinity_ttl_seconds,
enable_user_key_affinity=False,
enable_responses_api_affinity=False,
enable_session_id_affinity=False,
model_group_affinity_config=self.model_group_affinity_config,
)
self.optional_callbacks.append(affinity_callback)
litellm.logging_callback_manager.add_litellm_callback(affinity_callback)
if self.model_group_affinity_config:
self._ensure_deployment_affinity_callback()
if self.alerting_config is not None:
self._initialize_alerting()
@ -1655,6 +1653,28 @@ class Router:
_move_before_deployment_affinity(self.optional_callbacks, ec_callback)
_move_before_deployment_affinity(litellm.callbacks, ec_callback)
def _ensure_deployment_affinity_callback(self) -> None:
"""Register the DeploymentAffinityCheck callback (global flags all False) if absent.
Needed when nothing enabled a global affinity flag but affinity can still
activate per request: per-group `model_group_affinity_config` entries, or the
session-affinity marker a complexity router stamps at pre-routing time.
"""
if any(isinstance(cb, DeploymentAffinityCheck) for cb in (self.optional_callbacks or [])):
return
if self.optional_callbacks is None:
self.optional_callbacks = []
affinity_callback: Final = DeploymentAffinityCheck(
cache=self.cache,
ttl_seconds=self.deployment_affinity_ttl_seconds,
enable_user_key_affinity=False,
enable_responses_api_affinity=False,
enable_session_id_affinity=False,
model_group_affinity_config=self.model_group_affinity_config,
)
self.optional_callbacks.append(affinity_callback)
litellm.logging_callback_manager.add_litellm_callback(affinity_callback)
def add_optional_pre_call_checks(self, optional_pre_call_checks: OptionalPreCallChecks | None):
if optional_pre_call_checks is None:
return
@ -6361,17 +6381,16 @@ class Router:
return response
except Exception as new_exception:
parent_otel_span: Final = _get_parent_otel_span_from_kwargs(kwargs)
fallback_failure_exception_str = redact_string(str(new_exception))
fallback_failure_exception_str = truncate_fallback_error_detail(redact_string(str(new_exception)))
cooldown_info: Final = await _async_get_cooldown_deployments_with_debug_info(
litellm_router_instance=self,
parent_otel_span=parent_otel_span,
)
verbose_router_logger.error(
"litellm.router.py::async_function_with_fallbacks() - "
"Error occurred while trying to do fallbacks - %s\n%s\n"
"Error occurred while trying to do fallbacks - %s\n"
"Debug Information:\nCooldown Deployments=%s",
fallback_failure_exception_str,
redact_string(traceback.format_exc()),
cooldown_info,
)
@ -7162,7 +7181,7 @@ class Router:
k,
v,
) in kwargs.items(): # log everything in kwargs except the old previous_models value - prevent nesting
if k not in [_metadata_var, "messages", "original_function"]:
if k != _metadata_var and k not in RETRY_BREADCRUMB_EXCLUDED_KWARGS:
previous_model[k] = v
elif k == _metadata_var and isinstance(v, dict):
previous_model[_metadata_var] = {}
@ -7677,6 +7696,8 @@ class Router:
strategy=complexity_router,
strategy_label="Complexity-router",
)
if complexity_router._uses_deployment_pin:
self._ensure_deployment_affinity_callback()
def _is_adaptive_router_deployment(self, litellm_params: LiteLLM_Params) -> bool:
"""True when this deployment opts in via the `auto_router/adaptive_router` model prefix."""
@ -11184,6 +11205,9 @@ class Router:
router_strategy: Final = self._select_pre_routing_strategy(model=model, request_kwargs=request_kwargs)
if router_strategy is None:
self._record_routing_decision(request_kwargs=request_kwargs, routing_decision=None)
self._stamp_or_clear_metadata_key(
request_kwargs=request_kwargs, key=SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY, value=None
)
return None
pre_routing_hook_response: Final = await router_strategy.async_pre_routing_hook(
@ -11197,6 +11221,11 @@ class Router:
request_kwargs=request_kwargs,
routing_decision=(pre_routing_hook_response.routing_decision if pre_routing_hook_response else None),
)
self._stamp_or_clear_metadata_key(
request_kwargs=request_kwargs,
key=SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
value=(pre_routing_hook_response.session_affinity_ttl_seconds if pre_routing_hook_response else None),
)
# `model` (the alias, e.g. "smart-router") is never the deployment actually
# called - apply the alias's own litellm_params (besides `model` itself,
@ -11228,21 +11257,40 @@ class Router:
to the deployment that actually served the request. Every attempt therefore
writes or clears, never just writes.
"""
if routing_decision is None:
Router._stamp_or_clear_metadata_key(
request_kwargs=request_kwargs,
key="routing_decision",
value=(
None
if routing_decision is None
else Router._redact_prompt_text_if_needed(
request_kwargs=request_kwargs, routing_decision=routing_decision
)
),
)
@staticmethod
def _stamp_or_clear_metadata_key(request_kwargs: dict, key: str, value: object | None) -> None:
"""Write a proxy-internal metadata key for THIS routing attempt, or clear it.
Fallbacks and retries re-enter the pre-routing hook with the same
`request_kwargs`, so every attempt must write or clear, never just write;
a value left behind by an earlier attempt would be attributed to this one.
`get_or_create_metadata_bucket` is the single owner of "which dict holds
proxy-internal metadata": it picks `litellm_metadata` when present (so the
value never lands in the `metadata` dict that routes like /v1/messages
forward to the provider) and replaces a non-dict value rather than silently
skipping the write. Clearing pops from BOTH buckets so a request whose
bucket resolution changed between attempts cannot resurrect a stale value.
"""
if value is None:
for bucket in (request_kwargs.get("metadata"), request_kwargs.get("litellm_metadata")):
if isinstance(bucket, dict):
bucket.pop("routing_decision", None)
bucket.pop(key, None)
return
# `get_or_create_metadata_bucket` is the single owner of "which dict holds
# proxy-internal metadata": it picks `litellm_metadata` when present (so the
# decision never lands in the `metadata` dict that routes like /v1/messages
# forward to the provider) and replaces a non-dict value rather than silently
# skipping the write.
_, metadata_bucket = get_or_create_metadata_bucket(request_kwargs)
metadata_bucket["routing_decision"] = Router._redact_prompt_text_if_needed(
request_kwargs=request_kwargs, routing_decision=routing_decision
)
metadata_bucket[key] = value
@staticmethod
def _redact_prompt_text_if_needed(

View file

@ -1651,6 +1651,28 @@ class ComplexityRouter(CustomLogger):
caller_scope: Final = self._get_user_api_key_hash_from_request_kwargs(request_kwargs) or "unscoped"
return f"complexity_router_session_affinity:v1:{self.model_name}:{caller_scope}:{session_id}"
@property
def _uses_tier_pin(self) -> bool:
return bool(self.config.session_affinity and not self.config.plugins)
@property
def _uses_deployment_pin(self) -> bool:
"""session_affinity implies the deployment pin: a session frozen onto one model
group but load-balanced across its deployments would still go cache-cold, which
is the exact failure both flags exist to prevent."""
return bool((self.config.deployment_affinity or self.config.session_affinity) and not self.config.plugins)
def _with_session_deployment_affinity(
self, response: PreRoutingHookResponse | None
) -> PreRoutingHookResponse | None:
if response is None or not self._uses_deployment_pin:
return response
return response.model_copy(
update={ # mutable-ok: model_copy types update as a plain dict
"session_affinity_ttl_seconds": self.config.session_affinity_ttl_seconds
}
)
async def async_pre_routing_hook(
self,
model: str,
@ -1685,7 +1707,7 @@ class ComplexityRouter(CustomLogger):
resolved_messages: Final = self._resolve_messages(messages, request_kwargs)
conversation_continuing: Final = _conversation_is_continuing(resolved_messages)
use_session_affinity: Final = self.config.session_affinity and not self.config.plugins
use_session_affinity: Final = self._uses_tier_pin
session_id: Final = self._get_session_id_from_request_kwargs(request_kwargs) if use_session_affinity else None
cache_key = self._get_session_affinity_cache_key(session_id, request_kwargs) if session_id is not None else None
@ -1724,16 +1746,19 @@ class ComplexityRouter(CustomLogger):
"ComplexityRouter: routing decision cause=%s, routed_model=%s", cause, routed_model
)
has_original_messages: Final = messages is not None and len(messages) > 0
return PreRoutingHookResponse(
model=routed_model,
messages=messages if has_original_messages else None,
routing_decision=self._build_routing_decision(
routed_model=routed_model,
cause=cause,
escalation_keyword=pin_escalation_keyword,
escalated=escalated,
conversation_continuing=conversation_continuing,
),
return self._with_session_deployment_affinity(
PreRoutingHookResponse(
model=routed_model,
messages=messages if has_original_messages else None,
routing_decision=self._build_routing_decision(
routed_model=routed_model,
cause=cause,
tier=self._tier_for_model(routed_model),
escalation_keyword=pin_escalation_keyword,
escalated=escalated,
conversation_continuing=conversation_continuing,
),
)
)
response: Final = await self._classify_and_route(
@ -1751,7 +1776,7 @@ class ComplexityRouter(CustomLogger):
value=response.model,
ttl=self.config.session_affinity_ttl_seconds,
)
return response
return self._with_session_deployment_affinity(response)
async def _classify_and_route(
self,
@ -1797,7 +1822,8 @@ class ComplexityRouter(CustomLogger):
if user_message is None:
verbose_router_logger.debug("ComplexityRouter: No user message found, routing to default model")
if not self.config.plugins and self.config.default_model:
default_model_first: Final = not self.config.plugins and self.config.default_model
if default_model_first:
# No plugins configured: preserve the pre-existing default_model-first
# priority exactly (changing it would be a silent behavior change for
# every non-plugin user, not just a security fix).
@ -1809,12 +1835,14 @@ class ComplexityRouter(CustomLogger):
routed_model = await self._pick_model_for_tier(
ComplexityTier.MEDIUM, messages, resolved_messages, request_kwargs
)
fallback_tier: Final = None if default_model_first else ComplexityTier.MEDIUM
return PreRoutingHookResponse(
model=routed_model,
messages=messages if has_original_messages else None,
routing_decision=self._build_routing_decision(
routed_model=routed_model,
cause="default_fallback",
tier=fallback_tier,
conversation_continuing=conversation_continuing,
),
)

View file

@ -508,13 +508,39 @@ class ComplexityRouterConfig(BaseModel):
"session's first turn and reuse it for every later turn, skipping re-classification. "
"Off by default so every turn is classified on its own merits and routed to the cheapest "
"adequate tier. Set True to keep a multi-turn session on one model, which preserves "
"provider prompt caches and avoids cross-model conversation-history errors."
"provider prompt caches and avoids cross-model conversation-history errors. Always "
"implies the deployment pin regardless of deployment_affinity: the session sticks to "
"one deployment of the pinned model, since freezing the model while re-shuffling its "
"deployments would still go cache-cold."
),
)
deployment_affinity: bool = Field(
default=True,
description=(
"When True and a session_id is resolvable on the request, pin the deployment chosen "
"inside each routed model group and reuse it whenever the session returns to that "
"group, without pinning which group the session routes to. Independent of "
"session_affinity, which pins the model group instead (and always carries this "
"deployment pin with it): with session_affinity off, "
"every turn is still classified on its own merits while a session that escalates to a "
"stronger tier and comes back still lands on the deployment it used before, which is "
"what keeps a provider prompt cache warm. Pins are held per model group, so switching "
"tiers does not disturb the pin left behind in the previous group. On by default "
"because re-shuffling a conversation across deployments of the same model discards "
"that cache for no benefit; set False to keep every turn load-balanced across the "
"group, which is what a deployment set with tight per-deployment rate limits wants. "
"Inert when no session_id is resolvable, since there is nothing to key a pin on, and "
"suppressed when plugins are configured, for the same reason session_affinity is."
),
)
session_affinity_ttl_seconds: int = Field(
default=3600,
gt=0,
description="TTL for the session affinity pin; refreshed on every cache hit",
description=(
"TTL for the session affinity pin; refreshed on every cache hit. Bounds both the "
"session_affinity model pin and the deployment_affinity deployment pin, so it measures "
"idle time for the session's routing decisions rather than total session length"
),
)
plugins: list[RoutingPlugin] | None = Field(

View file

@ -7,6 +7,7 @@ if TYPE_CHECKING:
from litellm.types.llms.openai import OpenAIFileObject
from litellm._logging import verbose_logger
from litellm.constants import ROUTER_FALLBACK_ERROR_DETAIL_MAX_CHARS
from litellm.exceptions import BadRequestError
from litellm.types.router import CredentialLiteLLMParams
@ -43,6 +44,22 @@ def resolve_model_group_alias(model_group_alias: object, model: str) -> str | No
return target
def truncate_fallback_error_detail(detail: str) -> str:
"""
Bound a fallback failure detail before it is logged or appended to an exception message.
Each level of the fallback walk records the failure of the level below it, so an
untruncated detail carries every nested failure with it and grows superlinearly with
the number of attempted model groups. One deterministic pre-network failure walked
through a small fallback graph is enough to turn that into hundreds of megabytes of
output on the event-loop thread, which starves the process that produced it.
"""
if len(detail) <= ROUTER_FALLBACK_ERROR_DETAIL_MAX_CHARS:
return detail
dropped: Final = len(detail) - ROUTER_FALLBACK_ERROR_DETAIL_MAX_CHARS
return f"{detail[:ROUTER_FALLBACK_ERROR_DETAIL_MAX_CHARS]}... [truncated {dropped} characters]"
def get_litellm_params_sensitive_credential_hash(litellm_params: dict) -> str:
"""
Hash of the credential params, used for mapping the file id to the right model

View file

@ -1,3 +1,6 @@
import hashlib
import json
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any, Final
@ -19,6 +22,52 @@ else:
LitellmRouter = Any
def fallback_attempt_key(fallback_target: object) -> str | None:
"""
Identity of one fallback attempt, so the same attempt is never made twice per request.
A bare model group name and a `{"model": name}` entry describe the same attempt. An
entry carrying anything else describes a different one and keeps its own identity: a
client-side fallback list overrides request params such as `messages`, and the router
re-targets the group that just failed by attaching `_target_order` or
`_excluded_deployment_ids` to select a different set of deployments inside it. The
payload is hashed rather than kept, so a large `messages` override does not make the
request hold a second copy of itself.
Returns None for a shape with no usable identity, which is never skipped.
"""
if isinstance(fallback_target, str):
return fallback_target
if not isinstance(fallback_target, dict):
return None
model: Final = fallback_target.get("model")
if tuple(fallback_target) == ("model",) and isinstance(model, str):
return model
serialized: Final = json.dumps(fallback_target, sort_keys=True, default=str)
return hashlib.sha256(serialized.encode()).hexdigest()
@dataclass(slots=True)
class AttemptedFallbackTargets:
"""
The fallback attempts a single request has already made.
One instance is created on the first fallback hop and shared by reference for the rest
of the walk, so an attempt made in one branch is not repeated in a sibling branch.
Without it the walk enumerates paths rather than attempts: a fallback graph containing
a cycle retries one deterministic failure once per path through the cycle, and a
client-side fallback list is re-walked at every level of the recursion.
"""
keys: frozenset[str] = frozenset()
def __contains__(self, key: str) -> bool:
return key in self.keys
def record(self, key: str) -> None:
self.keys = self.keys | frozenset((key,))
def _check_stripped_model_group(model_group: str, fallback_key: str) -> bool:
"""
Handles wildcard routing scenario
@ -106,7 +155,14 @@ async def run_async_fallback(
fallback_model_group: List[str] of fallback model groups. example: ["gpt-4", "gpt-3.5-turbo"]
original_model_group: The original model group. example: "gpt-3.5-turbo"
original_exception: The original exception.
**kwargs: Keyword arguments.
**kwargs: Keyword arguments. `attempted_targets` carries the fallback attempts
already made for this request, created on the first hop and shared by reference
for the rest of the walk. A target already in it is skipped, so neither a
fallback graph that loops back on itself nor a client-side fallback list
re-walked at each level can repeat an attempt that has already failed. Identity
comes from `fallback_attempt_key`, so an entry that overrides request params or
re-targets the failed group with a different deployment selection stays distinct
from a bare name.
Returns:
The response from the successful fallback model group.
@ -120,10 +176,27 @@ async def run_async_fallback(
error_from_fallbacks = original_exception
fallback_errors = (get_fallback_error_info(original_exception),)
# Read out of kwargs and narrowed here rather than declared as a parameter: every caller
# reaches this function by spreading a loosely-typed kwargs dict, so a declared parameter
# would carry an annotation that no call site can actually be checked against.
carried_targets: Final = kwargs.get("attempted_targets")
attempted: Final = (
carried_targets if isinstance(carried_targets, AttemptedFallbackTargets) else AttemptedFallbackTargets()
)
attempted.record(original_model_group)
for mg in fallback_model_group:
if mg == original_model_group:
continue
attempt_key = fallback_attempt_key(mg)
if attempt_key is not None:
if attempt_key in attempted:
verbose_router_logger.info(
"Skipping fallback to model_group = %s, already attempted for this request",
mask_sensitive_structure(mg),
)
continue
attempted.record(attempt_key)
try:
# LOGGING
kwargs = litellm_router.log_retry(kwargs=kwargs, e=original_exception)
@ -138,6 +211,7 @@ async def run_async_fallback(
fallback_depth = fallback_depth + 1
kwargs["fallback_depth"] = fallback_depth
kwargs["max_fallbacks"] = max_fallbacks
kwargs["attempted_targets"] = attempted
if include_fallback_errors:
kwargs["include_fallback_errors"] = include_fallback_errors
response = await litellm_router.async_function_with_fallbacks(*args, **kwargs)

View file

@ -13,12 +13,15 @@ where routing to a consistent deployment is still beneficial.
"""
import hashlib
import json
from collections.abc import Mapping, Sequence
from typing import Any, Final, cast
from typing_extensions import TypedDict
from litellm._logging import verbose_router_logger
from litellm.caching.dual_cache import DualCache
from litellm.constants import SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY
from litellm.integrations.custom_logger import CustomLogger, Span
from litellm.responses.utils import ResponsesAPIRequestUtils
from litellm.types.llms.openai import AllMessageValues
@ -29,6 +32,47 @@ class DeploymentAffinityCacheValue(TypedDict):
model_id: str
VALID_MODEL_GROUP_AFFINITY_FLAGS: Final = frozenset(
{
"deployment_affinity",
"responses_api_deployment_check",
"session_affinity",
"encrypted_content_affinity",
}
)
def warn_on_unknown_model_group_affinity_flags(model_group_affinity_config: Mapping[str, Sequence[str]] | None) -> None:
"""`model_group_affinity_config` is one Router-level config consumed by two callbacks:
DeploymentAffinityCheck acts on three of the flags and EncryptedContentAffinityCheck
on the fourth, so typo detection lives here at the schema, not inside either consumer.
"""
if model_group_affinity_config is None:
return
for group, flags in model_group_affinity_config.items():
unknown = set(flags) - VALID_MODEL_GROUP_AFFINITY_FLAGS
if unknown:
verbose_router_logger.warning(
"model_group_affinity_config: unknown flag(s) %s for model group '%s'; will be ignored. Valid flags: %s",
unknown,
group,
VALID_MODEL_GROUP_AFFINITY_FLAGS,
)
_CLAIM_PIN_SCRIPT: Final = """
local current = redis.call('GET', KEYS[1])
if current == false then
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
return ARGV[1]
end
if current == ARGV[1] then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
return current
"""
class DeploymentAffinityCheck(CustomLogger):
"""
Router deployment affinity callback.
@ -38,14 +82,6 @@ class DeploymentAffinityCheck(CustomLogger):
"""
CACHE_KEY_PREFIX = "deployment_affinity:v1"
VALID_FLAGS = frozenset(
{
"deployment_affinity",
"responses_api_deployment_check",
"session_affinity",
"encrypted_content_affinity",
}
)
def __init__(
self,
@ -63,15 +99,6 @@ class DeploymentAffinityCheck(CustomLogger):
self.enable_responses_api_affinity = enable_responses_api_affinity
self.enable_session_id_affinity = enable_session_id_affinity
self.model_group_affinity_config: dict[str, list[str]] = model_group_affinity_config or {}
for group, flags in self.model_group_affinity_config.items():
unknown = set(flags) - self.VALID_FLAGS
if unknown:
verbose_router_logger.warning(
"DeploymentAffinityCheck: unknown flag(s) %s for model group '%s'; will be ignored. Valid flags: %s",
unknown,
group,
self.VALID_FLAGS,
)
def _get_effective_flags(self, model_group: str) -> tuple[bool, bool, bool]:
"""
@ -218,8 +245,13 @@ class DeploymentAffinityCheck(CustomLogger):
return f"{cls.CACHE_KEY_PREFIX}:{model_group}:{hashed_user_key}"
@classmethod
def get_session_affinity_cache_key(cls, model_group: str, session_id: str) -> str:
return f"{cls.CACHE_KEY_PREFIX}:session:{model_group}:{session_id}"
def get_session_affinity_cache_key(cls, model_group: str, session_id: str, user_key: str | None) -> str:
"""Session pins are scoped by the caller's hashed API key so two callers reusing
the same client-supplied session_id cannot read or steer each other's pin.
`"unscoped"` covers direct Router usage with no authenticated caller, matching
the complexity router's own session pin key."""
hashed_user_key: Final = cls._hash_user_key(user_key) if user_key is not None else "unscoped"
return f"{cls.CACHE_KEY_PREFIX}:session:{model_group}:{hashed_user_key}:{session_id}"
@staticmethod
def _get_user_key_from_metadata_dict(metadata: dict) -> str | None:
@ -278,6 +310,97 @@ class DeploymentAffinityCheck(CustomLogger):
return session_id
return None
@staticmethod
def _get_marker_session_affinity_ttl(request_kwargs: dict) -> int | None:
"""TTL from the session-affinity marker the Router stamps at pre-routing time
when an auto-router routed this request with session_affinity enabled.
Marker presence enables session pinning for this request only; anything that
is not a positive int is treated as absent."""
for metadata in DeploymentAffinityCheck._iter_metadata_dicts(request_kwargs):
ttl = metadata.get(SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY)
if isinstance(ttl, int) and not isinstance(ttl, bool) and ttl > 0:
return ttl
return None
@staticmethod
def _pinned_model_id(stored: object) -> str | None:
"""Deployment id held by a stored pin, for both the dict shape this writes and the
bare string older writers left behind. None when the value is neither."""
if isinstance(stored, dict):
model_id: Final = stored.get("model_id")
return str(model_id) if model_id is not None else None
if isinstance(stored, str):
return stored
return None
def _set_local_pin(self, cache_key: str, value: object, ttl_seconds: int) -> None:
"""The one owner of authoritative local pin writes: a plain set keeps a live
key's original expiry (`allow_ttl_override`), so the entry is replaced to make
the TTL real. Every local pin write goes through here so the redis-winner sync
and the pod-local claim can never disagree about expiry again."""
self.cache.in_memory_cache.delete_cache(cache_key)
self.cache.in_memory_cache.set_cache(cache_key, value, ttl=ttl_seconds)
async def _claim_pin(self, cache_key: str, pin_value: DeploymentAffinityCacheValue, ttl_seconds: int) -> str | None:
"""First-writer-wins pin write: store `pin_value` only when the key is absent and
return the deployment id the key holds afterwards, so a caller learns whether it won
by comparing against its own id, and None when the stored value is one no reader can
interpret. Concurrent claimers converge on the
first write instead of the last. Re-claiming with the stored value refreshes its
TTL, the same keepalive the complexity router's model pin documents: an active
session must not lose its pin mid-conversation just because it outlives the
original write, so `session_affinity_ttl_seconds` bounds idle time, not total
session length. On Redis one Lua script does the get-or-set-or-refresh
atomically (same registration seam the rate limiters use) and the in-memory
tier is synchronized to the winner; without Redis, and whenever Redis is
unreachable, the pod-local check-and-set below stands in and is atomic because it
runs synchronously on the event loop. Degrading to a pod-local claim rather than
propagating the fault is what keeps same-pod stickiness through a Redis blip: the
caller only logs this result, so an escaping error would leave the session with no
pin at all and reshuffle every turn for the outage, which is worse than losing
cross-pod agreement. The redis tier is
resolved per call because the proxy attaches it after Router construction
(`Router._update_redis_cache`); the compiled script is cached per event loop
underneath the registration seam.
"""
redis_cache: Final = self.cache.redis_cache
if redis_cache is not None:
try:
claim_script: Final = redis_cache.async_register_script(_CLAIM_PIN_SCRIPT)
raw: Final = await claim_script(keys=(cache_key,), args=(json.dumps(pin_value), int(ttl_seconds)))
decoded: Final = raw.decode("utf-8") if isinstance(raw, bytes) else raw
if not isinstance(decoded, str):
return pin_value["model_id"]
try:
winner: object = json.loads(decoded)
except json.JSONDecodeError:
winner = decoded
self._set_local_pin(cache_key=cache_key, value=winner, ttl_seconds=ttl_seconds)
return self._pinned_model_id(winner)
except Exception as e: # noqa: BLE001 # any Redis/Lua failure degrades to the pod-local claim, never unpins
verbose_router_logger.debug(
"DeploymentAffinityCheck: redis pin claim failed, falling back to pod-local claim. error=%s", e
)
return self._claim_pin_in_memory(cache_key=cache_key, pin_value=pin_value, ttl_seconds=ttl_seconds)
def _claim_pin_in_memory(
self, cache_key: str, pin_value: DeploymentAffinityCacheValue, ttl_seconds: int
) -> str | None:
"""Pod-local half of the claim, used when no Redis tier is attached and as the
fallback when the Redis claim fails. Mirrors the Lua script exactly, including
the keepalive: re-claiming with the stored value slides the idle window through
`_set_local_pin`. Both branches stay synchronous, hence atomic on the event
loop."""
existing: Final = self.cache.in_memory_cache.get_cache(cache_key)
if existing is not None:
existing_model_id: Final = self._pinned_model_id(existing)
if existing_model_id == pin_value["model_id"]:
self._set_local_pin(cache_key=cache_key, value=pin_value, ttl_seconds=ttl_seconds)
return existing_model_id
self._set_local_pin(cache_key=cache_key, value=pin_value, ttl_seconds=ttl_seconds)
return pin_value["model_id"]
@staticmethod
def _find_deployment_by_model_id(healthy_deployments: list[dict], model_id: str) -> dict | None:
for deployment in healthy_deployments:
@ -334,12 +457,21 @@ class DeploymentAffinityCheck(CustomLogger):
if stable_model_map_key is None:
return typed_healthy_deployments
session_affinity_active: Final = (
enable_session_id or self._get_marker_session_affinity_ttl(request_kwargs=request_kwargs) is not None
)
user_key: Final = (
self._get_user_key_from_request_kwargs(request_kwargs=request_kwargs)
if (session_affinity_active or enable_user_key)
else None
)
# 2) Session-id -> deployment affinity
if enable_session_id:
if session_affinity_active:
session_id: Final = self._get_session_id_from_request_kwargs(request_kwargs=request_kwargs)
if session_id is not None:
session_cache_key: Final = self.get_session_affinity_cache_key(
model_group=stable_model_map_key, session_id=session_id
model_group=stable_model_map_key, session_id=session_id, user_key=user_key
)
session_cache_result: Final = await self.cache.async_get_cache(key=session_cache_key)
@ -371,7 +503,6 @@ class DeploymentAffinityCheck(CustomLogger):
if not enable_user_key:
return typed_healthy_deployments
user_key: Final = self._get_user_key_from_request_kwargs(request_kwargs=request_kwargs)
if user_key is None:
return typed_healthy_deployments
@ -438,18 +569,22 @@ class DeploymentAffinityCheck(CustomLogger):
enable_session_id,
) = self._get_effective_flags(deployment_model_name)
if not enable_user_key and not enable_session_id:
marker_session_ttl: Final = self._get_marker_session_affinity_ttl(request_kwargs=kwargs)
session_affinity_active: Final = enable_session_id or marker_session_ttl is not None
if not enable_user_key and not session_affinity_active:
return None
user_key = None
if enable_user_key:
user_key = self._get_user_key_from_request_kwargs(request_kwargs=kwargs)
user_key: Final = (
self._get_user_key_from_request_kwargs(request_kwargs=kwargs)
if (enable_user_key or session_affinity_active)
else None
)
session_id: Final = (
self._get_session_id_from_request_kwargs(request_kwargs=kwargs) if session_affinity_active else None
)
session_id = None
if enable_session_id:
session_id = self._get_session_id_from_request_kwargs(request_kwargs=kwargs)
if user_key is None and session_id is None:
if not ((enable_user_key and user_key is not None) or session_id is not None):
return None
model_info = kwargs.get("model_info")
@ -473,22 +608,31 @@ class DeploymentAffinityCheck(CustomLogger):
verbose_router_logger.warning("DeploymentAffinityCheck: model_id missing; skipping affinity cache update.")
return None
if user_key is not None:
pin_value: Final = DeploymentAffinityCacheValue(model_id=str(model_id))
if enable_user_key and user_key is not None:
try:
cache_key: Final = self.get_affinity_cache_key(model_group=deployment_model_name, user_key=user_key)
await self.cache.async_set_cache(
cache_key,
DeploymentAffinityCacheValue(model_id=str(model_id)),
ttl=self.ttl_seconds,
)
verbose_router_logger.debug(
"DeploymentAffinityCheck: set affinity mapping model_map_key=%s deployment=%s ttl=%s user_key=%s",
deployment_model_name,
model_id,
self.ttl_seconds,
self._shorten_for_logs(user_key),
claimed_user_pin: Final = await self._claim_pin(
cache_key=cache_key,
pin_value=pin_value,
ttl_seconds=self.ttl_seconds,
)
if claimed_user_pin == pin_value["model_id"]:
verbose_router_logger.debug(
"DeploymentAffinityCheck: set affinity mapping model_map_key=%s deployment=%s ttl=%s user_key=%s",
deployment_model_name,
model_id,
self.ttl_seconds,
self._shorten_for_logs(user_key),
)
else:
verbose_router_logger.debug(
"DeploymentAffinityCheck: affinity pin already claimed model_map_key=%s existing=%s ours=%s",
deployment_model_name,
claimed_user_pin,
model_id,
)
except Exception as e:
# Non-blocking: affinity is a best-effort optimization.
verbose_router_logger.debug(
@ -500,21 +644,31 @@ class DeploymentAffinityCheck(CustomLogger):
# Also persist Session-ID affinity if enabled and session-id is provided
if session_id is not None:
try:
session_affinity_ttl: Final = marker_session_ttl if marker_session_ttl is not None else self.ttl_seconds
session_cache_key: Final = self.get_session_affinity_cache_key(
model_group=deployment_model_name, session_id=session_id
model_group=deployment_model_name, session_id=session_id, user_key=user_key
)
await self.cache.async_set_cache(
session_cache_key,
DeploymentAffinityCacheValue(model_id=str(model_id)),
ttl=self.ttl_seconds,
)
verbose_router_logger.debug(
"DeploymentAffinityCheck: set session affinity mapping model_map_key=%s deployment=%s ttl=%s session_id=%s",
deployment_model_name,
model_id,
self.ttl_seconds,
session_id,
claimed_session_pin: Final = await self._claim_pin(
cache_key=session_cache_key,
pin_value=pin_value,
ttl_seconds=session_affinity_ttl,
)
if claimed_session_pin == pin_value["model_id"]:
verbose_router_logger.debug(
"DeploymentAffinityCheck: set session affinity mapping model_map_key=%s deployment=%s ttl=%s session_id=%s",
deployment_model_name,
model_id,
session_affinity_ttl,
session_id,
)
else:
verbose_router_logger.debug(
"DeploymentAffinityCheck: session pin already claimed model_map_key=%s existing=%s ours=%s session_id=%s",
deployment_model_name,
claimed_session_pin,
model_id,
session_id,
)
except Exception as e:
verbose_router_logger.debug(
"DeploymentAffinityCheck: failed to set session affinity cache. model_map_key=%s error=%s",

View file

@ -5,6 +5,7 @@ from typing import Any, Final, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from typing_extensions import Required, TypedDict
from litellm.constants import BEDROCK_APPLY_GUARDRAIL_CHUNK_BUDGET_CHARS
from litellm.types.proxy.guardrails.guardrail_hooks.akto import (
AktoConfigModel,
)
@ -525,6 +526,15 @@ class BedrockGuardrailConfigModel(BaseModel):
description="InvokeGuardrailChecks: block when any sensitiveInformation confidenceScore "
">= this value (scores are in [0,1]). Set to null to make PII detection detect-only.",
)
chunk_budget_chars: int = Field(
default=BEDROCK_APPLY_GUARDRAIL_CHUNK_BUDGET_CHARS,
gt=0,
description="ApplyGuardrail: batch size, in characters, used to re-send content after AWS "
"has rejected a request as too large. Requests AWS accepts are always sent in a single "
"call, so this has no effect until a rejection happens. Defaults to 25,000; a batch AWS "
"still rejects is bisected automatically, so this value only trades round trips against "
"batch size and cannot fail a request on its own.",
)
class LakeraV2GuardrailConfigModel(BaseModel):

View file

@ -2,7 +2,28 @@
Type definitions for WebSearch Interception integration.
"""
from typing import TypedDict
from typing import Literal, TypedDict
from pydantic import BaseModel
class AnthropicSearchQuery(BaseModel):
"""``input`` of an Anthropic ``server_tool_use`` block for a web search."""
query: str
class AnthropicServerToolUseBlock(BaseModel):
"""
The ``server_tool_use`` block that must accompany a ``web_search_tool_result``.
Anthropic requires the pair, with a ``srvtoolu_``-prefixed id shared by both.
"""
type: Literal["server_tool_use"] = "server_tool_use"
id: str
name: Literal["web_search"] = "web_search"
input: AnthropicSearchQuery
class WebSearchInterceptionConfig(TypedDict, total=False):

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