mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
chore: merge litellm_internal_staging into litellm_fix_priority_tpm_enforcement
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
commit
53cc5eb8f7
1341 changed files with 24058 additions and 12180 deletions
1
.github/workflows/test-linting.yml
vendored
1
.github/workflows/test-linting.yml
vendored
|
|
@ -104,6 +104,7 @@ jobs:
|
|||
- name: Check basedpyright budget (delta vs base)
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: |
|
||||
(uv run --no-sync basedpyright --outputjson || true) | uv run --no-sync python scripts/type_check_gate.py --base "$BASE_SHA"
|
||||
|
||||
|
|
|
|||
10
.github/workflows/test-litellm-ui-lint.yml
vendored
10
.github/workflows/test-litellm-ui-lint.yml
vendored
|
|
@ -29,7 +29,15 @@ jobs:
|
|||
id: changed
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
# base.sha is the base branch tip from when the PR was opened, while
|
||||
# actions/checkout leaves HEAD on a merge of the PR into the *current*
|
||||
# base tip. "$BASE_SHA"...HEAD therefore spans every base-branch commit
|
||||
# landed since, so a PR that touches no UI file still gets linted
|
||||
# against hundreds of other people's files. Diff the PR head against its
|
||||
# own merge base instead, which is exactly what this PR changed.
|
||||
merge_base=$(git merge-base "$BASE_SHA" "$HEAD_SHA")
|
||||
: > "$RUNNER_TEMP/prettier_files.txt"
|
||||
: > "$RUNNER_TEMP/eslint_files.txt"
|
||||
while IFS= read -r f; do
|
||||
|
|
@ -41,7 +49,7 @@ jobs:
|
|||
*.json | *.css | *.scss | *.md | *.mdx | *.yml | *.yaml | *.html)
|
||||
printf '%s\n' "$f" >> "$RUNNER_TEMP/prettier_files.txt" ;;
|
||||
esac
|
||||
done < <(git diff --name-only --diff-filter=ACMR --relative "$BASE_SHA"...HEAD -- .)
|
||||
done < <(git diff --name-only --diff-filter=ACMR --relative "$merge_base" "$HEAD_SHA" -- .)
|
||||
if [ -s "$RUNNER_TEMP/prettier_files.txt" ] || [ -s "$RUNNER_TEMP/eslint_files.txt" ]; then
|
||||
echo "has_files=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
|
|
|
|||
151
.github/workflows/test_server_root_path.yml
vendored
151
.github/workflows/test_server_root_path.yml
vendored
|
|
@ -1,151 +0,0 @@
|
|||
name: Test Proxy SERVER_ROOT_PATH Routing
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- litellm_internal_staging
|
||||
- litellm_oss_staging
|
||||
- "litellm_**"
|
||||
|
||||
jobs:
|
||||
test-server-root-path:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
root_path: ["/api/v1", "/llmproxy"]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/share/boost
|
||||
sudo apt-get clean
|
||||
df -h /
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile.non_root
|
||||
tags: litellm-test:${{ github.sha }}
|
||||
load: true
|
||||
push: false
|
||||
|
||||
- name: Start LiteLLM container with SERVER_ROOT_PATH
|
||||
run: |
|
||||
docker run -d \
|
||||
--name litellm-test \
|
||||
-p 4000:4000 \
|
||||
-e SERVER_ROOT_PATH="${{ matrix.root_path }}" \
|
||||
-e LITELLM_MASTER_KEY="sk-1234" \
|
||||
litellm-test:${{ github.sha }} \
|
||||
--detailed_debug
|
||||
|
||||
- name: Wait for container to be healthy
|
||||
run: |
|
||||
echo "Waiting for LiteLLM to start..."
|
||||
max_attempts=30
|
||||
attempt=0
|
||||
|
||||
while [ $attempt -lt $max_attempts ]; do
|
||||
if docker logs litellm-test 2>&1 | grep -q "Uvicorn running"; then
|
||||
echo "LiteLLM started successfully"
|
||||
break
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
echo "Attempt $attempt/$max_attempts - waiting for server to start..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ $attempt -eq $max_attempts ]; then
|
||||
echo "Server failed to start within timeout"
|
||||
docker logs litellm-test
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
|
||||
- name: Show container logs
|
||||
if: always()
|
||||
run: docker logs litellm-test
|
||||
|
||||
- name: Test UI endpoint with root path
|
||||
run: |
|
||||
ROOT_PATH="${{ matrix.root_path }}"
|
||||
echo "Testing UI at: http://localhost:4000${ROOT_PATH}/ui/"
|
||||
|
||||
for i in 1 2 3; do
|
||||
content=$(curl -sL --max-time 5 -H "Authorization: Bearer sk-1234" "http://localhost:4000${ROOT_PATH}/ui/")
|
||||
if echo "$content" | grep -q -E "(html|<!DOCTYPE|<head|<body)"; then
|
||||
echo "UI page contains valid HTML content"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $i/3 - no valid HTML, retrying in 5s..."
|
||||
sleep 5
|
||||
done
|
||||
echo "UI page does not contain expected HTML content"
|
||||
echo "Response: $content"
|
||||
docker logs litellm-test
|
||||
exit 1
|
||||
|
||||
- name: Setup Node for Playwright
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install e2e deps and Chromium
|
||||
working-directory: tests/e2e/ui
|
||||
run: |
|
||||
retry() {
|
||||
local attempt=1
|
||||
local max_attempts=4
|
||||
until "$@"; do
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
echo "Command failed after $attempt attempts: $*"
|
||||
return 1
|
||||
fi
|
||||
echo "Attempt $attempt failed: $*. Retrying in $((attempt * 15))s..."
|
||||
sleep $((attempt * 15))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
}
|
||||
|
||||
npm config set fetch-retries 5
|
||||
npm config set fetch-retry-mintimeout 20000
|
||||
npm config set fetch-retry-maxtimeout 120000
|
||||
|
||||
retry npm ci
|
||||
retry npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run SERVER_ROOT_PATH redirect e2e
|
||||
working-directory: tests/e2e/ui
|
||||
env:
|
||||
SERVER_ROOT_PATH: ${{ matrix.root_path }}
|
||||
run: npx playwright test --config=serverRootPath.config.ts
|
||||
|
||||
- name: Upload Playwright artifacts on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: playwright-trace-${{ strategy.job-index }}
|
||||
path: tests/e2e/ui/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
docker stop litellm-test || true
|
||||
docker rm litellm-test || true
|
||||
|
|
@ -70,6 +70,10 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
|
|||
"/project/",
|
||||
"/memory/",
|
||||
"/mcp/",
|
||||
# Control plane (see the List Endpoints + Tables standard). Every resource
|
||||
# eventually moves under this prefix, so allowlist it once rather than
|
||||
# per-resource.
|
||||
"/management/v1/",
|
||||
# Spend / analytics
|
||||
"/spend/",
|
||||
"/analytics/",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"reportAny": {
|
||||
"limit": 37484
|
||||
"limit": 34906
|
||||
},
|
||||
"reportArgumentType": {
|
||||
"limit": 2704
|
||||
"limit": 2701
|
||||
},
|
||||
"reportAssignmentType": {
|
||||
"limit": 330
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
"limit": 516
|
||||
},
|
||||
"reportCallIssue": {
|
||||
"limit": 124
|
||||
"limit": 123
|
||||
},
|
||||
"reportConstantRedefinition": {
|
||||
"limit": 59
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
"limit": 42
|
||||
},
|
||||
"reportExplicitAny": {
|
||||
"limit": 10389
|
||||
"limit": 10230
|
||||
},
|
||||
"reportFunctionMemberAccess": {
|
||||
"limit": 11
|
||||
|
|
@ -54,10 +54,10 @@
|
|||
"limit": 0
|
||||
},
|
||||
"reportMissingParameterType": {
|
||||
"limit": 5900
|
||||
"limit": 5893
|
||||
},
|
||||
"reportMissingTypeArgument": {
|
||||
"limit": 15903
|
||||
"limit": 15886
|
||||
},
|
||||
"reportMissingTypeStubs": {
|
||||
"limit": 41
|
||||
|
|
@ -99,31 +99,31 @@
|
|||
"limit": 0
|
||||
},
|
||||
"reportUnknownArgumentType": {
|
||||
"limit": 45894
|
||||
"limit": 45870
|
||||
},
|
||||
"reportUnknownLambdaType": {
|
||||
"limit": 113
|
||||
},
|
||||
"reportUnknownMemberType": {
|
||||
"limit": 40539
|
||||
"limit": 40525
|
||||
},
|
||||
"reportUnknownParameterType": {
|
||||
"limit": 20403
|
||||
"limit": 20384
|
||||
},
|
||||
"reportUnknownVariableType": {
|
||||
"limit": 32141
|
||||
"limit": 32099
|
||||
},
|
||||
"reportUnnecessaryCast": {
|
||||
"limit": 177
|
||||
},
|
||||
"reportUnnecessaryComparison": {
|
||||
"limit": 1025
|
||||
"limit": 1023
|
||||
},
|
||||
"reportUnnecessaryContains": {
|
||||
"limit": 7
|
||||
},
|
||||
"reportUnnecessaryIsInstance": {
|
||||
"limit": 1209
|
||||
"limit": 1206
|
||||
},
|
||||
"reportUntypedBaseClass": {
|
||||
"limit": 165
|
||||
|
|
|
|||
46
db_scripts/backfill_daily_tool_spend.sql
Normal file
46
db_scripts/backfill_daily_tool_spend.sql
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
-- One-shot backfill of the LiteLLM_DailyToolSpend rollup from the per-request
|
||||
-- LiteLLM_SpendLogToolIndex x LiteLLM_SpendLogs tables.
|
||||
--
|
||||
-- This is an opt-in, manual operation. New deployments do not need it: the
|
||||
-- rollup is written at request time from the moment the release is deployed.
|
||||
-- Run it only if you want the Cost Optimization "Spend by tool" card to show
|
||||
-- history from before the deploy, and only once.
|
||||
--
|
||||
-- IMPORTANT caveats before running:
|
||||
--
|
||||
-- 1. Pre-deploy index rows may include tools that were merely DECLARED in a
|
||||
-- request body but never invoked (the release this ships with stops
|
||||
-- recording those). For agentic clients that declare many tools per
|
||||
-- request, backfilled history attributes each request's full spend to
|
||||
-- every declared tool, overstating per-tool spend. Post-deploy rows do not
|
||||
-- have this problem. If your traffic is mostly such clients, consider not
|
||||
-- backfilling.
|
||||
--
|
||||
-- 2. Coverage is bounded by spend-log retention: rows older than
|
||||
-- maximum_spend_logs_retention_period are already gone.
|
||||
--
|
||||
-- 3. Replace the cutover timestamp below with the time you deployed the
|
||||
-- release, so backfilled per-request rows cannot double-count on top of
|
||||
-- rollup rows the new writer already created. ON CONFLICT DO NOTHING is a
|
||||
-- second guard for (date, tool_name) buckets the writer already touched:
|
||||
-- such buckets keep the writer's numbers and skip the backfill's.
|
||||
--
|
||||
-- Usage:
|
||||
-- psql "$DATABASE_URL" -v cutover="'2026-07-25T00:00:00Z'" -f db_scripts/backfill_daily_tool_spend.sql
|
||||
|
||||
SET TIME ZONE 'UTC';
|
||||
|
||||
INSERT INTO "LiteLLM_DailyToolSpend" (date, tool_name, spend, total_tokens, request_count, created_at, updated_at)
|
||||
SELECT
|
||||
to_char(ti.start_time, 'YYYY-MM-DD') AS date,
|
||||
ti.tool_name,
|
||||
COALESCE(SUM(sl.spend), 0) AS spend,
|
||||
COALESCE(SUM(sl.total_tokens), 0) AS total_tokens,
|
||||
COUNT(*) AS request_count,
|
||||
now() AS created_at,
|
||||
now() AS updated_at
|
||||
FROM "LiteLLM_SpendLogToolIndex" ti
|
||||
JOIN "LiteLLM_SpendLogs" sl ON sl.request_id = ti.request_id
|
||||
WHERE ti.start_time < :cutover::timestamptz
|
||||
GROUP BY 1, 2
|
||||
ON CONFLICT (date, tool_name) DO NOTHING;
|
||||
|
|
@ -316,26 +316,34 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
where_clause: Dict[str, Any] = {"file_purpose": "batch", **owner_filter}
|
||||
|
||||
if after:
|
||||
where_clause["id"] = {"gt": after}
|
||||
cursor_row = (
|
||||
await self.prisma_client.db.litellm_managedobjecttable.find_first(
|
||||
where={**where_clause, "unified_object_id": after}
|
||||
)
|
||||
)
|
||||
if cursor_row is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid 'after' cursor: no batch found with id '{after}'.",
|
||||
)
|
||||
|
||||
fetch_limit = limit or 20
|
||||
if target_model_names:
|
||||
# Oversample so post-fetch model-name filtering still has enough rows.
|
||||
fetch_limit = max(fetch_limit * 3, 100)
|
||||
page_size = limit or 20
|
||||
cursor_args: Dict[str, Any] = (
|
||||
{"cursor": {"unified_object_id": after}, "skip": 1} if after else {}
|
||||
)
|
||||
|
||||
batches = await self.prisma_client.db.litellm_managedobjecttable.find_many(
|
||||
where=where_clause,
|
||||
take=fetch_limit,
|
||||
order={"created_at": "desc"},
|
||||
take=page_size + 1,
|
||||
order=[{"created_at": "desc"}, {"unified_object_id": "desc"}],
|
||||
**cursor_args,
|
||||
)
|
||||
|
||||
batch_objects: List[LiteLLMBatch] = []
|
||||
for batch in batches:
|
||||
try:
|
||||
# Stop once we have enough after filtering
|
||||
if len(batch_objects) >= (limit or 20):
|
||||
break
|
||||
has_more = len(batches) > page_size
|
||||
|
||||
batch_objects: List[LiteLLMBatch] = []
|
||||
for batch in batches[:page_size]:
|
||||
try:
|
||||
batch_data = (
|
||||
json.loads(batch.file_object)
|
||||
if isinstance(batch.file_object, str)
|
||||
|
|
@ -351,9 +359,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
)
|
||||
continue
|
||||
|
||||
return build_list_page(
|
||||
batch_objects, has_more=len(batch_objects) == (limit or 20)
|
||||
)
|
||||
return build_list_page(batch_objects, has_more=has_more)
|
||||
|
||||
async def get_user_created_file_ids(
|
||||
self, user_api_key_dict: UserAPIKeyAuth, model_object_ids: List[str]
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ Endpoints for /project operations
|
|||
#### PROJECT MANAGEMENT ####
|
||||
|
||||
import json
|
||||
from typing import List, Optional, Union
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
|
|
@ -25,15 +26,24 @@ from litellm.proxy.management_helpers.utils import (
|
|||
)
|
||||
from litellm.proxy.utils import PrismaClient, handle_exception_on_proxy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prisma import models as prisma_models
|
||||
from prisma.actions import LiteLLM_TeamTableActions
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _team_table(prisma_client: PrismaClient) -> "LiteLLM_TeamTableActions[prisma_models.LiteLLM_TeamTable]":
|
||||
team_table: LiteLLM_TeamTableActions[prisma_models.LiteLLM_TeamTable] = prisma_client.db.litellm_teamtable
|
||||
return team_table
|
||||
|
||||
|
||||
async def _check_user_permission_for_project(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
team_id: Optional[str],
|
||||
team_id: str | None,
|
||||
prisma_client: PrismaClient,
|
||||
require_admin: bool = False,
|
||||
team_object: Optional[LiteLLM_TeamTable] = None,
|
||||
team_object: LiteLLM_TeamTable | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if user has permission to manage a project.
|
||||
|
|
@ -57,9 +67,7 @@ async def _check_user_permission_for_project(
|
|||
|
||||
team = team_object
|
||||
if team is None:
|
||||
team = await prisma_client.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": team_id}
|
||||
)
|
||||
team = await _team_table(prisma_client).find_unique(where={"team_id": team_id})
|
||||
|
||||
if team and team.admins:
|
||||
return user_api_key_dict.user_id in team.admins
|
||||
|
|
@ -70,9 +78,9 @@ async def _check_user_permission_for_project(
|
|||
async def _validate_team_exists(
|
||||
team_id: str,
|
||||
prisma_client: PrismaClient,
|
||||
):
|
||||
) -> "prisma_models.LiteLLM_TeamTable":
|
||||
"""Validate that a team exists. Returns the team row."""
|
||||
team = await prisma_client.db.litellm_teamtable.find_unique(
|
||||
team = await _team_table(prisma_client).find_unique(
|
||||
where={"team_id": team_id},
|
||||
)
|
||||
|
||||
|
|
@ -89,7 +97,7 @@ async def _validate_team_exists(
|
|||
|
||||
def _check_team_project_limits(
|
||||
team_object: LiteLLM_TeamTable,
|
||||
data: Union[NewProjectRequest, UpdateProjectRequest],
|
||||
data: NewProjectRequest | UpdateProjectRequest,
|
||||
) -> None:
|
||||
"""
|
||||
Check that project limits respect its parent Team's limits.
|
||||
|
|
@ -108,16 +116,12 @@ def _check_team_project_limits(
|
|||
if data.max_budget is not None and data.max_budget < 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": f"max_budget cannot be negative. Received: {data.max_budget}"
|
||||
},
|
||||
detail={"error": f"max_budget cannot be negative. Received: {data.max_budget}"},
|
||||
)
|
||||
if data.soft_budget is not None and data.soft_budget < 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": f"soft_budget cannot be negative. Received: {data.soft_budget}"
|
||||
},
|
||||
detail={"error": f"soft_budget cannot be negative. Received: {data.soft_budget}"},
|
||||
)
|
||||
|
||||
# --- soft_budget < max_budget ---
|
||||
|
|
@ -131,7 +135,7 @@ def _check_team_project_limits(
|
|||
)
|
||||
|
||||
# --- Validate project models are a subset of team models ---
|
||||
project_models = getattr(data, "models", None)
|
||||
project_models = data.models
|
||||
team_models = team_object.models or []
|
||||
if project_models and len(team_models) > 0:
|
||||
# If team has 'all-proxy-models', skip validation as it allows all models
|
||||
|
|
@ -148,11 +152,7 @@ def _check_team_project_limits(
|
|||
# --- Validate project max_budget <= team max_budget ---
|
||||
# Team stores budget fields directly (max_budget, tpm_limit, rpm_limit)
|
||||
# unlike Project which uses a separate LiteLLM_BudgetTable relation
|
||||
if (
|
||||
data.max_budget is not None
|
||||
and team_object.max_budget is not None
|
||||
and data.max_budget > team_object.max_budget
|
||||
):
|
||||
if data.max_budget is not None and team_object.max_budget is not None and data.max_budget > team_object.max_budget:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
|
|
@ -161,11 +161,7 @@ def _check_team_project_limits(
|
|||
)
|
||||
|
||||
# --- Validate project tpm_limit <= team tpm_limit ---
|
||||
if (
|
||||
data.tpm_limit is not None
|
||||
and team_object.tpm_limit is not None
|
||||
and data.tpm_limit > team_object.tpm_limit
|
||||
):
|
||||
if data.tpm_limit is not None and team_object.tpm_limit is not None and data.tpm_limit > team_object.tpm_limit:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
|
|
@ -174,11 +170,7 @@ def _check_team_project_limits(
|
|||
)
|
||||
|
||||
# --- Validate project rpm_limit <= team rpm_limit ---
|
||||
if (
|
||||
data.rpm_limit is not None
|
||||
and team_object.rpm_limit is not None
|
||||
and data.rpm_limit > team_object.rpm_limit
|
||||
):
|
||||
if data.rpm_limit is not None and team_object.rpm_limit is not None and data.rpm_limit > team_object.rpm_limit:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
|
|
@ -189,19 +181,19 @@ def _check_team_project_limits(
|
|||
|
||||
async def _create_budget_for_project(
|
||||
data: NewProjectRequest,
|
||||
user_id: Optional[str],
|
||||
user_id: str | None,
|
||||
litellm_proxy_admin_name: str,
|
||||
prisma_client: PrismaClient,
|
||||
) -> str:
|
||||
"""Create a budget for the project and return budget_id."""
|
||||
budget_params = LiteLLM_BudgetTable.model_fields.keys()
|
||||
_json_data = data.json(exclude_none=True)
|
||||
_json_data: Mapping[str, object] = data.json(exclude_none=True)
|
||||
_budget_data = {k: v for k, v in _json_data.items() if k in budget_params}
|
||||
budget_row = LiteLLM_BudgetTable(**_budget_data)
|
||||
budget_row = LiteLLM_BudgetTable.model_validate(_budget_data)
|
||||
|
||||
new_budget = prisma_client.jsonify_object(budget_row.json(exclude_none=True))
|
||||
|
||||
_budget = await prisma_client.db.litellm_budgettable.create(
|
||||
_budget: prisma_models.LiteLLM_BudgetTable = await prisma_client.db.litellm_budgettable.create(
|
||||
data={
|
||||
**new_budget,
|
||||
"created_by": user_id or litellm_proxy_admin_name,
|
||||
|
|
@ -214,8 +206,8 @@ async def _create_budget_for_project(
|
|||
|
||||
async def _set_project_object_permission(
|
||||
data: NewProjectRequest,
|
||||
prisma_client: Optional[PrismaClient],
|
||||
) -> Optional[str]:
|
||||
prisma_client: PrismaClient | None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Creates the LiteLLM_ObjectPermissionTable record for the project.
|
||||
Returns the object_permission_id if created, otherwise None.
|
||||
|
|
@ -224,7 +216,7 @@ async def _set_project_object_permission(
|
|||
return None
|
||||
|
||||
if data.object_permission is not None:
|
||||
created_object_permission = (
|
||||
created_object_permission: prisma_models.LiteLLM_ObjectPermissionTable = (
|
||||
await prisma_client.db.litellm_objectpermissiontable.create(
|
||||
data=data.object_permission.model_dump(exclude_none=True),
|
||||
)
|
||||
|
|
@ -344,8 +336,7 @@ async def new_project(
|
|||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "Only premium users can add tags to projects. "
|
||||
+ CommonProxyErrors.not_premium_user.value
|
||||
"error": "Only premium users can add tags to projects. " + CommonProxyErrors.not_premium_user.value
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -353,8 +344,7 @@ async def new_project(
|
|||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "Project management is an enterprise feature. "
|
||||
+ CommonProxyErrors.not_premium_user.value
|
||||
"error": "Project management is an enterprise feature. " + CommonProxyErrors.not_premium_user.value
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -375,13 +365,11 @@ async def new_project(
|
|||
)
|
||||
|
||||
# Validate team exists and get team object with budget
|
||||
team_object = await _validate_team_exists(
|
||||
team_id=data.team_id, prisma_client=prisma_client
|
||||
)
|
||||
team_object = await _validate_team_exists(team_id=data.team_id, prisma_client=prisma_client)
|
||||
|
||||
# Validate project limits against team limits
|
||||
_check_team_project_limits(
|
||||
team_object=LiteLLM_TeamTable(**team_object.model_dump()),
|
||||
team_object=LiteLLM_TeamTable.model_validate(team_object.model_dump()),
|
||||
data=data,
|
||||
)
|
||||
|
||||
|
|
@ -391,7 +379,7 @@ async def new_project(
|
|||
user_api_key_dict=user_api_key_dict,
|
||||
team_id=data.team_id,
|
||||
prisma_client=prisma_client,
|
||||
team_object=LiteLLM_TeamTable(**team_object.model_dump()),
|
||||
team_object=LiteLLM_TeamTable.model_validate(team_object.model_dump()),
|
||||
)
|
||||
|
||||
if not has_permission:
|
||||
|
|
@ -449,17 +437,13 @@ async def new_project(
|
|||
value=getattr(data, field),
|
||||
)
|
||||
|
||||
new_project_row = prisma_client.jsonify_object(
|
||||
project_row.json(exclude_none=True)
|
||||
)
|
||||
new_project_row = prisma_client.jsonify_object(project_row.json(exclude_none=True))
|
||||
|
||||
# Remove budget fields (following organization_endpoints.py pattern)
|
||||
new_project_row = _remove_budget_fields_from_project_data(new_project_row)
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
f"new_project_row: {json.dumps(new_project_row, indent=2)}"
|
||||
)
|
||||
response = await prisma_client.db.litellm_projecttable.create(
|
||||
verbose_proxy_logger.info(f"new_project_row: {json.dumps(new_project_row, indent=2)}")
|
||||
response: prisma_models.LiteLLM_ProjectTable = await prisma_client.db.litellm_projecttable.create(
|
||||
data={
|
||||
**new_project_row, # type: ignore
|
||||
},
|
||||
|
|
@ -469,9 +453,7 @@ async def new_project(
|
|||
return response
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(
|
||||
"litellm.proxy.management_endpoints.project_endpoints.new_project(): Exception occured - {}".format(
|
||||
str(e)
|
||||
)
|
||||
"litellm.proxy.management_endpoints.project_endpoints.new_project(): Exception occured - {}".format(str(e))
|
||||
)
|
||||
raise handle_exception_on_proxy(e)
|
||||
|
||||
|
|
@ -539,8 +521,7 @@ async def update_project(
|
|||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "Only premium users can add tags to projects. "
|
||||
+ CommonProxyErrors.not_premium_user.value
|
||||
"error": "Only premium users can add tags to projects. " + CommonProxyErrors.not_premium_user.value
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -548,8 +529,7 @@ async def update_project(
|
|||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "Project management is an enterprise feature. "
|
||||
+ CommonProxyErrors.not_premium_user.value
|
||||
"error": "Project management is an enterprise feature. " + CommonProxyErrors.not_premium_user.value
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -576,9 +556,9 @@ async def update_project(
|
|||
)
|
||||
|
||||
# Fetch existing project
|
||||
existing_project = await prisma_client.db.litellm_projecttable.find_unique(
|
||||
where={"project_id": data.project_id}
|
||||
)
|
||||
existing_project: (
|
||||
prisma_models.LiteLLM_ProjectTable | None
|
||||
) = await prisma_client.db.litellm_projecttable.find_unique(where={"project_id": data.project_id})
|
||||
|
||||
if existing_project is None:
|
||||
raise ProxyException(
|
||||
|
|
@ -595,9 +575,7 @@ async def update_project(
|
|||
target_team_id = data.team_id or existing_project.team_id
|
||||
target_team_obj = None
|
||||
if target_team_id is not None:
|
||||
target_team_obj = await _validate_team_exists(
|
||||
team_id=target_team_id, prisma_client=prisma_client
|
||||
)
|
||||
target_team_obj = await _validate_team_exists(team_id=target_team_id, prisma_client=prisma_client)
|
||||
|
||||
has_permission = await _check_user_permission_for_project(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
|
|
@ -620,32 +598,26 @@ async def update_project(
|
|||
team_id=data.team_id,
|
||||
prisma_client=prisma_client,
|
||||
team_object=(
|
||||
LiteLLM_TeamTable(**target_team_obj.model_dump())
|
||||
if target_team_obj
|
||||
else None
|
||||
LiteLLM_TeamTable.model_validate(target_team_obj.model_dump()) if target_team_obj else None
|
||||
),
|
||||
)
|
||||
if not can_assign_to_target:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "Cannot reassign project to a team you are not an admin of"
|
||||
},
|
||||
detail={"error": "Cannot reassign project to a team you are not an admin of"},
|
||||
)
|
||||
|
||||
# Validate project limits against team limits
|
||||
if target_team_obj is not None:
|
||||
_check_team_project_limits(
|
||||
team_object=LiteLLM_TeamTable(**target_team_obj.model_dump()),
|
||||
team_object=LiteLLM_TeamTable.model_validate(target_team_obj.model_dump()),
|
||||
data=data,
|
||||
)
|
||||
|
||||
# Prepare update data
|
||||
update_data = data.json(exclude_none=True, exclude={"project_id"})
|
||||
update_data = prisma_client.jsonify_object(update_data)
|
||||
update_data["updated_by"] = (
|
||||
user_api_key_dict.user_id or litellm_proxy_admin_name
|
||||
)
|
||||
update_data["updated_by"] = user_api_key_dict.user_id or litellm_proxy_admin_name
|
||||
|
||||
# Handle budget updates
|
||||
budget_fields = LiteLLM_BudgetTable.model_fields.keys()
|
||||
|
|
@ -671,21 +643,17 @@ async def update_project(
|
|||
if existing_project.object_permission_id:
|
||||
# Update existing permission
|
||||
await prisma_client.db.litellm_objectpermissiontable.update(
|
||||
where={
|
||||
"object_permission_id": existing_project.object_permission_id
|
||||
},
|
||||
where={"object_permission_id": existing_project.object_permission_id},
|
||||
data=object_permission_data,
|
||||
)
|
||||
else:
|
||||
# Create new permission
|
||||
created_permission = (
|
||||
created_permission: prisma_models.LiteLLM_ObjectPermissionTable = (
|
||||
await prisma_client.db.litellm_objectpermissiontable.create(
|
||||
data=object_permission_data,
|
||||
)
|
||||
)
|
||||
update_data["object_permission_id"] = (
|
||||
created_permission.object_permission_id
|
||||
)
|
||||
update_data["object_permission_id"] = created_permission.object_permission_id
|
||||
|
||||
# Handle metadata fields
|
||||
for field in LiteLLM_ManagementEndpoint_MetadataFields:
|
||||
|
|
@ -698,7 +666,7 @@ async def update_project(
|
|||
update_data = _remove_budget_fields_from_project_data(update_data)
|
||||
|
||||
# Update project
|
||||
updated_project = await prisma_client.db.litellm_projecttable.update(
|
||||
updated_project: prisma_models.LiteLLM_ProjectTable | None = await prisma_client.db.litellm_projecttable.update(
|
||||
where={"project_id": data.project_id},
|
||||
data=update_data,
|
||||
include={"litellm_budget_table": True, "object_permission": True},
|
||||
|
|
@ -718,7 +686,7 @@ async def update_project(
|
|||
"/project/delete",
|
||||
tags=["project management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=List[LiteLLM_ProjectTable],
|
||||
response_model=list[LiteLLM_ProjectTable],
|
||||
)
|
||||
@management_endpoint_wrapper
|
||||
async def delete_project(
|
||||
|
|
@ -749,8 +717,7 @@ async def delete_project(
|
|||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "Project management is an enterprise feature. "
|
||||
+ CommonProxyErrors.not_premium_user.value
|
||||
"error": "Project management is an enterprise feature. " + CommonProxyErrors.not_premium_user.value
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -778,9 +745,7 @@ async def delete_project(
|
|||
|
||||
for project_id in data.project_ids:
|
||||
# Check if project exists
|
||||
existing_project = await prisma_client.db.litellm_projecttable.find_unique(
|
||||
where={"project_id": project_id}
|
||||
)
|
||||
existing_project = await prisma_client.db.litellm_projecttable.find_unique(where={"project_id": project_id})
|
||||
|
||||
if existing_project is None:
|
||||
raise ProxyException(
|
||||
|
|
@ -791,11 +756,9 @@ async def delete_project(
|
|||
)
|
||||
|
||||
# Check if there are any keys associated with this project
|
||||
associated_keys = (
|
||||
await prisma_client.db.litellm_verificationtoken.find_many(
|
||||
where={"project_id": project_id}
|
||||
)
|
||||
)
|
||||
associated_keys: Sequence[
|
||||
prisma_models.LiteLLM_VerificationToken
|
||||
] = await prisma_client.db.litellm_verificationtoken.find_many(where={"project_id": project_id})
|
||||
|
||||
if len(associated_keys) > 0:
|
||||
raise ProxyException(
|
||||
|
|
@ -806,9 +769,9 @@ async def delete_project(
|
|||
)
|
||||
|
||||
# Delete the project
|
||||
deleted_project = await prisma_client.db.litellm_projecttable.delete(
|
||||
where={"project_id": project_id}
|
||||
)
|
||||
deleted_project: (
|
||||
prisma_models.LiteLLM_ProjectTable | None
|
||||
) = await prisma_client.db.litellm_projecttable.delete(where={"project_id": project_id})
|
||||
|
||||
deleted_projects.append(deleted_project)
|
||||
|
||||
|
|
@ -854,7 +817,7 @@ async def project_info(
|
|||
)
|
||||
|
||||
# Fetch project
|
||||
project = await prisma_client.db.litellm_projecttable.find_unique(
|
||||
project: prisma_models.LiteLLM_ProjectTable | None = await prisma_client.db.litellm_projecttable.find_unique(
|
||||
where={"project_id": project_id},
|
||||
include={"litellm_budget_table": True, "object_permission": True},
|
||||
)
|
||||
|
|
@ -872,17 +835,11 @@ async def project_info(
|
|||
is_team_member = False
|
||||
|
||||
if project.team_id and user_api_key_dict.user_id:
|
||||
team = await prisma_client.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": project.team_id}
|
||||
)
|
||||
team = await _team_table(prisma_client).find_unique(where={"team_id": project.team_id})
|
||||
if team:
|
||||
caller_user_id = user_api_key_dict.user_id
|
||||
for m in team.members_with_roles or []:
|
||||
m_user_id = (
|
||||
m.get("user_id")
|
||||
if isinstance(m, dict)
|
||||
else getattr(m, "user_id", None)
|
||||
)
|
||||
m_user_id = m.get("user_id") if isinstance(m, dict) else getattr(m, "user_id", None)
|
||||
if m_user_id == caller_user_id:
|
||||
is_team_member = True
|
||||
break
|
||||
|
|
@ -896,9 +853,7 @@ async def project_info(
|
|||
return project
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(
|
||||
"litellm.proxy.management_endpoints.project_endpoints.project_info(): Exception occured - {}".format(
|
||||
str(e)
|
||||
)
|
||||
"litellm.proxy.management_endpoints.project_endpoints.project_info(): Exception occured - {}".format(str(e))
|
||||
)
|
||||
raise handle_exception_on_proxy(e)
|
||||
|
||||
|
|
@ -907,7 +862,7 @@ async def project_info(
|
|||
"/project/list",
|
||||
tags=["project management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=List[LiteLLM_ProjectTable],
|
||||
response_model=list[LiteLLM_ProjectTable],
|
||||
)
|
||||
async def list_projects(
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
|
|
@ -932,21 +887,19 @@ async def list_projects(
|
|||
|
||||
# If proxy admin, get all projects
|
||||
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
|
||||
projects = await prisma_client.db.litellm_projecttable.find_many(
|
||||
projects: Sequence[
|
||||
prisma_models.LiteLLM_ProjectTable
|
||||
] = await prisma_client.db.litellm_projecttable.find_many(
|
||||
include={"litellm_budget_table": True, "object_permission": True}
|
||||
)
|
||||
else:
|
||||
# Look up the user's team memberships via the reverse-index on
|
||||
# LiteLLM_UserTable.teams (maintained by team_member_add alongside
|
||||
# members_with_roles). This avoids a full scan of all team rows.
|
||||
user_record = await prisma_client.db.litellm_usertable.find_unique(
|
||||
user_record: prisma_models.LiteLLM_UserTable | None = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": user_api_key_dict.user_id},
|
||||
)
|
||||
user_team_ids = (
|
||||
user_record.teams
|
||||
if user_record is not None and user_record.teams
|
||||
else []
|
||||
)
|
||||
user_team_ids: Sequence[str] = user_record.teams if user_record is not None and user_record.teams else []
|
||||
|
||||
projects = await prisma_client.db.litellm_projecttable.find_many(
|
||||
where={"team_id": {"in": user_team_ids}},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-enterprise"
|
||||
version = "0.1.51"
|
||||
version = "0.1.52"
|
||||
description = "Package for LiteLLM Enterprise features"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
|
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
|
|||
module-root = ""
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.1.51"
|
||||
version = "0.1.52"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-enterprise==",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ GATEWAY_PATH_PREFIXES: tuple[str, ...] = (
|
|||
"/messages",
|
||||
"/v1/skills",
|
||||
"/v1/a2a/",
|
||||
"/a2a/",
|
||||
# LiteLLM-native LLM surface
|
||||
"/v1/rerank",
|
||||
"/v2/rerank",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
"/v1/fine-tuning" "/fine-tuning" "/v1/responses" "/responses" "/v1/threads" "/threads"
|
||||
"/v1/assistants" "/assistants" "/v1/vector_stores" "/vector_stores" "/v1/indexes"
|
||||
"/v1/models" "/models" "/openai" "/engines"
|
||||
"/v1/messages" "/messages" "/v1/skills" "/v1/a2a"
|
||||
"/v1/messages" "/messages" "/v1/skills" "/v1/a2a" "/a2a"
|
||||
"/v1/rerank" "/v2/rerank" "/rerank" "/v1/ocr" "/ocr" "/v1/rag" "/rag"
|
||||
"/v1/video" "/v1/videos" "/video" "/videos" "/v1/search" "/search"
|
||||
"/v1/containers" "/containers" "/v1/evals" "/v1/memory" "/queue/chat"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogToolIndex_start_time_idx" ON "LiteLLM_SpendLogToolIndex"("start_time");
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "LiteLLM_DailyToolSpend" (
|
||||
"date" TEXT NOT NULL,
|
||||
"tool_name" TEXT NOT NULL,
|
||||
"spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||
"total_tokens" BIGINT NOT NULL DEFAULT 0,
|
||||
"request_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LiteLLM_DailyToolSpend_pkey" PRIMARY KEY ("date","tool_name")
|
||||
);
|
||||
|
|
@ -1094,6 +1094,20 @@ model LiteLLM_SpendLogToolIndex {
|
|||
|
||||
@@id([request_id, tool_name])
|
||||
@@index([tool_name, start_time])
|
||||
@@index([start_time])
|
||||
}
|
||||
|
||||
// Daily tool spend rollup (one row per tool per day) – the Cost Optimization card reads this, never SpendLogs
|
||||
model LiteLLM_DailyToolSpend {
|
||||
date String
|
||||
tool_name String
|
||||
spend Float @default(0.0)
|
||||
total_tokens BigInt @default(0)
|
||||
request_count BigInt @default(0)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
@@id([date, tool_name])
|
||||
}
|
||||
|
||||
// Prompt table for storing prompt configurations
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.80"
|
||||
version = "0.4.81"
|
||||
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
|
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
|
|||
module-root = ""
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.4.80"
|
||||
version = "0.4.81"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-proxy-extras==",
|
||||
|
|
|
|||
|
|
@ -209,7 +209,15 @@ class ResponsesToCompletionBridgeHandler:
|
|||
json_mode=kwargs.get("json_mode"),
|
||||
)
|
||||
elif isinstance(result, ModelResponse):
|
||||
return result
|
||||
if not stream:
|
||||
return result
|
||||
return self._completed_response_as_stream(
|
||||
response=result,
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
logging_obj=logging_obj,
|
||||
json_mode=kwargs.get("json_mode"),
|
||||
)
|
||||
elif not stream:
|
||||
responses_api_response = self._collect_response_from_stream(result)
|
||||
return self.transformation_handler.transform_response(
|
||||
|
|
@ -299,7 +307,15 @@ class ResponsesToCompletionBridgeHandler:
|
|||
json_mode=kwargs.get("json_mode"),
|
||||
)
|
||||
elif isinstance(result, ModelResponse):
|
||||
return result
|
||||
if not stream:
|
||||
return result
|
||||
return self._completed_response_as_stream(
|
||||
response=result,
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
logging_obj=logging_obj,
|
||||
json_mode=kwargs.get("json_mode"),
|
||||
)
|
||||
elif not stream:
|
||||
responses_api_response = await self._collect_response_from_stream_async(result)
|
||||
return self.transformation_handler.transform_response(
|
||||
|
|
@ -331,6 +347,25 @@ class ResponsesToCompletionBridgeHandler:
|
|||
)
|
||||
return self._apply_post_stream_processing(streamwrapper, model, custom_llm_provider)
|
||||
|
||||
def _completed_response_as_stream(
|
||||
self,
|
||||
response: "ModelResponse",
|
||||
model: str,
|
||||
custom_llm_provider: str,
|
||||
logging_obj: "LiteLLMLoggingObj",
|
||||
json_mode: bool | None,
|
||||
) -> "CustomStreamWrapper":
|
||||
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
||||
from litellm.llms.base_llm.base_model_iterator import MockResponseIterator
|
||||
|
||||
streamwrapper = CustomStreamWrapper(
|
||||
completion_stream=MockResponseIterator(model_response=response, json_mode=json_mode),
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
return self._apply_post_stream_processing(streamwrapper, model, custom_llm_provider)
|
||||
|
||||
@staticmethod
|
||||
def _apply_post_stream_processing(
|
||||
stream: "CustomStreamWrapper",
|
||||
|
|
|
|||
|
|
@ -1077,6 +1077,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
||||
def __init__(self, streaming_response, sync_stream: bool, json_mode: Optional[bool] = False):
|
||||
super().__init__(streaming_response, sync_stream, json_mode)
|
||||
self._chat_completion_id: str | None = None
|
||||
|
||||
def _handle_string_chunk(
|
||||
self, str_line: Union[str, "BaseModel"]
|
||||
|
|
@ -1384,4 +1385,13 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
|||
ModelResponseStream: OpenAI-formatted streaming chunk
|
||||
"""
|
||||
verbose_logger.debug(f"Chat provider: transform_streaming_response called with chunk: {chunk}")
|
||||
return OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(chunk)
|
||||
return self._with_stream_scoped_id(
|
||||
OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(chunk)
|
||||
)
|
||||
|
||||
def _with_stream_scoped_id(self, chunk: "ModelResponseStream") -> "ModelResponseStream":
|
||||
if self._chat_completion_id is None:
|
||||
self._chat_completion_id = chunk.id
|
||||
else:
|
||||
chunk.id = self._chat_completion_id
|
||||
return chunk
|
||||
|
|
|
|||
|
|
@ -1420,6 +1420,8 @@ LITELLM_EXPIRED_UI_SESSION_KEY_CLEANUP_BATCH_SIZE = int(
|
|||
os.getenv("LITELLM_EXPIRED_UI_SESSION_KEY_CLEANUP_BATCH_SIZE", 1000)
|
||||
)
|
||||
LITELLM_PROXY_ADMIN_NAME = "default_user_id"
|
||||
LITELLM_PROXY_BUDGET_NAME = "litellm-proxy-budget"
|
||||
GLOBAL_PROXY_SPEND_CACHE_KEY = f"{LITELLM_PROXY_ADMIN_NAME}:spend"
|
||||
|
||||
########################### CLI SSO AUTHENTICATION CONSTANTS ###########################
|
||||
LITELLM_CLI_SOURCE_IDENTIFIER = "litellm-cli"
|
||||
|
|
@ -1457,6 +1459,7 @@ SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES = int(os.getenv("SPEND_LOG_CLEA
|
|||
SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS = float(
|
||||
os.getenv("SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS", 0.5)
|
||||
)
|
||||
TOOL_SPEND_TOP_TOOLS = 100
|
||||
SPEND_LOG_PARTITION_INTERVAL = os.getenv("SPEND_LOG_PARTITION_INTERVAL", "day")
|
||||
SPEND_LOG_PARTITION_PRECREATE_AHEAD = int(os.getenv("SPEND_LOG_PARTITION_PRECREATE_AHEAD", 7))
|
||||
SPEND_LOG_QUEUE_SIZE_THRESHOLD = int(os.getenv("SPEND_LOG_QUEUE_SIZE_THRESHOLD", 100))
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ from litellm.exceptions import (
|
|||
# proxy's metadata sanitizer.
|
||||
_PRE_CALL_EXECUTED_TOKEN = secrets.token_hex(16)
|
||||
|
||||
_GUARDRAIL_BLOCK_STATUS_CODES = frozenset({400, 403, 422})
|
||||
|
||||
_guardrail_self_recorded: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
||||
"litellm_guardrail_self_recorded", default=False
|
||||
)
|
||||
|
|
@ -1055,8 +1057,15 @@ class CustomGuardrail(CustomLogger):
|
|||
- GuardrailRaisedException (generic guardrail API, tool permission)
|
||||
- BlockedPiiEntityError (Presidio PII detection)
|
||||
- SensitiveDataRouteException (sensitive-data reroute to on-premise model)
|
||||
- HTTPException with status 400 (content policy violation)
|
||||
- HTTPException with a block-signalling status (400, 403, 422)
|
||||
- ModifyResponseException (passthrough mode violation)
|
||||
|
||||
Only the statuses guardrails use in-tree to signal a deliberate rejection
|
||||
count as an intervention: 400 (content policy), 403 (e.g. akto) and 422
|
||||
(e.g. llm_as_a_judge). Other 4xx codes are commonly propagated from an
|
||||
upstream guardrail provider response (401 bad key, 408 timeout, 429 rate
|
||||
limit, or a raw upstream status), which are technical failures, not
|
||||
blocks, so they stay guardrail_failed_to_respond.
|
||||
"""
|
||||
if isinstance(e, ModifyResponseException):
|
||||
return True
|
||||
|
|
@ -1069,7 +1078,11 @@ class CustomGuardrail(CustomLogger):
|
|||
),
|
||||
):
|
||||
return True
|
||||
if HTTPException is not None and isinstance(e, HTTPException) and e.status_code == 400:
|
||||
if (
|
||||
HTTPException is not None
|
||||
and isinstance(e, HTTPException)
|
||||
and e.status_code in _GUARDRAIL_BLOCK_STATUS_CODES
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,15 @@ class LangsmithLogger(CustomBatchLogger):
|
|||
"dotted_order": metadata.get("dotted_order", None),
|
||||
}
|
||||
|
||||
def _redact_metadata(self, metadata: dict) -> dict:
|
||||
# helper is shallow; also scrub nested requester_metadata since
|
||||
# LangSmith forwards the whole dict into the run
|
||||
redacted = redact_user_api_key_info(metadata=dict(metadata))
|
||||
nested = redacted.get("requester_metadata")
|
||||
if isinstance(nested, dict):
|
||||
redacted["requester_metadata"] = redact_user_api_key_info(metadata=nested)
|
||||
return redacted
|
||||
|
||||
def _build_extra_metadata(self, metadata: Dict):
|
||||
extra_metadata = dict(metadata)
|
||||
requester_metadata = extra_metadata.get("requester_metadata")
|
||||
|
|
@ -141,13 +150,7 @@ class LangsmithLogger(CustomBatchLogger):
|
|||
if key in requester_metadata and key not in extra_metadata:
|
||||
extra_metadata[key] = requester_metadata[key]
|
||||
|
||||
# helper is shallow; also scrub nested requester_metadata since
|
||||
# LangSmith forwards the whole dict into `extra`
|
||||
extra_metadata = redact_user_api_key_info(metadata=extra_metadata)
|
||||
nested = extra_metadata.get("requester_metadata")
|
||||
if isinstance(nested, dict):
|
||||
extra_metadata["requester_metadata"] = redact_user_api_key_info(metadata=nested)
|
||||
return extra_metadata
|
||||
return self._redact_metadata(extra_metadata)
|
||||
|
||||
def _build_outputs_with_usage(self, payload: StandardLoggingPayload) -> Dict[str, Any]:
|
||||
response = payload["response"]
|
||||
|
|
@ -200,12 +203,13 @@ class LangsmithLogger(CustomBatchLogger):
|
|||
|
||||
metadata = payload["metadata"]
|
||||
extra_metadata = self._build_extra_metadata(dict(metadata))
|
||||
inputs = {**payload, "metadata": self._redact_metadata(dict(metadata))}
|
||||
outputs = self._build_outputs_with_usage(payload)
|
||||
|
||||
data = {
|
||||
"name": fields["run_name"],
|
||||
"run_type": "llm",
|
||||
"inputs": payload,
|
||||
"inputs": inputs,
|
||||
"outputs": outputs,
|
||||
"session_name": fields["project_name"],
|
||||
"start_time": payload["startTime"],
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from litellm.integrations.otel.model.baggage import promoted_baggage
|
|||
from litellm.integrations.otel.model.config import OpenTelemetryV2Config
|
||||
from litellm.integrations.otel.plumbing.context import (
|
||||
is_recordable_span,
|
||||
mcp_message_transport_span,
|
||||
request_root_span,
|
||||
resolve_mcp_span_context,
|
||||
resolve_parent_context,
|
||||
|
|
@ -641,8 +642,14 @@ class OpenTelemetryV2(CustomLogger):
|
|||
endpoint, auth failure), so the failed request carries the same error keys
|
||||
a failed LLM call does. v1's ``OpenTelemetry`` implemented this same hook;
|
||||
v2 lost it when it stopped subclassing ``OpenTelemetry``, which is the
|
||||
LIT-4179 regression for pre-call failures."""
|
||||
span = request_root_span() or user_api_key_dict.parent_otel_span
|
||||
LIT-4179 regression for pre-call failures.
|
||||
|
||||
An MCP message is handled on the session's task, where the request-root
|
||||
anchor is whatever request opened the session, so prefer the transport the
|
||||
gateway published for this specific message. Without that, a failed tool
|
||||
call aimed its error at the ``initialize`` request's finished span and the
|
||||
SDK dropped it, leaving the POST that actually failed unmarked."""
|
||||
span = mcp_message_transport_span() or request_root_span() or user_api_key_dict.parent_otel_span
|
||||
if span is None or not is_recordable_span(span):
|
||||
return None
|
||||
stamp_error(span, _span_error_from_exception(original_exception, traceback_str=traceback_str))
|
||||
|
|
|
|||
|
|
@ -79,47 +79,67 @@ def reset_mcp_message_trace_carrier(token: "Token[Mapping[str, str] | None]") ->
|
|||
_mcp_message_trace_carrier.reset(token)
|
||||
|
||||
|
||||
# The transport span of the HTTP request carrying the CURRENT MCP message, as a
|
||||
# plain ``SpanContext`` so it can cross a task boundary.
|
||||
# The transport span of the HTTP request carrying the CURRENT MCP message.
|
||||
#
|
||||
# ``_request_root_span`` above cannot be used for MCP: a *stateful* streamable-HTTP
|
||||
# session runs every message on the single task spawned by that session's
|
||||
# ``initialize`` POST, so the ContextVar the ASGI request task writes at auth time
|
||||
# is frozen at ``initialize`` there and never sees the later ``tools/call`` POSTs.
|
||||
# Reading it from the message handler would parent every tool call in the session
|
||||
# to the first request's (already ended) server span. The gateway instead resolves
|
||||
# the current message's transport span on the request task and hands it over the
|
||||
# same way it hands over per-request auth, and the handler publishes it here for
|
||||
# the span emitter to pick up.
|
||||
_mcp_message_transport_span_context: "ContextVar[SpanContext | None]" = ContextVar(
|
||||
"litellm_otel_mcp_message_transport_span_context", default=None
|
||||
# Reading it from the message handler parents every tool call in the session to the
|
||||
# first request's server span and aims that call's ``error.*`` at it — a span that
|
||||
# ended long ago, so the SDK drops the write and the failure reaches no request at
|
||||
# all. The gateway instead resolves the current message's transport span on the
|
||||
# request task and hands it over the same way it hands over per-request auth, and
|
||||
# the handler publishes it here for the span emitter and the failure hook.
|
||||
_mcp_message_transport_span: "ContextVar[Span | None]" = ContextVar(
|
||||
"litellm_otel_mcp_message_transport_span", default=None
|
||||
)
|
||||
|
||||
|
||||
def set_mcp_message_transport_span_context(
|
||||
span_context: "SpanContext | None",
|
||||
) -> "Token[SpanContext | None]":
|
||||
def set_mcp_message_transport_span(span: object) -> "Token[Span | None]":
|
||||
"""Publish the transport span of the request carrying the current MCP message.
|
||||
|
||||
Also re-anchors the request root, so everything else the message emits or stamps
|
||||
— the identity attributes seeded onto the server span, a guardrail span, a
|
||||
proxy-level failure — lands on this request instead of on the one that opened
|
||||
the session. The MCP SDK dispatches each message on its own task, so the anchor
|
||||
is scoped to this message; the handler re-publishes it for the next one either
|
||||
way. Only a transport still open for writes is anchored: replacing the anchor
|
||||
with a request that already answered would just move the dropped writes from one
|
||||
finished span to another.
|
||||
|
||||
Takes ``object`` because the gateway reads it back out of the ASGI scope, whose
|
||||
values are untyped; anything that is not a usable span is stored as ``None``
|
||||
rather than trusted.
|
||||
|
||||
Returns the reset token; the caller must reset it once the message is handled
|
||||
so the transport never leaks to the next message on the same session task.
|
||||
"""
|
||||
return _mcp_message_transport_span_context.set(span_context)
|
||||
transport = span if isinstance(span, Span) and is_recordable_span(span) else None
|
||||
if transport is not None and transport.is_recording():
|
||||
set_request_root_span(transport)
|
||||
return _mcp_message_transport_span.set(transport)
|
||||
|
||||
|
||||
def reset_mcp_message_transport_span_context(token: "Token[SpanContext | None]") -> None:
|
||||
_mcp_message_transport_span_context.reset(token)
|
||||
def reset_mcp_message_transport_span(token: "Token[Span | None]") -> None:
|
||||
_mcp_message_transport_span.reset(token)
|
||||
|
||||
|
||||
def request_root_span_context() -> "SpanContext | None":
|
||||
"""The anchored request root span's context, safe to hand to another task.
|
||||
def mcp_message_transport_span() -> "Span | None":
|
||||
"""The published transport span, only while it is still open for writes.
|
||||
|
||||
A ``SpanContext`` is an immutable value, unlike the live ``Span``, so passing it
|
||||
across the MCP session-task boundary cannot keep a finished span alive or invite
|
||||
writes to it from the wrong request.
|
||||
Recording — not merely valid — is the bar here because this span is the target
|
||||
of ``error.*`` stamping from another task, and the publisher's validity check
|
||||
cannot speak for a span that has since ended. A finished span keeps a valid
|
||||
context forever, so it would otherwise be handed back for a write the SDK then
|
||||
refuses. The POST carrying a ``tools/call`` stays open until the result is
|
||||
written, so it is recording for the life of the call; a notification POST can
|
||||
answer first, and this returns ``None`` for it rather than writing into the void.
|
||||
"""
|
||||
span = request_root_span()
|
||||
return span.get_span_context() if span is not None else None
|
||||
span = _mcp_message_transport_span.get()
|
||||
if span is None or not span.is_recording():
|
||||
return None
|
||||
return span
|
||||
|
||||
|
||||
def _mcp_transport_span_context() -> "SpanContext | None":
|
||||
|
|
@ -127,12 +147,16 @@ def _mcp_transport_span_context() -> "SpanContext | None":
|
|||
|
||||
Prefers the transport the gateway published for this specific message; falls
|
||||
back to the ambient request anchor for paths that emit an MCP span on the
|
||||
request task itself (the REST MCP endpoints, the SDK).
|
||||
request task itself (the REST MCP endpoints, the SDK). Parenting and linking
|
||||
only need the immutable context, and unlike ``mcp_message_transport_span`` they
|
||||
stay correct against a transport that has already finished, so this does not
|
||||
require the span to still be recording.
|
||||
"""
|
||||
published = _mcp_message_transport_span_context.get()
|
||||
if published is not None and published.is_valid:
|
||||
return published
|
||||
return request_root_span_context()
|
||||
published = _mcp_message_transport_span.get()
|
||||
if published is not None:
|
||||
return published.get_span_context()
|
||||
span = request_root_span()
|
||||
return span.get_span_context() if span is not None else None
|
||||
|
||||
|
||||
def set_request_baggage(values: Mapping[str, str], context: Context | None = None) -> Context:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from typing import (
|
|||
Dict,
|
||||
List,
|
||||
Literal,
|
||||
Mapping,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
|
|
@ -1449,6 +1450,8 @@ class PrometheusLogger(CustomLogger):
|
|||
prompt_details = usage_object.get("prompt_tokens_details") or {}
|
||||
completion_details = usage_object.get("completion_tokens_details") or {}
|
||||
|
||||
cache_creation_detail_tokens = PrometheusLogger._resolve_cache_write_tokens(prompt_details)
|
||||
|
||||
detail_metrics: List[Tuple[Any, DEFINED_PROMETHEUS_METRICS, Any]] = [
|
||||
(
|
||||
self.litellm_input_cached_tokens_metric,
|
||||
|
|
@ -1458,7 +1461,7 @@ class PrometheusLogger(CustomLogger):
|
|||
(
|
||||
self.litellm_input_cache_creation_tokens_metric,
|
||||
"litellm_input_cache_creation_tokens_metric",
|
||||
(prompt_details.get("cache_creation_tokens") if isinstance(prompt_details, dict) else None),
|
||||
cache_creation_detail_tokens,
|
||||
),
|
||||
(
|
||||
self.litellm_input_audio_tokens_metric,
|
||||
|
|
@ -1597,27 +1600,12 @@ class PrometheusLogger(CustomLogger):
|
|||
)
|
||||
|
||||
# Provider prompt caching metrics are independent of LiteLLM cache_hit.
|
||||
provider_cache_read_tokens = 0
|
||||
provider_cache_creation_tokens = 0
|
||||
usage_obj = (standard_logging_payload.get("metadata", {}) or {}).get("usage_object")
|
||||
if isinstance(usage_obj, dict):
|
||||
# Prefer explicit provider cache fields when available.
|
||||
_read = usage_obj.get("cache_read_input_tokens")
|
||||
_write = usage_obj.get("cache_creation_input_tokens")
|
||||
|
||||
if isinstance(_read, int):
|
||||
provider_cache_read_tokens = _read
|
||||
if isinstance(_write, int):
|
||||
provider_cache_creation_tokens = _write
|
||||
|
||||
# Fallback to prompt_tokens_details.cached_tokens (common normalization point).
|
||||
# Only fallback when the explicit field is genuinely absent (None).
|
||||
if _read is None:
|
||||
prompt_details = usage_obj.get("prompt_tokens_details")
|
||||
if isinstance(prompt_details, dict):
|
||||
cached_tokens = prompt_details.get("cached_tokens")
|
||||
if isinstance(cached_tokens, int):
|
||||
provider_cache_read_tokens = cached_tokens
|
||||
(
|
||||
provider_cache_read_tokens,
|
||||
provider_cache_creation_tokens,
|
||||
) = PrometheusLogger._resolve_provider_cache_tokens(usage_obj)
|
||||
|
||||
if provider_cache_read_tokens > 0:
|
||||
PrometheusLogger._inc_labeled_counter(
|
||||
|
|
@ -1639,6 +1627,40 @@ class PrometheusLogger(CustomLogger):
|
|||
amount=float(provider_cache_creation_tokens),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_provider_cache_tokens(usage_obj: Mapping[str, object]) -> tuple[int, int]:
|
||||
# Prefer explicit provider cache fields when available.
|
||||
_read = usage_obj.get("cache_read_input_tokens")
|
||||
_write = usage_obj.get("cache_creation_input_tokens")
|
||||
|
||||
provider_cache_read_tokens = _read if isinstance(_read, int) else 0
|
||||
provider_cache_creation_tokens = _write if isinstance(_write, int) else 0
|
||||
|
||||
# Fallback to prompt_tokens_details (common normalization point).
|
||||
# Only fallback when the explicit field is genuinely absent (None).
|
||||
prompt_details = usage_obj.get("prompt_tokens_details")
|
||||
if _read is None and isinstance(prompt_details, dict):
|
||||
cached_tokens = prompt_details.get("cached_tokens")
|
||||
if isinstance(cached_tokens, int):
|
||||
provider_cache_read_tokens = cached_tokens
|
||||
|
||||
if _write is None:
|
||||
write_tokens = PrometheusLogger._resolve_cache_write_tokens(prompt_details)
|
||||
if write_tokens is not None:
|
||||
provider_cache_creation_tokens = write_tokens
|
||||
|
||||
return provider_cache_read_tokens, provider_cache_creation_tokens
|
||||
|
||||
@staticmethod
|
||||
def _resolve_cache_write_tokens(prompt_details: object) -> int | None:
|
||||
if not isinstance(prompt_details, dict):
|
||||
return None
|
||||
for key in ("cache_write_tokens", "cache_creation_tokens"):
|
||||
value = prompt_details.get(key)
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
return value
|
||||
return None
|
||||
|
||||
def _increment_mcp_tool_call_metrics(
|
||||
self,
|
||||
standard_logging_payload: StandardLoggingPayload,
|
||||
|
|
|
|||
|
|
@ -1878,7 +1878,14 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
elif standard_logging_object is not None:
|
||||
self.model_call_details["standard_logging_object"] = standard_logging_object
|
||||
else:
|
||||
self.model_call_details["response_cost"] = None
|
||||
# Streaming reaches here before its cost is known, so the cost
|
||||
# is seeded to None, but only when nothing has already
|
||||
# established one. A stream that assembles into a response
|
||||
# object recomputes the cost right after this; a pass-through
|
||||
# stream cannot (its body is opaque) and carries the cost its
|
||||
# upstream reported in the response headers, which an
|
||||
# unconditional reset would discard.
|
||||
self.model_call_details.setdefault("response_cost", None)
|
||||
|
||||
result = self._transform_usage_objects(result=result)
|
||||
|
||||
|
|
@ -5547,18 +5554,6 @@ def scrub_sensitive_keys_in_metadata(litellm_params: Optional[dict]):
|
|||
litellm_params["_langfuse_masking_function"] = masking_fn
|
||||
litellm_params["metadata"] = metadata
|
||||
|
||||
## check user_api_key_metadata for sensitive logging keys
|
||||
cleaned_user_api_key_metadata = {}
|
||||
if "user_api_key_metadata" in metadata and isinstance(metadata["user_api_key_metadata"], dict):
|
||||
for k, v in metadata["user_api_key_metadata"].items():
|
||||
if k == "logging": # prevent logging user logging keys
|
||||
cleaned_user_api_key_metadata[k] = "scrubbed_by_litellm_for_sensitive_keys"
|
||||
else:
|
||||
cleaned_user_api_key_metadata[k] = v
|
||||
|
||||
metadata["user_api_key_metadata"] = cleaned_user_api_key_metadata
|
||||
litellm_params["metadata"] = metadata
|
||||
|
||||
return litellm_params
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import mimetypes
|
|||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from enum import Enum
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, TypedDict, Union, cast, overload
|
||||
|
||||
from jinja2.sandbox import ImmutableSandboxedEnvironment
|
||||
|
|
@ -5350,7 +5351,9 @@ def prompt_factory(
|
|||
def get_attribute_or_key(tool_or_function, attribute, default=None):
|
||||
if hasattr(tool_or_function, attribute):
|
||||
return getattr(tool_or_function, attribute)
|
||||
return tool_or_function.get(attribute, default)
|
||||
if isinstance(tool_or_function, Mapping):
|
||||
return tool_or_function.get(attribute, default)
|
||||
return default
|
||||
|
||||
|
||||
class NormalizedToolCall(TypedDict):
|
||||
|
|
@ -5379,14 +5382,18 @@ def _parse_tool_call_arguments(raw: Any, tool_name: Optional[str], context: str)
|
|||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def _tool_calls_from_chat_completion_response(response: Any) -> list[NormalizedToolCall]:
|
||||
def _tool_calls_from_chat_completion_response(
|
||||
response: Any, include_all_choices: bool = False
|
||||
) -> list[NormalizedToolCall]:
|
||||
choices = get_attribute_or_key(response, "choices", None)
|
||||
if not (isinstance(choices, list) and choices):
|
||||
return []
|
||||
message = get_attribute_or_key(choices[0], "message", None)
|
||||
tool_calls = get_attribute_or_key(message, "tool_calls", None) if message else None
|
||||
if not isinstance(tool_calls, list):
|
||||
return []
|
||||
tool_calls: list[Any] = []
|
||||
for choice in choices if include_all_choices else choices[:1]:
|
||||
message = get_attribute_or_key(choice, "message", None)
|
||||
choice_tool_calls = get_attribute_or_key(message, "tool_calls", None) if message else None
|
||||
if isinstance(choice_tool_calls, list):
|
||||
tool_calls.extend(choice_tool_calls)
|
||||
result: list[NormalizedToolCall] = []
|
||||
for tc in tool_calls:
|
||||
fn = get_attribute_or_key(tc, "function", None)
|
||||
|
|
@ -5449,7 +5456,7 @@ def _tool_calls_from_anthropic_messages_response(response: Any) -> list[Normaliz
|
|||
return result
|
||||
|
||||
|
||||
def get_tool_calls_from_response(response: Any) -> list[NormalizedToolCall]:
|
||||
def get_tool_calls_from_response(response: Any, include_all_choices: bool = False) -> list[NormalizedToolCall]:
|
||||
"""
|
||||
Extract tool/function calls from a response object into a normalized
|
||||
``{"id", "name", "arguments"}`` shape, regardless of which API surface
|
||||
|
|
@ -5457,11 +5464,20 @@ def get_tool_calls_from_response(response: Any) -> list[NormalizedToolCall]:
|
|||
the Responses API (``output`` items of type ``function_call``), or the
|
||||
Anthropic Messages API (``content`` blocks of type ``tool_use``).
|
||||
|
||||
``include_all_choices`` decides the chat-completions scope: the default
|
||||
reads only ``choices[0]``, which is what consumers that act on THE reply
|
||||
(e.g. guardrails rebuilding the primary assistant message) want; usage
|
||||
accounting passes True because every choice of an ``n>1`` request costs
|
||||
money and its tool calls really ran. The other surfaces have a single
|
||||
output, so the flag has no effect on them.
|
||||
|
||||
Callers that only care about a specific tool should filter the result by
|
||||
``name`` themselves -- this returns every tool call found.
|
||||
"""
|
||||
chat_tool_calls = _tool_calls_from_chat_completion_response(response, include_all_choices=include_all_choices)
|
||||
if chat_tool_calls:
|
||||
return chat_tool_calls
|
||||
for extractor in (
|
||||
_tool_calls_from_chat_completion_response,
|
||||
_tool_calls_from_responses_api_response,
|
||||
_tool_calls_from_anthropic_messages_response,
|
||||
):
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import litellm
|
|||
import litellm.constants as _c
|
||||
from litellm.litellm_core_utils.url_utils import validate_url
|
||||
from litellm.llms.anthropic.common_utils import strip_advisor_blocks_from_messages
|
||||
from litellm.router_utils.cooldown_handlers import mark_advisor_orchestration_failure
|
||||
from litellm.types.llms.anthropic_messages.anthropic_response import (
|
||||
AnthropicMessagesResponse,
|
||||
)
|
||||
|
|
@ -124,30 +125,36 @@ class AdvisorOrchestrationHandler(MessagesInterceptor):
|
|||
|
||||
iteration += 1
|
||||
if iteration > max_uses:
|
||||
raise AdvisorMaxIterationsError(
|
||||
max_iterations_error = AdvisorMaxIterationsError(
|
||||
f"Advisor orchestration loop exceeded max_uses={max_uses}. "
|
||||
"Increase max_uses in the advisor tool definition or cap the request."
|
||||
)
|
||||
mark_advisor_orchestration_failure(max_iterations_error)
|
||||
raise max_iterations_error
|
||||
|
||||
# --- Build advisor context ---
|
||||
advisor_messages = _build_advisor_context(current_messages, executor_response, advisor_use_block)
|
||||
|
||||
# --- Advisor sub-call (always non-streaming, no tools) ---
|
||||
advisor_response: AnthropicMessagesResponse = await _call_messages_handler(
|
||||
model=advisor_model,
|
||||
messages=advisor_messages,
|
||||
tools=None,
|
||||
stream=False,
|
||||
max_tokens=max_tokens,
|
||||
custom_llm_provider=None, # let litellm resolve from model name
|
||||
metadata={
|
||||
**metadata_base,
|
||||
"advisor_sub_call": True,
|
||||
"parent_request_id": parent_request_id,
|
||||
},
|
||||
api_key=advisor_api_key,
|
||||
api_base=advisor_api_base,
|
||||
)
|
||||
try:
|
||||
advisor_response: AnthropicMessagesResponse = await _call_messages_handler(
|
||||
model=advisor_model,
|
||||
messages=advisor_messages,
|
||||
tools=None,
|
||||
stream=False,
|
||||
max_tokens=max_tokens,
|
||||
custom_llm_provider=None, # let litellm resolve from model name
|
||||
metadata={
|
||||
**metadata_base,
|
||||
"advisor_sub_call": True,
|
||||
"parent_request_id": parent_request_id,
|
||||
},
|
||||
api_key=advisor_api_key,
|
||||
api_base=advisor_api_base,
|
||||
)
|
||||
except Exception as advisor_sub_call_exception:
|
||||
mark_advisor_orchestration_failure(advisor_sub_call_exception)
|
||||
raise
|
||||
|
||||
advisor_text = _extract_response_text(advisor_response)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
Azure Batches API Handler
|
||||
"""
|
||||
|
||||
from typing import Any, Coroutine, Optional, Union, cast
|
||||
from collections.abc import Coroutine
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
from openai import AsyncOpenAI, OpenAI
|
||||
|
|
@ -33,32 +34,30 @@ class AzureBatchesAPI(BaseAzureLLM):
|
|||
async def acreate_batch(
|
||||
self,
|
||||
create_batch_data: CreateBatchRequest,
|
||||
azure_client: Union[AsyncAzureOpenAI, AsyncOpenAI],
|
||||
azure_client: AsyncAzureOpenAI | AsyncOpenAI,
|
||||
) -> LiteLLMBatch:
|
||||
response = await azure_client.batches.create(**create_batch_data) # type: ignore[arg-type]
|
||||
return LiteLLMBatch(**response.model_dump())
|
||||
return LiteLLMBatch.model_validate(response.model_dump())
|
||||
|
||||
def create_batch(
|
||||
self,
|
||||
_is_async: bool,
|
||||
create_batch_data: CreateBatchRequest,
|
||||
api_key: Optional[str],
|
||||
api_base: Optional[str],
|
||||
api_version: Optional[str],
|
||||
timeout: Union[float, httpx.Timeout],
|
||||
max_retries: Optional[int],
|
||||
client: Optional[Union[AzureOpenAI, AsyncAzureOpenAI, OpenAI, AsyncOpenAI]] = None,
|
||||
litellm_params: Optional[dict] = None,
|
||||
) -> Union[LiteLLMBatch, Coroutine[Any, Any, LiteLLMBatch]]:
|
||||
azure_client: Optional[Union[AzureOpenAI, AsyncAzureOpenAI, OpenAI, AsyncOpenAI]] = (
|
||||
self.get_azure_openai_client(
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
api_version=api_version,
|
||||
client=client,
|
||||
_is_async=_is_async,
|
||||
litellm_params=litellm_params or {},
|
||||
)
|
||||
api_key: str | None,
|
||||
api_base: str | None,
|
||||
api_version: str | None,
|
||||
timeout: float | httpx.Timeout,
|
||||
max_retries: int | None,
|
||||
client: AzureOpenAI | AsyncAzureOpenAI | OpenAI | AsyncOpenAI | None = None,
|
||||
litellm_params: dict | None = None,
|
||||
) -> LiteLLMBatch | Coroutine[object, object, LiteLLMBatch]:
|
||||
azure_client: AzureOpenAI | AsyncAzureOpenAI | OpenAI | AsyncOpenAI | None = self.get_azure_openai_client(
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
api_version=api_version,
|
||||
client=client,
|
||||
_is_async=_is_async,
|
||||
litellm_params=litellm_params or {},
|
||||
)
|
||||
if azure_client is None:
|
||||
raise ValueError(
|
||||
|
|
@ -73,38 +72,36 @@ class AzureBatchesAPI(BaseAzureLLM):
|
|||
return self.acreate_batch( # type: ignore
|
||||
create_batch_data=create_batch_data, azure_client=azure_client
|
||||
)
|
||||
response = cast(Union[AzureOpenAI, OpenAI], azure_client).batches.create(**create_batch_data) # type: ignore[arg-type]
|
||||
return LiteLLMBatch(**response.model_dump())
|
||||
response = cast(AzureOpenAI | OpenAI, azure_client).batches.create(**create_batch_data) # type: ignore[arg-type]
|
||||
return LiteLLMBatch.model_validate(response.model_dump())
|
||||
|
||||
async def aretrieve_batch(
|
||||
self,
|
||||
retrieve_batch_data: RetrieveBatchRequest,
|
||||
client: Union[AsyncAzureOpenAI, AsyncOpenAI],
|
||||
client: AsyncAzureOpenAI | AsyncOpenAI,
|
||||
) -> LiteLLMBatch:
|
||||
response = await client.batches.retrieve(**retrieve_batch_data) # type: ignore[arg-type]
|
||||
return LiteLLMBatch(**response.model_dump())
|
||||
return LiteLLMBatch.model_validate(response.model_dump())
|
||||
|
||||
def retrieve_batch(
|
||||
self,
|
||||
_is_async: bool,
|
||||
retrieve_batch_data: RetrieveBatchRequest,
|
||||
api_key: Optional[str],
|
||||
api_base: Optional[str],
|
||||
api_version: Optional[str],
|
||||
timeout: Union[float, httpx.Timeout],
|
||||
max_retries: Optional[int],
|
||||
client: Optional[Union[AzureOpenAI, AsyncAzureOpenAI, OpenAI, AsyncOpenAI]] = None,
|
||||
litellm_params: Optional[dict] = None,
|
||||
api_key: str | None,
|
||||
api_base: str | None,
|
||||
api_version: str | None,
|
||||
timeout: float | httpx.Timeout,
|
||||
max_retries: int | None,
|
||||
client: AzureOpenAI | AsyncAzureOpenAI | OpenAI | AsyncOpenAI | None = None,
|
||||
litellm_params: dict | None = None,
|
||||
):
|
||||
azure_client: Optional[Union[AzureOpenAI, AsyncAzureOpenAI, OpenAI, AsyncOpenAI]] = (
|
||||
self.get_azure_openai_client(
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
api_version=api_version,
|
||||
client=client,
|
||||
_is_async=_is_async,
|
||||
litellm_params=litellm_params or {},
|
||||
)
|
||||
azure_client: AzureOpenAI | AsyncAzureOpenAI | OpenAI | AsyncOpenAI | None = self.get_azure_openai_client(
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
api_version=api_version,
|
||||
client=client,
|
||||
_is_async=_is_async,
|
||||
litellm_params=litellm_params or {},
|
||||
)
|
||||
if azure_client is None:
|
||||
raise ValueError(
|
||||
|
|
@ -119,38 +116,36 @@ class AzureBatchesAPI(BaseAzureLLM):
|
|||
return self.aretrieve_batch( # type: ignore
|
||||
retrieve_batch_data=retrieve_batch_data, client=azure_client
|
||||
)
|
||||
response = cast(Union[AzureOpenAI, OpenAI], azure_client).batches.retrieve(**retrieve_batch_data)
|
||||
return LiteLLMBatch(**response.model_dump())
|
||||
response = cast(AzureOpenAI | OpenAI, azure_client).batches.retrieve(**retrieve_batch_data)
|
||||
return LiteLLMBatch.model_validate(response.model_dump())
|
||||
|
||||
async def acancel_batch(
|
||||
self,
|
||||
cancel_batch_data: CancelBatchRequest,
|
||||
client: Union[AsyncAzureOpenAI, AsyncOpenAI],
|
||||
client: AsyncAzureOpenAI | AsyncOpenAI,
|
||||
) -> LiteLLMBatch:
|
||||
response = await client.batches.cancel(**cancel_batch_data)
|
||||
return LiteLLMBatch(**response.model_dump())
|
||||
return LiteLLMBatch.model_validate(response.model_dump())
|
||||
|
||||
def cancel_batch(
|
||||
self,
|
||||
_is_async: bool,
|
||||
cancel_batch_data: CancelBatchRequest,
|
||||
api_key: Optional[str],
|
||||
api_base: Optional[str],
|
||||
api_version: Optional[str],
|
||||
timeout: Union[float, httpx.Timeout],
|
||||
max_retries: Optional[int],
|
||||
client: Optional[Union[AzureOpenAI, AsyncAzureOpenAI, OpenAI, AsyncOpenAI]] = None,
|
||||
litellm_params: Optional[dict] = None,
|
||||
api_key: str | None,
|
||||
api_base: str | None,
|
||||
api_version: str | None,
|
||||
timeout: float | httpx.Timeout,
|
||||
max_retries: int | None,
|
||||
client: AzureOpenAI | AsyncAzureOpenAI | OpenAI | AsyncOpenAI | None = None,
|
||||
litellm_params: dict | None = None,
|
||||
):
|
||||
azure_client: Optional[Union[AzureOpenAI, AsyncAzureOpenAI, OpenAI, AsyncOpenAI]] = (
|
||||
self.get_azure_openai_client(
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
api_version=api_version,
|
||||
client=client,
|
||||
_is_async=_is_async,
|
||||
litellm_params=litellm_params or {},
|
||||
)
|
||||
azure_client: AzureOpenAI | AsyncAzureOpenAI | OpenAI | AsyncOpenAI | None = self.get_azure_openai_client(
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
api_version=api_version,
|
||||
client=client,
|
||||
_is_async=_is_async,
|
||||
litellm_params=litellm_params or {},
|
||||
)
|
||||
if azure_client is None:
|
||||
raise ValueError(
|
||||
|
|
@ -172,13 +167,13 @@ class AzureBatchesAPI(BaseAzureLLM):
|
|||
"Azure client is not an instance of AzureOpenAI or OpenAI. Make sure you passed a sync client."
|
||||
)
|
||||
response = azure_client.batches.cancel(**cancel_batch_data)
|
||||
return LiteLLMBatch(**response.model_dump())
|
||||
return LiteLLMBatch.model_validate(response.model_dump())
|
||||
|
||||
async def alist_batches(
|
||||
self,
|
||||
client: Union[AsyncAzureOpenAI, AsyncOpenAI],
|
||||
after: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
client: AsyncAzureOpenAI | AsyncOpenAI,
|
||||
after: str | None = None,
|
||||
limit: int | None = None,
|
||||
):
|
||||
response = await client.batches.list(after=after, limit=limit) # type: ignore
|
||||
return response
|
||||
|
|
@ -186,25 +181,23 @@ class AzureBatchesAPI(BaseAzureLLM):
|
|||
def list_batches(
|
||||
self,
|
||||
_is_async: bool,
|
||||
api_key: Optional[str],
|
||||
api_base: Optional[str],
|
||||
api_version: Optional[str],
|
||||
timeout: Union[float, httpx.Timeout],
|
||||
max_retries: Optional[int],
|
||||
after: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
client: Optional[Union[AzureOpenAI, AsyncAzureOpenAI, OpenAI, AsyncOpenAI]] = None,
|
||||
litellm_params: Optional[dict] = None,
|
||||
api_key: str | None,
|
||||
api_base: str | None,
|
||||
api_version: str | None,
|
||||
timeout: float | httpx.Timeout,
|
||||
max_retries: int | None,
|
||||
after: str | None = None,
|
||||
limit: int | None = None,
|
||||
client: AzureOpenAI | AsyncAzureOpenAI | OpenAI | AsyncOpenAI | None = None,
|
||||
litellm_params: dict | None = None,
|
||||
):
|
||||
azure_client: Optional[Union[AzureOpenAI, AsyncAzureOpenAI, OpenAI, AsyncOpenAI]] = (
|
||||
self.get_azure_openai_client(
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
api_version=api_version,
|
||||
client=client,
|
||||
_is_async=_is_async,
|
||||
litellm_params=litellm_params or {},
|
||||
)
|
||||
azure_client: AzureOpenAI | AsyncAzureOpenAI | OpenAI | AsyncOpenAI | None = self.get_azure_openai_client(
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
api_version=api_version,
|
||||
client=client,
|
||||
_is_async=_is_async,
|
||||
litellm_params=litellm_params or {},
|
||||
)
|
||||
if azure_client is None:
|
||||
raise ValueError(
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ Azure AI Anthropic CountTokens API transformation logic.
|
|||
Extends the base Anthropic CountTokens transformation with Azure authentication.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from litellm.constants import ANTHROPIC_TOKEN_COUNTING_BETA_VERSION
|
||||
from litellm.llms.anthropic.count_tokens.transformation import (
|
||||
AnthropicCountTokensConfig,
|
||||
|
|
@ -25,8 +23,8 @@ class AzureAIAnthropicCountTokensConfig(AnthropicCountTokensConfig):
|
|||
def get_required_headers(
|
||||
self,
|
||||
api_key: str,
|
||||
litellm_params: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, str]:
|
||||
litellm_params: dict[str, object] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Get the required headers for the Azure AI Anthropic CountTokens API.
|
||||
|
||||
|
|
@ -53,7 +51,7 @@ class AzureAIAnthropicCountTokensConfig(AnthropicCountTokensConfig):
|
|||
if "api_key" not in litellm_params:
|
||||
litellm_params["api_key"] = api_key
|
||||
|
||||
litellm_params_obj = GenericLiteLLMParams(**litellm_params)
|
||||
litellm_params_obj = GenericLiteLLMParams.model_validate(litellm_params)
|
||||
|
||||
# Get Azure auth headers (api-key or Authorization)
|
||||
azure_headers = BaseAzureLLM._base_validate_azure_environment(headers={}, litellm_params=litellm_params_obj)
|
||||
|
|
|
|||
|
|
@ -78,8 +78,10 @@ class BaseAWSLLM:
|
|||
# Storage is in-process memory only: default ``DualCache()`` has no Redis backend unless attached
|
||||
# elsewhere. Entry TTL: static access-key + secret + region use ``_get_default_ttl_for_boto3_credentials``
|
||||
# (~59 minutes); ambient env (``_auth_with_env_vars`` returns ``ttl=None``) uses ``InMemoryCache``'s
|
||||
# ``default_ttl`` (600 seconds / 10 minutes). AssumeRole, web identity, profiles, and explicit
|
||||
# session-token tuples are not cached — see ``get_credentials`` and ``_get_or_set_cached_credentials``.
|
||||
# ``default_ttl`` (600 seconds / 10 minutes); web identity STS credentials use
|
||||
# ``_get_default_ttl_for_boto3_credentials`` (~59 minutes), keyed on all aws_* credential args
|
||||
# plus ssl_verify. AssumeRole, profiles, and explicit session-token tuples are not cached — see
|
||||
# ``get_credentials`` and ``_get_or_set_cached_credentials``.
|
||||
_shared_iam_cache: ClassVar[DualCache] = DualCache()
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
|
@ -136,11 +138,12 @@ class BaseAWSLLM:
|
|||
which ``InMemoryCache.set_cache`` resolves to ``default_ttl`` (600 seconds / 10 minutes by
|
||||
default).
|
||||
|
||||
Used only for static access-key credentials and ambient credentials from
|
||||
Used for static access-key credentials, ambient credentials from
|
||||
``_auth_with_env_vars`` (including when skipping AssumeRole because the runtime identity
|
||||
already matches ``aws_role_name``).
|
||||
already matches ``aws_role_name``), and web identity STS credentials (plain
|
||||
non-refreshable ``Credentials`` cached ~59 min, inside the 3600s STS session).
|
||||
|
||||
AssumeRole, web identity exchange, profiles, and explicit session-token tuples are not
|
||||
AssumeRole, profiles, and explicit session-token tuples are not
|
||||
cached here — shared ``Credentials`` / refresh state must not span logical sessions.
|
||||
"""
|
||||
cache_key = self.get_cache_key(credential_args)
|
||||
|
|
@ -266,23 +269,26 @@ class BaseAWSLLM:
|
|||
# Credentials - boto3.Credentials
|
||||
# cache ttl - Optional[int]. If None, the credentials are not cached. Some auth flows have no expiry time.
|
||||
#
|
||||
# iam_cache: static keys and ambient env only (including skip-AssumeRole path).
|
||||
# Do not cache AssumeRole / web identity / profile / explicit session-token paths here.
|
||||
# iam_cache: static keys, ambient env (including skip-AssumeRole path), and web identity.
|
||||
# Do not cache AssumeRole / profile / explicit session-token paths here.
|
||||
#########################################################
|
||||
if self._is_auth_with_web_identity_token(
|
||||
aws_web_identity_token,
|
||||
aws_role_name,
|
||||
aws_session_name,
|
||||
):
|
||||
credentials, _cache_ttl = self._auth_with_web_identity_token(
|
||||
aws_web_identity_token=cast(str, aws_web_identity_token),
|
||||
aws_role_name=cast(str, aws_role_name),
|
||||
aws_session_name=cast(str, aws_session_name),
|
||||
aws_region_name=aws_region_name,
|
||||
aws_sts_endpoint=aws_sts_endpoint,
|
||||
aws_external_id=aws_external_id,
|
||||
return self._get_or_set_cached_credentials(
|
||||
args,
|
||||
lambda: self._auth_with_web_identity_token(
|
||||
aws_web_identity_token=cast(str, aws_web_identity_token),
|
||||
aws_role_name=cast(str, aws_role_name),
|
||||
aws_session_name=cast(str, aws_session_name),
|
||||
aws_region_name=aws_region_name,
|
||||
aws_sts_endpoint=aws_sts_endpoint,
|
||||
aws_external_id=aws_external_id,
|
||||
ssl_verify=ssl_verify,
|
||||
),
|
||||
)
|
||||
return credentials
|
||||
elif self._is_auth_with_aws_role(aws_role_name):
|
||||
# Same role (IRSA/ECS/EC2): ambient creds via _get_or_set_cached_credentials like the
|
||||
# default env branch; never pre-read cache (must run _is_already_running_as_role first).
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ from litellm.llms.bedrock.common_utils import build_mantle_messages_url
|
|||
from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import (
|
||||
AmazonAnthropicClaudeMessagesConfig,
|
||||
)
|
||||
from litellm.types.llms.anthropic_messages.anthropic_response import (
|
||||
AnthropicMessagesResponse,
|
||||
AnthropicUsage,
|
||||
)
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -103,6 +107,25 @@ class AmazonMantleMessagesConfig(AmazonAnthropicClaudeMessagesConfig):
|
|||
)
|
||||
return {**request, "model": model_id, **stream_fields}
|
||||
|
||||
def transform_anthropic_messages_response(
|
||||
self,
|
||||
model: str,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> AnthropicMessagesResponse:
|
||||
response = super().transform_anthropic_messages_response(
|
||||
model=model,
|
||||
raw_response=raw_response,
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
existing_usage: AnthropicUsage = response.get("usage") or AnthropicUsage()
|
||||
normalized_usage: AnthropicUsage = {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
**existing_usage,
|
||||
}
|
||||
return {**response, "usage": normalized_usage}
|
||||
|
||||
def get_async_streaming_response_iterator(
|
||||
self,
|
||||
model: str,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
OpenAI Evals API configuration and transformations
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from collections.abc import Mapping
|
||||
|
||||
import httpx
|
||||
|
||||
|
|
@ -31,6 +31,10 @@ from litellm.types.router import GenericLiteLLMParams
|
|||
from litellm.types.utils import LlmProviders
|
||||
|
||||
|
||||
def _parsed_response_json(raw_response: httpx.Response) -> Mapping[str, object]:
|
||||
return raw_response.json()
|
||||
|
||||
|
||||
class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
||||
"""OpenAI-specific Evals API configuration"""
|
||||
|
||||
|
|
@ -38,7 +42,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
def custom_llm_provider(self) -> LlmProviders:
|
||||
return LlmProviders.OPENAI
|
||||
|
||||
def validate_environment(self, headers: dict, litellm_params: Optional[GenericLiteLLMParams]) -> dict:
|
||||
def validate_environment(self, headers: dict, litellm_params: GenericLiteLLMParams | None) -> dict:
|
||||
"""Add OpenAI-specific headers"""
|
||||
import litellm
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
|
|
@ -61,9 +65,9 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
|
||||
def get_complete_url(
|
||||
self,
|
||||
api_base: Optional[str],
|
||||
api_base: str | None,
|
||||
endpoint: str,
|
||||
eval_id: Optional[str] = None,
|
||||
eval_id: str | None = None,
|
||||
) -> str:
|
||||
"""Get complete URL for OpenAI Evals API"""
|
||||
if api_base is None:
|
||||
|
|
@ -79,7 +83,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
create_request: CreateEvalRequest,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> Dict:
|
||||
) -> dict:
|
||||
"""Transform create eval request for OpenAI"""
|
||||
verbose_logger.debug("Transforming create eval request: %s", create_request)
|
||||
|
||||
|
|
@ -94,17 +98,17 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> Eval:
|
||||
"""Transform OpenAI response to Eval object"""
|
||||
response_json = raw_response.json()
|
||||
response_json = _parsed_response_json(raw_response)
|
||||
verbose_logger.debug("Transforming create eval response: %s", response_json)
|
||||
|
||||
return Eval(**response_json)
|
||||
return Eval.model_validate(response_json)
|
||||
|
||||
def transform_list_evals_request(
|
||||
self,
|
||||
list_params: ListEvalsParams,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> Tuple[str, Dict]:
|
||||
) -> tuple[str, dict]:
|
||||
"""Transform list evals request for OpenAI"""
|
||||
api_base = "https://api.openai.com"
|
||||
if litellm_params and litellm_params.api_base:
|
||||
|
|
@ -113,7 +117,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
url = self.get_complete_url(api_base=api_base, endpoint="evals")
|
||||
|
||||
# Build query parameters
|
||||
query_params: Dict[str, Any] = {}
|
||||
query_params: dict[str, object] = {}
|
||||
if "limit" in list_params and list_params["limit"]:
|
||||
query_params["limit"] = list_params["limit"]
|
||||
if "after" in list_params and list_params["after"]:
|
||||
|
|
@ -138,10 +142,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> ListEvalsResponse:
|
||||
"""Transform OpenAI response to ListEvalsResponse"""
|
||||
response_json = raw_response.json()
|
||||
response_json = _parsed_response_json(raw_response)
|
||||
verbose_logger.debug("Transforming list evals response: %s", response_json)
|
||||
|
||||
return ListEvalsResponse(**response_json)
|
||||
return ListEvalsResponse.model_validate(response_json)
|
||||
|
||||
def transform_get_eval_request(
|
||||
self,
|
||||
|
|
@ -149,7 +153,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> Tuple[str, Dict]:
|
||||
) -> tuple[str, dict]:
|
||||
"""Transform get eval request for OpenAI"""
|
||||
url = self.get_complete_url(api_base=api_base, endpoint="evals", eval_id=eval_id)
|
||||
|
||||
|
|
@ -163,10 +167,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> Eval:
|
||||
"""Transform OpenAI response to Eval object"""
|
||||
response_json = raw_response.json()
|
||||
response_json = _parsed_response_json(raw_response)
|
||||
verbose_logger.debug("Transforming get eval response: %s", response_json)
|
||||
|
||||
return Eval(**response_json)
|
||||
return Eval.model_validate(response_json)
|
||||
|
||||
def transform_update_eval_request(
|
||||
self,
|
||||
|
|
@ -175,7 +179,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> Tuple[str, Dict, Dict]:
|
||||
) -> tuple[str, dict, dict]:
|
||||
"""Transform update eval request for OpenAI"""
|
||||
url = self.get_complete_url(api_base=api_base, endpoint="evals", eval_id=eval_id)
|
||||
|
||||
|
|
@ -192,10 +196,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> Eval:
|
||||
"""Transform OpenAI response to Eval object"""
|
||||
response_json = raw_response.json()
|
||||
response_json = _parsed_response_json(raw_response)
|
||||
verbose_logger.debug("Transforming update eval response: %s", response_json)
|
||||
|
||||
return Eval(**response_json)
|
||||
return Eval.model_validate(response_json)
|
||||
|
||||
def transform_delete_eval_request(
|
||||
self,
|
||||
|
|
@ -203,7 +207,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> Tuple[str, Dict]:
|
||||
) -> tuple[str, dict]:
|
||||
"""Transform delete eval request for OpenAI"""
|
||||
url = self.get_complete_url(api_base=api_base, endpoint="evals", eval_id=eval_id)
|
||||
|
||||
|
|
@ -217,10 +221,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> DeleteEvalResponse:
|
||||
"""Transform OpenAI response to DeleteEvalResponse"""
|
||||
response_json = raw_response.json()
|
||||
response_json = _parsed_response_json(raw_response)
|
||||
verbose_logger.debug("Transforming delete eval response: %s", response_json)
|
||||
|
||||
return DeleteEvalResponse(**response_json)
|
||||
return DeleteEvalResponse.model_validate(response_json)
|
||||
|
||||
def transform_cancel_eval_request(
|
||||
self,
|
||||
|
|
@ -228,12 +232,12 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> Tuple[str, Dict, Dict]:
|
||||
) -> tuple[str, dict, dict]:
|
||||
"""Transform cancel eval request for OpenAI"""
|
||||
url = f"{self.get_complete_url(api_base=api_base, endpoint='evals', eval_id=eval_id)}/cancel"
|
||||
|
||||
# Empty body for cancel request
|
||||
request_body: Dict[str, Any] = {}
|
||||
request_body: dict[str, object] = {}
|
||||
|
||||
verbose_logger.debug("Cancel eval request - URL: %s", url)
|
||||
|
||||
|
|
@ -245,10 +249,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> CancelEvalResponse:
|
||||
"""Transform OpenAI response to CancelEvalResponse"""
|
||||
response_json = raw_response.json()
|
||||
response_json = _parsed_response_json(raw_response)
|
||||
verbose_logger.debug("Transforming cancel eval response: %s", response_json)
|
||||
|
||||
return CancelEvalResponse(**response_json)
|
||||
return CancelEvalResponse.model_validate(response_json)
|
||||
|
||||
# Run API Transformations
|
||||
def transform_create_run_request(
|
||||
|
|
@ -257,7 +261,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
create_request: CreateRunRequest,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> Tuple[str, Dict]:
|
||||
) -> tuple[str, dict]:
|
||||
"""Transform create run request for OpenAI"""
|
||||
api_base = "https://api.openai.com"
|
||||
if litellm_params and litellm_params.api_base:
|
||||
|
|
@ -279,10 +283,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> Run:
|
||||
"""Transform OpenAI response to Run object"""
|
||||
response_json = raw_response.json()
|
||||
response_json = _parsed_response_json(raw_response)
|
||||
verbose_logger.debug("Transforming create run response: %s", response_json)
|
||||
|
||||
return Run(**response_json)
|
||||
return Run.model_validate(response_json)
|
||||
|
||||
def transform_list_runs_request(
|
||||
self,
|
||||
|
|
@ -290,7 +294,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
list_params: ListRunsParams,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> Tuple[str, Dict]:
|
||||
) -> tuple[str, dict]:
|
||||
"""Transform list runs request for OpenAI"""
|
||||
api_base = "https://api.openai.com"
|
||||
if litellm_params and litellm_params.api_base:
|
||||
|
|
@ -300,7 +304,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
url = f"{api_base}/v1/evals/{encoded_eval_id}/runs"
|
||||
|
||||
# Build query parameters
|
||||
query_params: Dict[str, Any] = {}
|
||||
query_params: dict[str, object] = {}
|
||||
if "limit" in list_params and list_params["limit"]:
|
||||
query_params["limit"] = list_params["limit"]
|
||||
if "after" in list_params and list_params["after"]:
|
||||
|
|
@ -323,10 +327,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> ListRunsResponse:
|
||||
"""Transform OpenAI response to ListRunsResponse"""
|
||||
response_json = raw_response.json()
|
||||
response_json = _parsed_response_json(raw_response)
|
||||
verbose_logger.debug("Transforming list runs response: %s", response_json)
|
||||
|
||||
return ListRunsResponse(**response_json)
|
||||
return ListRunsResponse.model_validate(response_json)
|
||||
|
||||
def transform_get_run_request(
|
||||
self,
|
||||
|
|
@ -335,7 +339,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> Tuple[str, Dict]:
|
||||
) -> tuple[str, dict]:
|
||||
"""Transform get run request for OpenAI"""
|
||||
encoded_eval_id = encode_url_path_segment(eval_id, field_name="eval_id")
|
||||
encoded_run_id = encode_url_path_segment(run_id, field_name="run_id")
|
||||
|
|
@ -351,10 +355,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> Run:
|
||||
"""Transform OpenAI response to Run object"""
|
||||
response_json = raw_response.json()
|
||||
response_json = _parsed_response_json(raw_response)
|
||||
verbose_logger.debug("Transforming get run response: %s", response_json)
|
||||
|
||||
return Run(**response_json)
|
||||
return Run.model_validate(response_json)
|
||||
|
||||
def transform_cancel_run_request(
|
||||
self,
|
||||
|
|
@ -363,14 +367,14 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> Tuple[str, Dict, Dict]:
|
||||
) -> tuple[str, dict, dict]:
|
||||
"""Transform cancel run request for OpenAI"""
|
||||
encoded_eval_id = encode_url_path_segment(eval_id, field_name="eval_id")
|
||||
encoded_run_id = encode_url_path_segment(run_id, field_name="run_id")
|
||||
url = f"{api_base}/v1/evals/{encoded_eval_id}/runs/{encoded_run_id}/cancel"
|
||||
|
||||
# Empty body for cancel request
|
||||
request_body: Dict[str, Any] = {}
|
||||
request_body: dict[str, object] = {}
|
||||
|
||||
verbose_logger.debug("Cancel run request - URL: %s", url)
|
||||
|
||||
|
|
@ -382,10 +386,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> CancelRunResponse:
|
||||
"""Transform OpenAI response to CancelRunResponse"""
|
||||
response_json = raw_response.json()
|
||||
response_json = _parsed_response_json(raw_response)
|
||||
verbose_logger.debug("Transforming cancel run response: %s", response_json)
|
||||
|
||||
return CancelRunResponse(**response_json)
|
||||
return CancelRunResponse.model_validate(response_json)
|
||||
|
||||
def transform_delete_run_request(
|
||||
self,
|
||||
|
|
@ -394,14 +398,14 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> Tuple[str, Dict, Dict]:
|
||||
) -> tuple[str, dict, dict]:
|
||||
"""Transform delete run request for OpenAI"""
|
||||
encoded_eval_id = encode_url_path_segment(eval_id, field_name="eval_id")
|
||||
encoded_run_id = encode_url_path_segment(run_id, field_name="run_id")
|
||||
url = f"{api_base}/v1/evals/{encoded_eval_id}/runs/{encoded_run_id}"
|
||||
|
||||
# Empty body for delete request
|
||||
request_body: Dict[str, Any] = {}
|
||||
request_body: dict[str, object] = {}
|
||||
|
||||
verbose_logger.debug("Delete run request - URL: %s", url)
|
||||
|
||||
|
|
@ -413,7 +417,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> RunDeleteResponse:
|
||||
"""Transform OpenAI response to RunDeleteResponse"""
|
||||
response_json = raw_response.json()
|
||||
response_json = _parsed_response_json(raw_response)
|
||||
verbose_logger.debug("Transforming delete run response: %s", response_json)
|
||||
|
||||
return RunDeleteResponse(**response_json)
|
||||
return RunDeleteResponse.model_validate(response_json)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
from collections.abc import Callable, Mapping, Sequence
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Dict,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Tuple,
|
||||
Protocol,
|
||||
Union,
|
||||
get_args,
|
||||
get_origin,
|
||||
|
|
@ -17,10 +15,10 @@ from pydantic import fields as pyd_fields
|
|||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.core_helpers import process_response_headers
|
||||
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
|
||||
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import (
|
||||
_safe_convert_created_field,
|
||||
)
|
||||
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
|
||||
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import (
|
||||
|
|
@ -47,8 +45,15 @@ else:
|
|||
LiteLLMLoggingObj = Any
|
||||
|
||||
|
||||
class _EventModelClass(Protocol):
|
||||
@property
|
||||
def model_fields(self) -> Mapping[str, pyd_fields.FieldInfo]: ...
|
||||
|
||||
def model_validate(self, obj: Mapping[str, object]) -> ResponsesAPIStreamingResponse: ...
|
||||
|
||||
|
||||
class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
||||
_SUPPORTED_OPTIONAL_PARAMS: List[str] = [
|
||||
_SUPPORTED_OPTIONAL_PARAMS: list[str] = [
|
||||
# Doc-listed knobs
|
||||
"instructions",
|
||||
"max_output_tokens",
|
||||
|
|
@ -89,9 +94,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
supported.remove("metadata")
|
||||
return supported
|
||||
|
||||
def get_error_class(
|
||||
self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers]
|
||||
) -> VolcEngineError:
|
||||
def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> VolcEngineError:
|
||||
typed_headers: httpx.Headers = headers if isinstance(headers, httpx.Headers) else httpx.Headers(headers or {})
|
||||
return VolcEngineError(
|
||||
status_code=status_code,
|
||||
|
|
@ -99,14 +102,14 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
headers=typed_headers,
|
||||
)
|
||||
|
||||
def validate_environment(self, headers: dict, model: str, litellm_params: Optional[GenericLiteLLMParams]) -> dict:
|
||||
def validate_environment(self, headers: dict, model: str, litellm_params: GenericLiteLLMParams | None) -> dict:
|
||||
"""
|
||||
Build auth headers for Volcengine Responses API.
|
||||
"""
|
||||
if litellm_params is None:
|
||||
litellm_params = GenericLiteLLMParams()
|
||||
elif isinstance(litellm_params, dict):
|
||||
litellm_params = GenericLiteLLMParams(**litellm_params)
|
||||
litellm_params = GenericLiteLLMParams.model_validate(litellm_params)
|
||||
|
||||
api_key = (
|
||||
litellm_params.api_key
|
||||
|
|
@ -122,7 +125,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
|
||||
def get_complete_url(
|
||||
self,
|
||||
api_base: Optional[str],
|
||||
api_base: str | None,
|
||||
litellm_params: dict,
|
||||
) -> str:
|
||||
"""
|
||||
|
|
@ -149,7 +152,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
response_api_optional_params: ResponsesAPIOptionalRequestParams,
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
) -> Dict:
|
||||
) -> dict:
|
||||
"""
|
||||
Volcengine Responses API aligns with OpenAI parameters.
|
||||
Remove parameters not supported by the public docs.
|
||||
|
|
@ -173,11 +176,11 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
def transform_responses_api_request(
|
||||
self,
|
||||
model: str,
|
||||
input: Union[str, ResponseInputParam],
|
||||
response_api_optional_request_params: Dict,
|
||||
input: str | ResponseInputParam,
|
||||
response_api_optional_request_params: dict,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> Dict:
|
||||
) -> dict:
|
||||
"""
|
||||
Volcengine rejects any undocumented fields (including extra_body). Fail fast
|
||||
with clear errors and re-filter with the documented whitelist before delegating
|
||||
|
|
@ -210,7 +213,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
def transform_streaming_response(
|
||||
self,
|
||||
model: str,
|
||||
parsed_chunk: dict,
|
||||
parsed_chunk: Mapping[str, object],
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> ResponsesAPIStreamingResponse:
|
||||
"""
|
||||
|
|
@ -222,18 +225,19 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
if isinstance(chunk, dict):
|
||||
resp = chunk.get("response")
|
||||
if isinstance(resp, dict) and "output" not in resp:
|
||||
resp_items: Mapping[str, object] = resp
|
||||
patched_chunk = dict(chunk)
|
||||
patched_resp = dict(resp)
|
||||
patched_resp = dict(resp_items)
|
||||
patched_resp["output"] = []
|
||||
patched_chunk["response"] = patched_resp
|
||||
chunk = patched_chunk
|
||||
|
||||
event_type = str(chunk.get("type")) if isinstance(chunk, dict) else None
|
||||
event_pydantic_model = OpenAIResponsesAPIConfig.get_event_model_class(event_type=event_type)
|
||||
event_pydantic_model: _EventModelClass = OpenAIResponsesAPIConfig.get_event_model_class(event_type=event_type)
|
||||
|
||||
patched_chunk = self._fill_missing_fields(chunk, event_pydantic_model)
|
||||
|
||||
return event_pydantic_model(**patched_chunk)
|
||||
return event_pydantic_model.model_validate(patched_chunk)
|
||||
|
||||
def transform_response_api_response(
|
||||
self,
|
||||
|
|
@ -246,7 +250,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
original_response=raw_response.text,
|
||||
additional_args={"complete_input_dict": {}},
|
||||
)
|
||||
raw_response_json = raw_response.json()
|
||||
raw_response_json = self._parsed_response_body(raw_response)
|
||||
if "created_at" in raw_response_json:
|
||||
raw_response_json["created_at"] = _safe_convert_created_field(raw_response_json["created_at"])
|
||||
except Exception:
|
||||
|
|
@ -256,10 +260,11 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
processed_headers = process_response_headers(raw_response_headers)
|
||||
|
||||
try:
|
||||
response = ResponsesAPIResponse(**raw_response_json)
|
||||
response = ResponsesAPIResponse.model_validate(raw_response_json)
|
||||
except Exception:
|
||||
verbose_logger.debug("Volcengine Responses API: falling back to model_construct for response parsing.")
|
||||
response = ResponsesAPIResponse.model_construct(**raw_response_json)
|
||||
construct_response: Callable[..., ResponsesAPIResponse] = ResponsesAPIResponse.model_construct
|
||||
response = construct_response(**raw_response_json)
|
||||
|
||||
response._hidden_params["additional_headers"] = processed_headers
|
||||
response._hidden_params["headers"] = raw_response_headers
|
||||
|
|
@ -274,10 +279,10 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> Tuple[str, Dict]:
|
||||
) -> tuple[str, dict]:
|
||||
encoded_response_id = encode_url_path_segment(response_id, field_name="response_id")
|
||||
url = f"{api_base}/{encoded_response_id}"
|
||||
data: Dict = {}
|
||||
data: dict = {}
|
||||
return url, data
|
||||
|
||||
def transform_delete_response_api_response(
|
||||
|
|
@ -286,16 +291,17 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> DeleteResponseResult:
|
||||
try:
|
||||
raw_response_json = raw_response.json()
|
||||
raw_response_json = self._parsed_response_body(raw_response)
|
||||
except Exception:
|
||||
raise VolcEngineError(message=raw_response.text, status_code=raw_response.status_code)
|
||||
try:
|
||||
return DeleteResponseResult(**raw_response_json)
|
||||
return DeleteResponseResult.model_validate(raw_response_json)
|
||||
except Exception:
|
||||
verbose_logger.debug(
|
||||
"Volcengine Responses API: falling back to model_construct for delete response parsing."
|
||||
)
|
||||
return DeleteResponseResult.model_construct(**raw_response_json)
|
||||
construct_delete_result: Callable[..., DeleteResponseResult] = DeleteResponseResult.model_construct
|
||||
return construct_delete_result(**raw_response_json)
|
||||
|
||||
#########################################################
|
||||
########## GET RESPONSE API TRANSFORMATION ###############
|
||||
|
|
@ -306,10 +312,10 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> Tuple[str, Dict]:
|
||||
) -> tuple[str, dict]:
|
||||
encoded_response_id = encode_url_path_segment(response_id, field_name="response_id")
|
||||
url = f"{api_base}/{encoded_response_id}"
|
||||
data: Dict = {}
|
||||
data: dict = {}
|
||||
return url, data
|
||||
|
||||
def transform_get_response_api_response(
|
||||
|
|
@ -318,14 +324,14 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> ResponsesAPIResponse:
|
||||
try:
|
||||
raw_response_json = raw_response.json()
|
||||
raw_response_json = self._parsed_response_body(raw_response)
|
||||
except Exception:
|
||||
raise VolcEngineError(message=raw_response.text, status_code=raw_response.status_code)
|
||||
|
||||
raw_response_headers = dict(raw_response.headers)
|
||||
processed_headers = process_response_headers(raw_response_headers)
|
||||
|
||||
response = ResponsesAPIResponse(**raw_response_json)
|
||||
response = ResponsesAPIResponse.model_validate(raw_response_json)
|
||||
response._hidden_params["additional_headers"] = processed_headers
|
||||
response._hidden_params["headers"] = raw_response_headers
|
||||
return response
|
||||
|
|
@ -339,15 +345,15 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
after: Optional[str] = None,
|
||||
before: Optional[str] = None,
|
||||
include: Optional[List[str]] = None,
|
||||
after: str | None = None,
|
||||
before: str | None = None,
|
||||
include: list[str] | None = None,
|
||||
limit: int = 20,
|
||||
order: Literal["asc", "desc"] = "desc",
|
||||
) -> Tuple[str, Dict]:
|
||||
) -> tuple[str, dict]:
|
||||
encoded_response_id = encode_url_path_segment(response_id, field_name="response_id")
|
||||
url = f"{api_base}/{encoded_response_id}/input_items"
|
||||
params: Dict[str, Any] = {}
|
||||
params: dict[str, str | int] = {}
|
||||
if after is not None:
|
||||
params["after"] = after
|
||||
if before is not None:
|
||||
|
|
@ -364,9 +370,9 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
self,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> Dict:
|
||||
) -> dict:
|
||||
try:
|
||||
return raw_response.json()
|
||||
return self._parsed_response_body(raw_response)
|
||||
except Exception:
|
||||
raise VolcEngineError(message=raw_response.text, status_code=raw_response.status_code)
|
||||
|
||||
|
|
@ -379,10 +385,10 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> Tuple[str, Dict]:
|
||||
) -> tuple[str, dict]:
|
||||
encoded_response_id = encode_url_path_segment(response_id, field_name="response_id")
|
||||
url = f"{api_base}/{encoded_response_id}/cancel"
|
||||
data: Dict = {}
|
||||
data: dict = {}
|
||||
return url, data
|
||||
|
||||
def transform_cancel_response_api_response(
|
||||
|
|
@ -391,23 +397,23 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> ResponsesAPIResponse:
|
||||
try:
|
||||
raw_response_json = raw_response.json()
|
||||
raw_response_json = self._parsed_response_body(raw_response)
|
||||
except Exception:
|
||||
raise VolcEngineError(message=raw_response.text, status_code=raw_response.status_code)
|
||||
|
||||
raw_response_headers = dict(raw_response.headers)
|
||||
processed_headers = process_response_headers(raw_response_headers)
|
||||
|
||||
response = ResponsesAPIResponse(**raw_response_json)
|
||||
response = ResponsesAPIResponse.model_validate(raw_response_json)
|
||||
response._hidden_params["additional_headers"] = processed_headers
|
||||
response._hidden_params["headers"] = raw_response_headers
|
||||
return response
|
||||
|
||||
def should_fake_stream(
|
||||
self,
|
||||
model: Optional[str],
|
||||
stream: Optional[bool],
|
||||
custom_llm_provider: Optional[str] = None,
|
||||
model: str | None,
|
||||
stream: bool | None,
|
||||
custom_llm_provider: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Volcengine Responses API supports native streaming; never fall back to fake stream.
|
||||
|
|
@ -415,7 +421,24 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
return False
|
||||
|
||||
@staticmethod
|
||||
def _fill_missing_fields(chunk: Any, event_model: Any) -> Dict[str, Any]:
|
||||
def _parsed_response_body(raw_response: httpx.Response) -> dict[str, object]:
|
||||
return raw_response.json()
|
||||
|
||||
@staticmethod
|
||||
def _annotation_origin(annotation: object) -> object:
|
||||
return get_origin(annotation)
|
||||
|
||||
@staticmethod
|
||||
def _annotation_args(annotation: object) -> tuple[object, ...]:
|
||||
return get_args(annotation)
|
||||
|
||||
@staticmethod
|
||||
def _field_annotation(field: pyd_fields.FieldInfo) -> object:
|
||||
annotation: object = field.annotation
|
||||
return annotation
|
||||
|
||||
@staticmethod
|
||||
def _fill_missing_fields(chunk: Mapping[str, object], event_model: object | None) -> Mapping[str, object]:
|
||||
"""
|
||||
Heuristically fill missing required fields with safe defaults based on the
|
||||
event model's field annotations. This keeps parsing tolerant of providers that
|
||||
|
|
@ -424,31 +447,37 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
if not isinstance(chunk, dict) or event_model is None:
|
||||
return chunk
|
||||
|
||||
patched: Dict[str, Any] = dict(chunk)
|
||||
fields_map = getattr(event_model, "model_fields", {}) or {}
|
||||
patched = dict(chunk)
|
||||
fields_map: Mapping[str, pyd_fields.FieldInfo] = getattr(event_model, "model_fields", {}) or {}
|
||||
|
||||
for name, field in fields_map.items():
|
||||
if name in patched:
|
||||
patched[name] = VolcEngineResponsesAPIConfig._maybe_fill_nested(patched[name], field.annotation)
|
||||
patched[name] = VolcEngineResponsesAPIConfig._maybe_fill_nested(
|
||||
patched[name], VolcEngineResponsesAPIConfig._field_annotation(field)
|
||||
)
|
||||
continue
|
||||
|
||||
# Explicit default or factory
|
||||
if field.default is not pyd_fields.PydanticUndefined and field.default is not None:
|
||||
patched[name] = field.default
|
||||
field_default: object = field.default
|
||||
if field_default is not pyd_fields.PydanticUndefined and field_default is not None:
|
||||
patched[name] = field_default
|
||||
continue
|
||||
if field.default_factory is not None and field.default_factory is not pyd_fields.PydanticUndefined:
|
||||
patched[name] = field.default_factory()
|
||||
default_factory: Callable[..., object] | None = field.default_factory
|
||||
if default_factory is not None and default_factory is not pyd_fields.PydanticUndefined:
|
||||
patched[name] = default_factory()
|
||||
continue
|
||||
|
||||
# Heuristic defaults for missing required fields
|
||||
patched[name] = VolcEngineResponsesAPIConfig._default_for_annotation(field.annotation)
|
||||
patched[name] = VolcEngineResponsesAPIConfig._default_for_annotation(
|
||||
VolcEngineResponsesAPIConfig._field_annotation(field)
|
||||
)
|
||||
|
||||
return patched
|
||||
|
||||
@staticmethod
|
||||
def _default_for_annotation(annotation: Any) -> Any:
|
||||
origin = get_origin(annotation)
|
||||
args = get_args(annotation)
|
||||
def _default_for_annotation(annotation: object) -> object:
|
||||
origin = VolcEngineResponsesAPIConfig._annotation_origin(annotation)
|
||||
args = VolcEngineResponsesAPIConfig._annotation_args(annotation)
|
||||
|
||||
if annotation is int:
|
||||
return 0
|
||||
|
|
@ -456,7 +485,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
return []
|
||||
if origin is Union:
|
||||
# Prefer empty list when any option is a list
|
||||
if any((arg is list or get_origin(arg) is list) for arg in args):
|
||||
if any((arg is list or VolcEngineResponsesAPIConfig._annotation_origin(arg) is list) for arg in args):
|
||||
return []
|
||||
if type(None) in args:
|
||||
return None
|
||||
|
|
@ -467,53 +496,51 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
return None
|
||||
|
||||
@staticmethod
|
||||
def _maybe_fill_nested(value: Any, annotation: Any) -> Any:
|
||||
def _maybe_fill_nested(value: object, annotation: object) -> object:
|
||||
"""
|
||||
Recursively fill nested dict/list structures based on the annotated model.
|
||||
"""
|
||||
model_cls = VolcEngineResponsesAPIConfig._pick_model_class(annotation, value)
|
||||
args = get_args(annotation)
|
||||
args = VolcEngineResponsesAPIConfig._annotation_args(annotation)
|
||||
|
||||
if isinstance(value, dict) and model_cls is not None:
|
||||
return VolcEngineResponsesAPIConfig._fill_missing_fields(value, model_cls)
|
||||
nested_items: Mapping[str, object] = value
|
||||
return VolcEngineResponsesAPIConfig._fill_missing_fields(nested_items, model_cls)
|
||||
|
||||
if isinstance(value, list):
|
||||
# Attempt to fill list elements if we know the element annotation
|
||||
elem_ann: Any = args[0] if args else None
|
||||
elem_ann: object = args[0] if args else None
|
||||
if elem_ann is not None:
|
||||
return [VolcEngineResponsesAPIConfig._maybe_fill_nested(v, elem_ann) for v in value]
|
||||
nested_elements: Sequence[object] = value
|
||||
return [VolcEngineResponsesAPIConfig._maybe_fill_nested(v, elem_ann) for v in nested_elements]
|
||||
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _pick_model_class(annotation: Any, value: Any) -> Optional[Any]:
|
||||
def _pick_model_class(annotation: object, value: object) -> object | None:
|
||||
"""
|
||||
Choose the best-matching Pydantic model class for a nested dict.
|
||||
"""
|
||||
candidates: List[Any] = []
|
||||
origin = get_origin(annotation)
|
||||
|
||||
if hasattr(annotation, "model_fields"):
|
||||
candidates.append(annotation)
|
||||
if origin is Union:
|
||||
for arg in get_args(annotation):
|
||||
if hasattr(arg, "model_fields"):
|
||||
candidates.append(arg)
|
||||
origin = VolcEngineResponsesAPIConfig._annotation_origin(annotation)
|
||||
union_args = VolcEngineResponsesAPIConfig._annotation_args(annotation) if origin is Union else ()
|
||||
candidates = tuple(candidate for candidate in (annotation, *union_args) if hasattr(candidate, "model_fields"))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# Try to match by literal "type" field when available
|
||||
if isinstance(value, dict):
|
||||
v_type = value.get("type")
|
||||
value_items: Mapping[str, object] = value
|
||||
v_type = value_items.get("type")
|
||||
for candidate in candidates:
|
||||
try:
|
||||
type_field = candidate.model_fields.get("type")
|
||||
candidate_fields: Mapping[str, pyd_fields.FieldInfo] = getattr(candidate, "model_fields")
|
||||
type_field = candidate_fields.get("type")
|
||||
if type_field is None:
|
||||
continue
|
||||
literal_ann = type_field.annotation
|
||||
if get_origin(literal_ann) is Literal:
|
||||
literal_values = get_args(literal_ann)
|
||||
literal_ann = VolcEngineResponsesAPIConfig._field_annotation(type_field)
|
||||
if VolcEngineResponsesAPIConfig._annotation_origin(literal_ann) is Literal:
|
||||
literal_values = VolcEngineResponsesAPIConfig._annotation_args(literal_ann)
|
||||
if v_type in literal_values:
|
||||
return candidate
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -2887,7 +2887,7 @@
|
|||
"input_cost_per_token": 5e-06,
|
||||
"output_cost_per_token": 2.5e-05,
|
||||
"litellm_provider": "azure_ai",
|
||||
"max_input_tokens": 200000,
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
|
|
@ -2916,7 +2916,7 @@
|
|||
"input_cost_per_token": 5e-06,
|
||||
"output_cost_per_token": 2.5e-05,
|
||||
"litellm_provider": "azure_ai",
|
||||
"max_input_tokens": 200000,
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
|
|
@ -3010,7 +3010,7 @@
|
|||
"input_cost_per_token": 5e-06,
|
||||
"output_cost_per_token": 2.5e-05,
|
||||
"litellm_provider": "azure_ai",
|
||||
"max_input_tokens": 200000,
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
|
|
@ -45890,6 +45890,7 @@
|
|||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
|
|
@ -45915,6 +45916,7 @@
|
|||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
|
|
@ -45940,6 +45942,7 @@
|
|||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
|
|
@ -45999,6 +46002,7 @@
|
|||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ import base64
|
|||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Callable, Coroutine, Mapping
|
||||
from dataclasses import dataclass
|
||||
from io import IOBase
|
||||
from typing import Any, Callable, Coroutine, Union, cast
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
|
||||
|
|
@ -42,7 +43,7 @@ class _PreparedOCRRequest:
|
|||
provider_config: BaseOCRConfig
|
||||
optional_params: dict[str, object]
|
||||
litellm_params: dict[str, object]
|
||||
effective_timeout: Union[float, httpx.Timeout]
|
||||
effective_timeout: float | httpx.Timeout
|
||||
litellm_logging_obj: LiteLLMLoggingObj
|
||||
|
||||
|
||||
|
|
@ -63,13 +64,13 @@ _RUST_OCR_PROVIDERS = {
|
|||
|
||||
def _prepare_ocr_request(
|
||||
model: str,
|
||||
document: dict[str, Any],
|
||||
document: Mapping[str, object],
|
||||
api_key: str | None,
|
||||
api_base: str | None,
|
||||
timeout: Union[float, httpx.Timeout] | None,
|
||||
timeout: float | httpx.Timeout | None,
|
||||
custom_llm_provider: str | None,
|
||||
extra_headers: dict[str, Any] | None,
|
||||
kwargs: dict[str, Any],
|
||||
extra_headers: dict[str, object] | None,
|
||||
kwargs: dict[str, object],
|
||||
) -> _PreparedOCRRequest:
|
||||
litellm_logging_obj = cast(LiteLLMLoggingObj, kwargs.pop("litellm_logging_obj"))
|
||||
litellm_call_id = cast(str | None, kwargs.get("litellm_call_id", None))
|
||||
|
|
@ -120,7 +121,7 @@ def _prepare_ocr_request(
|
|||
|
||||
verbose_logger.debug(f"OCR call - model: {model}, provider: {custom_llm_provider}")
|
||||
|
||||
litellm_params = GenericLiteLLMParams(**kwargs)
|
||||
litellm_params = GenericLiteLLMParams.model_validate(kwargs)
|
||||
|
||||
supported_params = ocr_provider_config.get_supported_ocr_params(model=model)
|
||||
non_default_params = {}
|
||||
|
|
@ -155,7 +156,7 @@ def _prepare_ocr_request(
|
|||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
extra_headers=cast(dict[str, object] | None, extra_headers),
|
||||
extra_headers=extra_headers,
|
||||
provider_config=ocr_provider_config,
|
||||
optional_params=cast(dict[str, object], optional_params),
|
||||
litellm_params=dict(litellm_params),
|
||||
|
|
@ -305,13 +306,13 @@ async def _run_rust_aocr(
|
|||
@client
|
||||
async def aocr(
|
||||
model: str,
|
||||
document: dict[str, Any],
|
||||
document: Mapping[str, object],
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
timeout: Union[float, httpx.Timeout] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
**kwargs,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
**kwargs: object,
|
||||
) -> OCRResponse:
|
||||
"""
|
||||
Async OCR function.
|
||||
|
|
@ -567,14 +568,14 @@ def convert_file_document_to_url_document(document: dict[str, Any]) -> dict[str,
|
|||
@client
|
||||
def ocr(
|
||||
model: str,
|
||||
document: dict[str, Any],
|
||||
document: Mapping[str, object],
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
timeout: Union[float, httpx.Timeout] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
**kwargs,
|
||||
) -> Union[OCRResponse, Coroutine[Any, Any, OCRResponse]]:
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
**kwargs: object,
|
||||
) -> OCRResponse | Coroutine[object, object, OCRResponse]:
|
||||
"""
|
||||
Synchronous OCR function.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
|
||||
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opentelemetry.trace import SpanContext
|
||||
|
||||
|
||||
class MCPAuthenticatedUser(AuthenticatedUser):
|
||||
"""
|
||||
|
|
@ -19,8 +16,6 @@ class MCPAuthenticatedUser(AuthenticatedUser):
|
|||
4. Server-specific authentication headers
|
||||
5. OAuth2 headers
|
||||
6. Raw headers - allows forwarding specific headers to the MCP server, specified by the admin.
|
||||
7. Transport span context - the tracing span of the HTTP request carrying the current
|
||||
message, which a stateful session's message handler cannot read from its own task.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
|
@ -33,7 +28,6 @@ class MCPAuthenticatedUser(AuthenticatedUser):
|
|||
mcp_protocol_version: Optional[str] = None,
|
||||
raw_headers: Optional[Dict[str, str]] = None,
|
||||
client_ip: Optional[str] = None,
|
||||
transport_span_context: Optional["SpanContext"] = None,
|
||||
):
|
||||
self.user_api_key_auth = user_api_key_auth
|
||||
self.mcp_auth_header = mcp_auth_header
|
||||
|
|
@ -43,4 +37,3 @@ class MCPAuthenticatedUser(AuthenticatedUser):
|
|||
self.oauth2_headers = oauth2_headers
|
||||
self.raw_headers = raw_headers
|
||||
self.client_ip = client_ip
|
||||
self.transport_span_context = transport_span_context
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -21,7 +21,6 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
)
|
||||
from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import (
|
||||
TokenEndpointAuthConfigError,
|
||||
build_token_endpoint_client_auth,
|
||||
normalize_token_endpoint_auth_method,
|
||||
)
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPTokenEndpointAuthMethod
|
||||
|
|
@ -54,7 +53,9 @@ from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
|
|||
)
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import (
|
||||
TOKEN_NO_CACHE_HEADERS,
|
||||
build_upstream_oauth2_token_request,
|
||||
get_request_base_url,
|
||||
resolve_upstream_resource,
|
||||
validate_trusted_redirect_uri,
|
||||
well_known_root_suffix,
|
||||
)
|
||||
|
|
@ -726,6 +727,7 @@ def _redirect_to_upstream_authorize(
|
|||
to the upstream authorize endpoint verbatim, no relay state cookie is set, and the upstream
|
||||
enforces its own registered redirect binding for the client."""
|
||||
scope_value = scope or (" ".join(mcp_server.scopes) if mcp_server.scopes else None)
|
||||
upstream_resource = resolve_upstream_resource(mcp_server)
|
||||
passthrough_params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
|
|
@ -734,6 +736,7 @@ def _redirect_to_upstream_authorize(
|
|||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": code_challenge_method,
|
||||
**({"scope": scope_value} if scope_value else {}),
|
||||
**({"resource": upstream_resource} if upstream_resource else {}),
|
||||
}
|
||||
parsed_auth_url = urlparse(mcp_server.authorization_url or "")
|
||||
merged_params = {**dict(parse_qsl(parsed_auth_url.query)), **passthrough_params}
|
||||
|
|
@ -842,6 +845,10 @@ async def authorize_with_server(
|
|||
if code_challenge_method:
|
||||
params["code_challenge_method"] = code_challenge_method
|
||||
|
||||
upstream_resource = resolve_upstream_resource(mcp_server)
|
||||
if upstream_resource:
|
||||
params["resource"] = upstream_resource
|
||||
|
||||
parsed_auth_url = urlparse(mcp_server.authorization_url)
|
||||
existing_params = dict(parse_qsl(parsed_auth_url.query))
|
||||
existing_params.update(params)
|
||||
|
|
@ -902,7 +909,8 @@ async def exchange_token_with_server(
|
|||
else (client_token_endpoint_auth_method or mcp_server.token_endpoint_auth_method)
|
||||
)
|
||||
try:
|
||||
client_auth = build_token_endpoint_client_auth(
|
||||
token_request = build_upstream_oauth2_token_request(
|
||||
mcp_server,
|
||||
auth_method=resolved_auth_method,
|
||||
client_id=resolved_client_id,
|
||||
client_secret=resolved_client_secret,
|
||||
|
|
@ -941,7 +949,7 @@ async def exchange_token_with_server(
|
|||
token_data: dict = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": upstream_refresh_token,
|
||||
**client_auth.body,
|
||||
**token_request.body,
|
||||
}
|
||||
refresh_request_scope = scope or bridge_upstream_scope
|
||||
if refresh_request_scope:
|
||||
|
|
@ -980,7 +988,7 @@ async def exchange_token_with_server(
|
|||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": resolved_redirect_uri,
|
||||
**client_auth.body,
|
||||
**token_request.body,
|
||||
}
|
||||
if code_verifier:
|
||||
token_data["code_verifier"] = code_verifier
|
||||
|
|
@ -991,11 +999,12 @@ async def exchange_token_with_server(
|
|||
if not isinstance(prepared, _BridgeMintReady):
|
||||
return _bridge_mint_error_response(prepared)
|
||||
bridge_mint_ready = prepared
|
||||
|
||||
async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check)
|
||||
try:
|
||||
response = await async_client.post(
|
||||
mcp_server.token_url,
|
||||
headers={"Accept": "application/json", **client_auth.headers},
|
||||
headers={"Accept": "application/json", **token_request.headers},
|
||||
data=token_data,
|
||||
)
|
||||
if response is not None:
|
||||
|
|
|
|||
|
|
@ -63,18 +63,21 @@ def _classify_oauth_error_code(
|
|||
) -> UpstreamOAuthFault:
|
||||
"""Blame assignment for a contract-conformant OAuth error code, shared by the token and DCR
|
||||
classifiers. Codes by which the upstream blames itself keep that blame; ``invalid_target`` is a
|
||||
gateway capability gap (RFC 8707 resource indicators, LIT-4339) no matter whose credentials were
|
||||
presented; credential-indicting codes follow the credential source; everything else, including
|
||||
codes we do not recognize, is the caller's to act on. The upstream's HTTP status is deliberately
|
||||
never consulted: status derives from this classification at render time, which is what keeps
|
||||
status and code from contradicting each other."""
|
||||
gateway configuration gap (the RFC 8707 resource indicator this server sends, or fails to send)
|
||||
no matter whose credentials were presented; credential-indicting codes follow the credential
|
||||
source; everything else, including codes we do not recognize, is the caller's to act on. The
|
||||
upstream's HTTP status is deliberately never consulted: status derives from this classification
|
||||
at render time, which is what keeps status and code from contradicting each other."""
|
||||
if code == "server_error" or code == "temporarily_unavailable":
|
||||
return UpstreamReportedFault(code=code)
|
||||
if code in GATEWAY_CAPABILITY_CODES:
|
||||
verbose_logger.warning(
|
||||
"MCP server %s: the upstream authorization server rejected the request with "
|
||||
"invalid_target; it may require RFC 8707 resource indicators, which the gateway "
|
||||
"does not send yet (tracked as LIT-4339)",
|
||||
"invalid_target, meaning it did not accept the RFC 8707 resource indicator for this "
|
||||
"request. Set upstream_resource on this server to the exact resource identifier the "
|
||||
"authorization server expects (or to 'auto' to send the server's own canonical url); "
|
||||
"if it is already set and the authorization server does not support resource "
|
||||
"indicators, unset it and express the target audience through scopes instead",
|
||||
log_context,
|
||||
)
|
||||
return GatewayRejected(code=code)
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ from litellm.proxy._experimental.mcp_server.oauth_utils import TOKEN_NO_CACHE_HE
|
|||
def _gateway_rejected_description(code: str) -> str:
|
||||
if code == "invalid_target":
|
||||
return (
|
||||
"the upstream authorization server rejected the request (invalid_target); "
|
||||
"it may require RFC 8707 resource indicators, which the gateway does not send yet"
|
||||
"the upstream authorization server rejected the request (invalid_target); it did not "
|
||||
"accept this server's RFC 8707 resource indicator. Set upstream_resource on the MCP "
|
||||
"server to the resource identifier the authorization server expects, or unset it if "
|
||||
"that authorization server does not support resource indicators"
|
||||
)
|
||||
return (
|
||||
f"the upstream authorization server rejected the gateway's configured client credentials "
|
||||
|
|
|
|||
|
|
@ -25,9 +25,10 @@ gateway presented its own stored credentials, these are gateway-side faults the
|
|||
when the caller supplied the credentials, they are the caller's to fix."""
|
||||
|
||||
GATEWAY_CAPABILITY_CODES: frozenset[str] = frozenset({"invalid_target"})
|
||||
"""Codes that indict a gateway capability regardless of whose credentials were presented:
|
||||
``invalid_target`` means the upstream wants RFC 8707 resource indicators, which the gateway does not
|
||||
send yet (LIT-4339). Never the caller's fault."""
|
||||
"""Codes that indict gateway configuration regardless of whose credentials were presented:
|
||||
``invalid_target`` means the upstream did not accept the RFC 8707 resource indicator the server
|
||||
sent, or requires one it was not configured to send (``upstream_resource``). Never the caller's
|
||||
fault."""
|
||||
|
||||
UPSTREAM_FAULT_CODES: frozenset[str] = frozenset({"server_error", "temporarily_unavailable"})
|
||||
"""Codes by which the upstream blames itself. Relaying them as caller faults would invert blame, so
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ from litellm.proxy._experimental.mcp_server.oauth2_token_cache import (
|
|||
)
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import (
|
||||
_redact_mcp_resource_url,
|
||||
canonicalize_url_identity,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials import (
|
||||
Error,
|
||||
|
|
@ -121,7 +122,6 @@ from litellm.proxy._experimental.mcp_server.utils import (
|
|||
normalize_server_name,
|
||||
parse_admin_env_vars,
|
||||
split_server_prefix_from_name,
|
||||
strip_known_server_prefix,
|
||||
validate_mcp_server_name,
|
||||
)
|
||||
from litellm.proxy._types import (
|
||||
|
|
@ -262,19 +262,11 @@ def _endpoints_yield_to_issuer(
|
|||
|
||||
|
||||
def _normalized_authorize_endpoint(url: str) -> str:
|
||||
"""Compare authorize endpoints on scheme, host, and path only. The default port is elided and
|
||||
the host is lowercased so ``https://IDP.example.com:443/authorize/`` and
|
||||
``https://idp.example.com/authorize`` are the same identity; query and trailing slash are not."""
|
||||
parsed = urlparse(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
default_port = {"https": 443, "http": 80}.get(scheme)
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
port = None
|
||||
authority = host if port is None or port == default_port else f"{host}:{port}"
|
||||
return f"{scheme}://{authority}{parsed.path.rstrip('/')}"
|
||||
"""Compare authorize endpoints / issuers on scheme, host, and path only, through the shared URL
|
||||
canonicalizer: the default port is elided and the host is lowercased so
|
||||
``https://IDP.example.com:443/authorize/`` and ``https://idp.example.com/authorize`` are the same
|
||||
identity, while query, fragment and a trailing slash are dropped."""
|
||||
return canonicalize_url_identity(url)
|
||||
|
||||
|
||||
def _issuer_matches(claimed_issuer: object, configured_issuer: str) -> bool:
|
||||
|
|
@ -1518,6 +1510,7 @@ class MCPServerManager:
|
|||
"subject_token_type",
|
||||
DEFAULT_SUBJECT_TOKEN_TYPE,
|
||||
),
|
||||
upstream_resource=server_config.get("upstream_resource", None),
|
||||
# ID-JAG fields
|
||||
id_jag_resource_token_endpoint=server_config.get("id_jag_resource_token_endpoint", None),
|
||||
id_jag_resource=server_config.get("id_jag_resource", None),
|
||||
|
|
@ -2017,6 +2010,7 @@ class MCPServerManager:
|
|||
subject_token_type=mcp_server.subject_token_type
|
||||
or (credentials_dict.get("subject_token_type") if credentials_dict else None)
|
||||
or DEFAULT_SUBJECT_TOKEN_TYPE,
|
||||
upstream_resource=(credentials_dict.get("upstream_resource") if credentials_dict else None),
|
||||
# ID-JAG fields — read from credentials JSON blob
|
||||
id_jag_resource_token_endpoint=(
|
||||
credentials_dict.get("id_jag_resource_token_endpoint") if credentials_dict else None
|
||||
|
|
@ -2492,6 +2486,13 @@ class MCPServerManager:
|
|||
the given toolsets. Results are cached via ``user_api_key_cache`` (a
|
||||
Redis-backed ``DualCache`` in production) so that cache entries are
|
||||
shared across workers and cold-cache DB hits are minimised.
|
||||
|
||||
A row names a tool on the server identified by ``server_id``, so the
|
||||
stored name is the tool's own name and is used as written. It is never
|
||||
reduced by the server's wire prefix: that prefix is added on the way out
|
||||
and is not part of any tool's identity, so treating a leading segment as
|
||||
one silently renames the tool when a native name happens to begin with
|
||||
it (``greyhound_internal_events`` on a server prefixed ``greyhound``).
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.toolset_db import list_mcp_toolsets
|
||||
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
|
||||
|
|
@ -2509,12 +2510,9 @@ class MCPServerManager:
|
|||
tool_permissions: dict[str, list[str]] = {}
|
||||
for toolset in toolsets:
|
||||
for tool in toolset.tools:
|
||||
raw_name = tool["tool_name"]
|
||||
server = self.get_mcp_server_by_id(tool["server_id"])
|
||||
unprefixed = strip_known_server_prefix(raw_name, server)
|
||||
tool_permissions.setdefault(tool["server_id"], [])
|
||||
if unprefixed not in tool_permissions[tool["server_id"]]:
|
||||
tool_permissions[tool["server_id"]].append(unprefixed)
|
||||
allowed_names = tool_permissions.setdefault(tool["server_id"], [])
|
||||
if tool["tool_name"] not in allowed_names:
|
||||
allowed_names.append(tool["tool_name"])
|
||||
await user_api_key_cache.async_set_cache(
|
||||
key=cache_key,
|
||||
value=tool_permissions,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ with ``client_id``, ``client_secret``, and ``token_url``.
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union
|
||||
|
||||
import httpx
|
||||
|
|
@ -26,8 +27,9 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
|
|||
decrypt_value_helper,
|
||||
encrypt_value_helper,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import (
|
||||
build_token_endpoint_client_auth,
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import (
|
||||
build_upstream_oauth2_token_request,
|
||||
resolve_upstream_resource,
|
||||
)
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
|
||||
|
|
@ -37,10 +39,18 @@ if TYPE_CHECKING:
|
|||
|
||||
class MCPOAuth2TokenCache(InMemoryCache):
|
||||
"""
|
||||
In-memory cache for OAuth2 client_credentials tokens, keyed by server_id.
|
||||
In-memory cache for OAuth2 client_credentials tokens, keyed by the identity of the token
|
||||
request rather than by server_id alone.
|
||||
|
||||
A minted token is only reusable for the exact request that produced it. Keying on server_id
|
||||
alone served a token minted under the previous configuration whenever any of those inputs
|
||||
changed, so editing scopes, rotating the client secret, or setting ``upstream_resource``
|
||||
silently kept handing out a token carrying the old scopes or audience until it expired. The
|
||||
identity below covers every input ``_fetch_token`` puts on the wire, so a change to any of
|
||||
them misses the cache and mints afresh.
|
||||
|
||||
Inherits from ``InMemoryCache`` for TTL-based storage and eviction.
|
||||
Adds per-server ``asyncio.Lock`` to prevent duplicate concurrent fetches.
|
||||
Adds a per-identity ``asyncio.Lock`` to prevent duplicate concurrent fetches.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
|
@ -50,8 +60,25 @@ class MCPOAuth2TokenCache(InMemoryCache):
|
|||
)
|
||||
self._locks: Dict[str, asyncio.Lock] = {}
|
||||
|
||||
def _get_lock(self, server_id: str) -> asyncio.Lock:
|
||||
return self._locks.setdefault(server_id, asyncio.Lock())
|
||||
@staticmethod
|
||||
def _token_identity(server: "MCPServer") -> str:
|
||||
"""Cache key for the token this server's config would mint, prefixed by server_id so a
|
||||
single server's entries stay greppable and invalidatable. The secret is hashed with the
|
||||
rest of the identity rather than stored in a key."""
|
||||
material = "\x00".join(
|
||||
(
|
||||
server.token_url or "",
|
||||
server.client_id or "",
|
||||
server.client_secret or "",
|
||||
" ".join(server.scopes or ()),
|
||||
resolve_upstream_resource(server) or "",
|
||||
server.token_endpoint_auth_method or "",
|
||||
)
|
||||
)
|
||||
return f"{server.server_id}:{hashlib.sha256(material.encode()).hexdigest()}"
|
||||
|
||||
def _get_lock(self, identity: str) -> asyncio.Lock:
|
||||
return self._locks.setdefault(identity, asyncio.Lock())
|
||||
|
||||
@staticmethod
|
||||
def _has_client_credentials_config(server: "MCPServer") -> bool:
|
||||
|
|
@ -67,21 +94,21 @@ class MCPOAuth2TokenCache(InMemoryCache):
|
|||
if not self._has_client_credentials_config(server):
|
||||
return None
|
||||
|
||||
server_id = server.server_id
|
||||
identity = self._token_identity(server)
|
||||
|
||||
# Fast path — cached token is still valid
|
||||
cached = self.get_cache(server_id)
|
||||
cached = self.get_cache(identity)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Slow path — acquire per-server lock then double-check
|
||||
async with self._get_lock(server_id):
|
||||
cached = self.get_cache(server_id)
|
||||
# Slow path — acquire per-identity lock then double-check
|
||||
async with self._get_lock(identity):
|
||||
cached = self.get_cache(identity)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
token, ttl = await self._fetch_token(server)
|
||||
self.set_cache(server_id, token, ttl=ttl)
|
||||
self.set_cache(identity, token, ttl=ttl)
|
||||
return token
|
||||
|
||||
async def _fetch_token(self, server: "MCPServer") -> Tuple[str, int]:
|
||||
|
|
@ -100,14 +127,15 @@ class MCPOAuth2TokenCache(InMemoryCache):
|
|||
f"token_url={bool(server.token_url)}"
|
||||
)
|
||||
|
||||
client_auth = build_token_endpoint_client_auth(
|
||||
token_request = build_upstream_oauth2_token_request(
|
||||
server,
|
||||
auth_method=server.token_endpoint_auth_method,
|
||||
client_id=server.client_id,
|
||||
client_secret=server.client_secret,
|
||||
)
|
||||
data: Dict[str, str] = {
|
||||
"grant_type": "client_credentials",
|
||||
**client_auth.body,
|
||||
**token_request.body,
|
||||
}
|
||||
if server.scopes:
|
||||
data["scope"] = " ".join(server.scopes)
|
||||
|
|
@ -117,7 +145,7 @@ class MCPOAuth2TokenCache(InMemoryCache):
|
|||
server.server_id,
|
||||
)
|
||||
|
||||
post_kwargs = {"data": data, **({"headers": client_auth.headers} if client_auth.headers else {})}
|
||||
post_kwargs = {"data": data, **({"headers": token_request.headers} if token_request.headers else {})}
|
||||
try:
|
||||
response = await client.post(server.token_url, **post_kwargs)
|
||||
response.raise_for_status()
|
||||
|
|
@ -159,8 +187,14 @@ class MCPOAuth2TokenCache(InMemoryCache):
|
|||
return access_token, ttl
|
||||
|
||||
def invalidate(self, server_id: str) -> None:
|
||||
"""Remove a cached token (e.g. after a 401)."""
|
||||
self.delete_cache(server_id)
|
||||
"""Remove every cached token for a server (e.g. after a 401).
|
||||
|
||||
Entries are keyed by token identity, so one server can hold more than one entry across a
|
||||
config change; a 401 invalidates all of them rather than only the current configuration's.
|
||||
"""
|
||||
prefix = f"{server_id}:"
|
||||
for key in [k for k in self.cache_dict if isinstance(k, str) and k.startswith(prefix)]:
|
||||
self.delete_cache(key)
|
||||
|
||||
|
||||
mcp_oauth2_token_cache = MCPOAuth2TokenCache()
|
||||
|
|
|
|||
|
|
@ -3,14 +3,22 @@
|
|||
|
||||
import os
|
||||
from ipaddress import ip_address
|
||||
from typing import Any, Dict, List, NoReturn, Optional
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, NoReturn, Optional
|
||||
from urllib.parse import ParseResult, urlparse, urlsplit, urlunparse, urlunsplit
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import (
|
||||
TokenEndpointClientAuth,
|
||||
build_token_endpoint_client_auth,
|
||||
normalize_token_endpoint_auth_method,
|
||||
)
|
||||
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
# RFC 6749 §5.1 / OAuth 2.1 draft-15 §4.1.3: token-endpoint responses
|
||||
# must not be cached — both success and error bodies may reveal secrets.
|
||||
TOKEN_NO_CACHE_HEADERS = {"Cache-Control": "no-store", "Pragma": "no-cache"}
|
||||
|
|
@ -21,6 +29,10 @@ TOKEN_NO_CACHE_HEADERS = {"Cache-Control": "no-store", "Pragma": "no-cache"}
|
|||
# explicit port, which would otherwise break a literal netloc compare).
|
||||
_DEFAULT_PORTS = {"http": 80, "https": 443}
|
||||
|
||||
# Sentinel ``upstream_resource`` value meaning "derive the RFC 8707 resource identifier from the
|
||||
# server's own url". RFC 8707 requires an absolute URI, so this can never be a real resource value.
|
||||
UPSTREAM_RESOURCE_AUTO = "auto"
|
||||
|
||||
# Env var for ops to allowlist additional redirect_uri origins beyond
|
||||
# same-origin + loopback — needed for first-party OAuth clients hosted
|
||||
# on sister domains (e.g. a web app on app.example.com registering as
|
||||
|
|
@ -574,3 +586,112 @@ def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None:
|
|||
if _trusted_redirect_uri_is_allowed(parsed, redirect_netloc, proxy_base):
|
||||
return
|
||||
_raise_trusted_redirect_uri_rejected(request, redirect_uri, parsed, redirect_netloc, proxy_base)
|
||||
|
||||
|
||||
def canonicalize_url_identity(url: str) -> str:
|
||||
"""Normalize a URL to a comparable identity: lowercase scheme and host, drop the scheme's default
|
||||
port, and strip userinfo, params, query, fragment and a trailing slash while keeping IPv6
|
||||
brackets. The one URL-canonicalization primitive shared by the RFC 8707 resource emitter and the
|
||||
RFC 8414 issuer/authorize-endpoint comparison, so the default-port and IPv6 rules cannot be
|
||||
present in one and missing in the other. The netloc (not ``parsed.hostname``) carries the
|
||||
authority so ``[::1]:8080`` survives with its brackets intact."""
|
||||
parsed = urlparse(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
netloc = _strip_default_port(scheme, parsed.netloc.rpartition("@")[2])
|
||||
return urlunparse((scheme, netloc, parsed.path.rstrip("/"), "", "", ""))
|
||||
|
||||
|
||||
def _canonical_resource_uri(url: str) -> str | None:
|
||||
"""Canonicalize an upstream MCP server URL into an RFC 8707 resource identifier.
|
||||
|
||||
Keeps only the scheme, host, port and path, which is the shape the MCP authorization spec's
|
||||
"Canonical Server URI" section describes and every one of its examples takes; the reference
|
||||
implementation is ``mcp.shared.auth_utils.resource_url_from_server_url``, and this is the stricter
|
||||
variant. The scheme and host are lowercased, the scheme's default port is dropped so
|
||||
``https://host:443/mcp`` and ``https://host/mcp`` never present as two resources, and a trailing
|
||||
slash is dropped so ``https://host/mcp/`` and ``https://host/mcp`` do not either.
|
||||
|
||||
Userinfo, query and fragment are dropped rather than carried. A transport URL routinely holds
|
||||
credentials in exactly those components (``user:password@``, ``?api_key=``), while a resource
|
||||
indicator names the resource and nothing else; this value is published somewhere the transport
|
||||
URL never goes, into the authorization redirect the browser follows and into token request
|
||||
bodies, so carrying them would disclose them to the authorization server, its logs, and browser
|
||||
history. RFC 8707 forbids a fragment outright and says a resource SHOULD NOT carry a query. An
|
||||
upstream whose identifier genuinely needs more than this is served by setting
|
||||
``upstream_resource`` explicitly, which is passed through untouched.
|
||||
|
||||
Returns ``None`` when the URL is not absolute, which cannot yield a valid resource identifier.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return None
|
||||
return canonicalize_url_identity(url)
|
||||
|
||||
|
||||
def resolve_upstream_resource(mcp_server: "MCPServer") -> str | None:
|
||||
"""Resolve the RFC 8707 ``resource`` value this server's upstream OAuth legs must carry.
|
||||
|
||||
The MCP authorization spec requires an MCP client to send ``resource`` on both the
|
||||
authorization request and every token request, naming the canonical URI of the MCP server the
|
||||
token is for. Authorization server temperaments are irreconcilable and undetectable, so this
|
||||
stays an explicit per-server opt-in: most SaaS providers ignore the parameter, some hard-reject
|
||||
it and express audience through scopes instead, and strict or MCP-native ones refuse to mint a
|
||||
correctly scoped token without it (``invalid_target``).
|
||||
|
||||
``None`` or blank omits the parameter, which is the default and preserves the behavior of every
|
||||
server working today. ``"auto"`` derives the canonical URI from the server's own URL; it is not
|
||||
an absolute URI, so RFC 8707 guarantees it can never collide with a real resource value. Any
|
||||
other value is sent verbatim, because the identifier has to match what the authorization server
|
||||
expects exactly and normalizing it could break that match.
|
||||
|
||||
Every upstream leg for a server resolves through this one function, so the authorize request
|
||||
and the token requests cannot disagree; a token request naming a resource the authorization
|
||||
request never asked for is itself an ``invalid_target`` under RFC 8707.
|
||||
"""
|
||||
configured = (mcp_server.upstream_resource or "").strip()
|
||||
if not configured:
|
||||
return None
|
||||
if configured.lower() != UPSTREAM_RESOURCE_AUTO:
|
||||
return configured
|
||||
if not mcp_server.url:
|
||||
verbose_logger.warning(
|
||||
"MCP server %s sets upstream_resource=auto but has no url to derive a resource "
|
||||
"identifier from; omitting the RFC 8707 resource parameter. Set upstream_resource to "
|
||||
"the exact resource identifier the authorization server expects instead.",
|
||||
mcp_server.server_id,
|
||||
)
|
||||
return None
|
||||
canonical = _canonical_resource_uri(mcp_server.url)
|
||||
if canonical is None:
|
||||
verbose_logger.warning(
|
||||
"MCP server %s sets upstream_resource=auto but its url is not an absolute URI, so no "
|
||||
"RFC 8707 resource identifier could be derived; omitting the resource parameter",
|
||||
mcp_server.server_id,
|
||||
)
|
||||
return canonical
|
||||
|
||||
|
||||
def build_upstream_oauth2_token_request(
|
||||
mcp_server: "MCPServer",
|
||||
*,
|
||||
auth_method: object,
|
||||
client_id: str | None,
|
||||
client_secret: str | None,
|
||||
) -> TokenEndpointClientAuth:
|
||||
"""Client auth plus the RFC 8707 ``resource`` for one upstream plain-OAuth2 token request.
|
||||
|
||||
Resolving both in one call is what stops a leg authenticating without naming the resource its
|
||||
sibling legs named; the RFC 8693 legs (OBO, id_jag) carry ``audience`` and stay on
|
||||
``build_token_endpoint_client_auth``. The client-auth inputs are passed in because a leg may
|
||||
authenticate as the caller's own client rather than the server's; ``resource`` always comes from
|
||||
the server, so no leg can choose or forget it.
|
||||
"""
|
||||
client_auth = build_token_endpoint_client_auth(
|
||||
auth_method=normalize_token_endpoint_auth_method(auth_method),
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
resource = resolve_upstream_resource(mcp_server)
|
||||
if not resource:
|
||||
return client_auth
|
||||
return TokenEndpointClientAuth(headers=client_auth.headers, body={**client_auth.body, "resource": resource})
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from fastapi import HTTPException
|
|||
from pydantic import SecretStr
|
||||
from typing_extensions import assert_never
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import resolve_upstream_resource
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
|
||||
ApiKeyConfig,
|
||||
AuthorizationCodeConfig,
|
||||
|
|
@ -144,6 +145,7 @@ def _client_credentials_spec(server: MCPServer, resource: str) -> ServerSpec:
|
|||
token_url=server.token_url,
|
||||
scopes=tuple(server.scopes or ()),
|
||||
audience=server.audience,
|
||||
upstream_resource=resolve_upstream_resource(server),
|
||||
token_endpoint_auth_method=server.token_endpoint_auth_method,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ from typing import TYPE_CHECKING, Protocol
|
|||
from litellm._logging import verbose_logger
|
||||
from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import (
|
||||
TokenEndpointAuthConfigError,
|
||||
build_token_endpoint_client_auth,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import build_upstream_oauth2_token_request
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import (
|
||||
OAuthToken,
|
||||
)
|
||||
|
|
@ -92,7 +92,8 @@ class AuthorizationCodeRefresher:
|
|||
return None
|
||||
|
||||
try:
|
||||
client_auth = build_token_endpoint_client_auth(
|
||||
token_request = build_upstream_oauth2_token_request(
|
||||
server,
|
||||
auth_method=server.token_endpoint_auth_method,
|
||||
client_id=server.client_id,
|
||||
client_secret=server.client_secret,
|
||||
|
|
@ -103,9 +104,9 @@ class AuthorizationCodeRefresher:
|
|||
form = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": token.refresh_token,
|
||||
**client_auth.body,
|
||||
**token_request.body,
|
||||
}
|
||||
body = await self._token_endpoint(server.token_url, form, client_auth.headers)
|
||||
body = await self._token_endpoint(server.token_url, form, token_request.headers)
|
||||
if body is None:
|
||||
return None
|
||||
access_token = body.get("access_token")
|
||||
|
|
|
|||
|
|
@ -292,6 +292,7 @@ def _prepare_grant(config: ClientCredentialsConfig) -> Result[_PreparedGrant, Cr
|
|||
**client_auth.body,
|
||||
**({"scope": " ".join(config.scopes)} if config.scopes else {}),
|
||||
**({"audience": config.audience} if config.audience else {}),
|
||||
**({"resource": config.upstream_resource} if config.upstream_resource else {}),
|
||||
}
|
||||
return Ok(
|
||||
_PreparedGrant(
|
||||
|
|
@ -313,6 +314,7 @@ def _identity_key(config: ClientCredentialsConfig) -> str:
|
|||
config.token_endpoint_auth_method or "",
|
||||
" ".join(config.scopes),
|
||||
config.audience or "",
|
||||
config.upstream_resource or "",
|
||||
)
|
||||
)
|
||||
return hashlib.sha256(material.encode("utf-8")).hexdigest()
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ collaborators acquire their globals per call, mirroring v1's lazy-import pattern
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -54,7 +54,7 @@ ServerLookup = Callable[[str], "MCPServer | None"]
|
|||
StoreBuilder = Callable[[ServerLookup], tuple[InvalidatableOAuthTokenStore, bool]]
|
||||
|
||||
|
||||
async def _read_credential(user_id: str, server_id: str) -> dict[str, object] | None:
|
||||
async def _read_credential(user_id: str, server_id: str) -> Mapping[str, object] | None:
|
||||
from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415
|
||||
get_user_oauth_credential,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -199,6 +199,7 @@ class ClientCredentialsConfig(BaseModel):
|
|||
token_url: str | None = None
|
||||
scopes: tuple[str, ...] = ()
|
||||
audience: str | None = None
|
||||
upstream_resource: str | None = None
|
||||
token_endpoint_auth_method: Literal["client_secret_post", "client_secret_basic"] | None = None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ injected, so the DB/decoding plumbing stays testable and out of this seam.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import (
|
||||
OAuthToken,
|
||||
)
|
||||
|
||||
CredentialReader = Callable[[str, str], Awaitable["dict[str, object] | None"]]
|
||||
CredentialReader = Callable[[str, str], Awaitable["Mapping[str, object] | None"]]
|
||||
|
||||
|
||||
def _iso_to_epoch(expires_at: str) -> float | None:
|
||||
|
|
@ -39,7 +39,7 @@ def _to_scopes(raw: object) -> tuple[str, ...]:
|
|||
return ()
|
||||
|
||||
|
||||
def _to_oauth_token(payload: dict[str, object]) -> OAuthToken | None:
|
||||
def _to_oauth_token(payload: Mapping[str, object]) -> OAuthToken | None:
|
||||
access_token = payload.get("access_token")
|
||||
if not isinstance(access_token, str):
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,18 +1,11 @@
|
|||
import asyncio
|
||||
import importlib
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from datetime import datetime
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Literal,
|
||||
Mapping,
|
||||
Optional,
|
||||
Set,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
import httpx
|
||||
|
|
@ -38,6 +31,9 @@ from litellm.proxy._experimental.mcp_server.utils import (
|
|||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.proxy._experimental.mcp_server.db import OAuthCredentialPayload
|
||||
from litellm.proxy.common_utils.http_parsing_utils import _safe_get_request_headers
|
||||
from litellm.types.mcp import MCPAuth
|
||||
from litellm.types.utils import CallTypes
|
||||
|
|
@ -97,12 +93,12 @@ if MCP_AVAILABLE:
|
|||
########################################################
|
||||
############ MCP Server REST API Routes #################
|
||||
async def _safe_fire_mcp_tool_call_logging(
|
||||
logging_obj: Optional[Any],
|
||||
logging_obj: Any | None,
|
||||
result: Any,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
|
||||
request_data: Optional[Mapping[str, object]] = None,
|
||||
user_api_key_auth: UserAPIKeyAuth | None = None,
|
||||
request_data: Mapping[str, object] | None = None,
|
||||
) -> None:
|
||||
if logging_obj is None:
|
||||
return
|
||||
|
|
@ -134,7 +130,7 @@ if MCP_AVAILABLE:
|
|||
|
||||
async def _handle_virtual_mcp_tool(
|
||||
request: Request,
|
||||
data: Dict[str, Any],
|
||||
data: dict[str, Any],
|
||||
tool_name: str,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> Any:
|
||||
|
|
@ -212,9 +208,9 @@ if MCP_AVAILABLE:
|
|||
|
||||
def _get_server_auth_header(
|
||||
server,
|
||||
mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]],
|
||||
mcp_auth_header: Optional[str],
|
||||
) -> Optional[Union[Dict[str, str], str]]:
|
||||
mcp_server_auth_headers: dict[str, dict[str, str]] | None,
|
||||
mcp_auth_header: str | None,
|
||||
) -> dict[str, str] | str | None:
|
||||
"""Helper function to get server-specific auth header with case-insensitive matching."""
|
||||
from litellm.proxy._experimental.mcp_server.utils import (
|
||||
lookup_mcp_server_auth_in_headers,
|
||||
|
|
@ -230,7 +226,7 @@ if MCP_AVAILABLE:
|
|||
return server_auth
|
||||
return mcp_auth_header
|
||||
|
||||
def _is_v1_resolved_oauth2_server(server: Optional[MCPServer]) -> bool:
|
||||
def _is_v1_resolved_oauth2_server(server: MCPServer | None) -> bool:
|
||||
"""Whether this server's per-user OAuth2 token is still resolved by v1.
|
||||
|
||||
A server the v2 resolver owns reads its stored token from the resolver at connect
|
||||
|
|
@ -246,7 +242,7 @@ if MCP_AVAILABLE:
|
|||
return False
|
||||
return to_server_spec(server) is None
|
||||
|
||||
def _v1_resolved_oauth2_server_ids(allowed_server_ids: List[str]) -> Set[str]:
|
||||
def _v1_resolved_oauth2_server_ids(allowed_server_ids: list[str]) -> set[str]:
|
||||
"""Return the subset of *allowed_server_ids* whose per-user OAuth2 token is still
|
||||
resolved by v1.
|
||||
|
||||
|
|
@ -260,10 +256,10 @@ if MCP_AVAILABLE:
|
|||
}
|
||||
|
||||
async def _get_user_oauth_extra_headers(
|
||||
server,
|
||||
server: MCPServer,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
prefetched_creds: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
) -> Optional[Dict[str, str]]:
|
||||
prefetched_creds: dict[str, "OAuthCredentialPayload"] | None = None,
|
||||
) -> dict[str, str] | None:
|
||||
"""
|
||||
For OAuth2 servers, look up the user's stored access token and return it
|
||||
as extra_headers {"Authorization": "Bearer <token>"} so that it reaches
|
||||
|
|
@ -315,7 +311,7 @@ if MCP_AVAILABLE:
|
|||
|
||||
async def _prefetch_user_oauth_creds(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
) -> dict[str, "OAuthCredentialPayload"]:
|
||||
"""Fetch all OAuth2 credentials for the user in a single DB query.
|
||||
|
||||
Returns a dict keyed by server_id. Used to avoid N+1 DB queries when
|
||||
|
|
@ -379,8 +375,8 @@ if MCP_AVAILABLE:
|
|||
|
||||
def _resolve_mcp_server_id_for_rest(
|
||||
server_id: str,
|
||||
allowed_server_ids: Union[Set[str], List[str]],
|
||||
client_ip: Optional[str] = None,
|
||||
allowed_server_ids: set[str] | list[str],
|
||||
client_ip: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Map REST ``server_id`` (UUID, server_name, or alias) to canonical server_id.
|
||||
|
|
@ -400,7 +396,7 @@ if MCP_AVAILABLE:
|
|||
request: Request,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
server_id: str,
|
||||
) -> Tuple[List[MCPServer], str]:
|
||||
) -> tuple[list[MCPServer], str]:
|
||||
"""
|
||||
Resolve allowed MCP servers for a tool call with IP filtering.
|
||||
|
||||
|
|
@ -471,7 +467,7 @@ if MCP_AVAILABLE:
|
|||
)
|
||||
|
||||
# Build allowed_mcp_servers list (only include allowed servers)
|
||||
allowed_mcp_servers: List[MCPServer] = []
|
||||
allowed_mcp_servers: list[MCPServer] = []
|
||||
for allowed_server_id in allowed_server_ids_set:
|
||||
server = global_mcp_server_manager.get_mcp_server_by_id(allowed_server_id)
|
||||
if server is not None:
|
||||
|
|
@ -482,9 +478,9 @@ if MCP_AVAILABLE:
|
|||
async def _get_tools_for_single_server(
|
||||
server,
|
||||
server_auth_header,
|
||||
raw_headers: Optional[Dict[str, str]] = None,
|
||||
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
raw_headers: dict[str, str] | None = None,
|
||||
user_api_key_auth: UserAPIKeyAuth | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
apply_tool_filters: bool = True,
|
||||
):
|
||||
"""Helper function to get tools for a single server.
|
||||
|
|
@ -530,7 +526,7 @@ if MCP_AVAILABLE:
|
|||
async def _resolve_allowed_mcp_servers_for_tool_call(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
server_id: str,
|
||||
) -> List[MCPServer]:
|
||||
) -> list[MCPServer]:
|
||||
"""Resolve allowed MCP servers for the given user and validate server_id access."""
|
||||
auth_contexts = await build_effective_auth_contexts(user_api_key_dict)
|
||||
allowed_server_ids_set = set()
|
||||
|
|
@ -545,7 +541,7 @@ if MCP_AVAILABLE:
|
|||
"message": f"The key is not allowed to access server {server_id}",
|
||||
},
|
||||
)
|
||||
allowed_mcp_servers: List[MCPServer] = []
|
||||
allowed_mcp_servers: list[MCPServer] = []
|
||||
for allowed_server_id in allowed_server_ids_set:
|
||||
server = global_mcp_server_manager.get_mcp_server_by_id(allowed_server_id)
|
||||
if server is not None:
|
||||
|
|
@ -554,10 +550,10 @@ if MCP_AVAILABLE:
|
|||
|
||||
async def _list_tools_for_single_server(
|
||||
server_id: str,
|
||||
allowed_server_ids: List[str],
|
||||
rest_client_ip: Optional[str],
|
||||
allowed_server_ids: list[str],
|
||||
rest_client_ip: str | None,
|
||||
mcp_server_auth_headers: dict,
|
||||
mcp_auth_header: Optional[str],
|
||||
mcp_auth_header: str | None,
|
||||
raw_headers_from_request: dict,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
apply_tool_filters: bool = True,
|
||||
|
|
@ -644,12 +640,12 @@ if MCP_AVAILABLE:
|
|||
"message": "Successfully retrieved tools",
|
||||
}
|
||||
|
||||
def _as_query_str(value: Any) -> Optional[str]:
|
||||
def _as_query_str(value: Any) -> str | None:
|
||||
"""Coerce an Optional[str] Query param to str|None, dropping unresolved FastAPI defaults."""
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
async def _resolve_toolset_scope(
|
||||
toolset_name: Optional[str],
|
||||
toolset_name: str | None,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> UserAPIKeyAuth:
|
||||
"""Resolve ``toolset_name`` to its scoped ``UserAPIKeyAuth``, or return unchanged."""
|
||||
|
|
@ -670,11 +666,9 @@ if MCP_AVAILABLE:
|
|||
@router.get("/tools/list", dependencies=[Depends(user_api_key_auth)])
|
||||
async def list_tool_rest_api(
|
||||
request: Request,
|
||||
server_id: Optional[str] = Query(None, description="The server id to list tools for"),
|
||||
mcp_server_name: Optional[str] = Query(
|
||||
None, description="Filter tools to a single MCP server by name or alias"
|
||||
),
|
||||
toolset_name: Optional[str] = Query(None, description="Filter tools to a single toolset by name"),
|
||||
server_id: str | None = Query(None, description="The server id to list tools for"),
|
||||
mcp_server_name: str | None = Query(None, description="Filter tools to a single MCP server by name or alias"),
|
||||
toolset_name: str | None = Query(None, description="Filter tools to a single toolset by name"),
|
||||
include_disabled_tools: bool = Query(
|
||||
False,
|
||||
description=(
|
||||
|
|
@ -981,7 +975,7 @@ if MCP_AVAILABLE:
|
|||
) = await _resolve_allowed_mcp_servers_with_ip_filter(request, user_api_key_dict, server_id)
|
||||
|
||||
# Look up per-user OAuth headers for this server (mirrors list_tool_rest_api).
|
||||
user_oauth_extra_headers: Optional[Dict[str, str]] = None
|
||||
user_oauth_extra_headers: dict[str, str] | None = None
|
||||
target_server = next(
|
||||
(s for s in allowed_mcp_servers if s.server_id == canonical_server_id),
|
||||
None,
|
||||
|
|
@ -1094,18 +1088,18 @@ if MCP_AVAILABLE:
|
|||
(client_id, client_secret, scopes) — any value may be ``None``.
|
||||
"""
|
||||
creds = request.credentials if isinstance(request.credentials, dict) else {}
|
||||
client_id: Optional[str] = creds.get("client_id")
|
||||
client_secret: Optional[str] = creds.get("client_secret")
|
||||
client_id: str | None = creds.get("client_id")
|
||||
client_secret: str | None = creds.get("client_secret")
|
||||
scopes_raw = creds.get("scopes")
|
||||
scopes: Optional[List[str]] = scopes_raw if isinstance(scopes_raw, list) else None
|
||||
scopes: list[str] | None = scopes_raw if isinstance(scopes_raw, list) else None
|
||||
return client_id, client_secret, scopes
|
||||
|
||||
async def _execute_with_mcp_client(
|
||||
request: NewMCPServerRequest,
|
||||
operation: Callable[..., Awaitable[Any]],
|
||||
mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None,
|
||||
oauth2_headers: Optional[Dict[str, str]] = None,
|
||||
raw_headers: Optional[Dict[str, str]] = None,
|
||||
mcp_auth_header: str | dict[str, str] | None = None,
|
||||
oauth2_headers: dict[str, str] | None = None,
|
||||
raw_headers: dict[str, str] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Create a temporary MCP client from *request*, run *operation*, and return the result.
|
||||
|
|
@ -1128,7 +1122,7 @@ if MCP_AVAILABLE:
|
|||
try:
|
||||
client_id, client_secret, scopes = _extract_credentials(request)
|
||||
|
||||
_oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = request.oauth2_flow or (
|
||||
_oauth2_flow: Literal["client_credentials", "authorization_code"] | None = request.oauth2_flow or (
|
||||
"client_credentials" if client_id and client_secret and request.token_url else None
|
||||
)
|
||||
# client_credentials requires token_url to fetch a token; without it the
|
||||
|
|
@ -1244,7 +1238,7 @@ if MCP_AVAILABLE:
|
|||
spec = await load_openapi_spec_async(spec_path)
|
||||
paths = spec.get("paths", {})
|
||||
components = spec.get("components", {})
|
||||
tools: List[dict] = []
|
||||
tools: list[dict] = []
|
||||
used_names: set = set()
|
||||
for path, path_item in paths.items():
|
||||
for method in ("get", "post", "put", "delete", "patch"):
|
||||
|
|
@ -1351,7 +1345,7 @@ if MCP_AVAILABLE:
|
|||
|
||||
headers = request.headers
|
||||
|
||||
mcp_auth_header: Optional[str] = None
|
||||
mcp_auth_header: str | None = None
|
||||
if new_mcp_server_request.auth_type in {
|
||||
MCPAuth.api_key,
|
||||
MCPAuth.bearer_token,
|
||||
|
|
@ -1365,7 +1359,7 @@ if MCP_AVAILABLE:
|
|||
# Authorization doubles as the admission fallback (LITELLM_API_KEY_HEADER_NAME_SECONDARY):
|
||||
# when the primary x-litellm-api-key header is absent, the Authorization value is the
|
||||
# caller's LiteLLM key, not an upstream token, and must never be forwarded upstream.
|
||||
oauth2_headers: Optional[Dict[str, str]] = None
|
||||
oauth2_headers: dict[str, str] | None = None
|
||||
if new_mcp_server_request.auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES and headers.get(
|
||||
MCPRequestHandler.LITELLM_API_KEY_HEADER_NAME_PRIMARY
|
||||
):
|
||||
|
|
@ -1376,8 +1370,8 @@ if MCP_AVAILABLE:
|
|||
return await session.list_tools()
|
||||
|
||||
list_tools_response = await client.run_with_session(_list_tools_session_operation)
|
||||
list_tools_result: List[MCPTool] = list_tools_response.tools
|
||||
model_dumped_tools: List[dict] = [tool.model_dump() for tool in list_tools_result]
|
||||
list_tools_result: list[MCPTool] = list_tools_response.tools
|
||||
model_dumped_tools: list[dict] = [tool.model_dump() for tool in list_tools_result]
|
||||
return {
|
||||
"tools": model_dumped_tools,
|
||||
"error": None,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,9 +1,9 @@
|
|||
1:"$Sreact.fragment"
|
||||
2:I[347257,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0l6q6-77u4dy~.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"ClientPageRoot"]
|
||||
3:I[871135,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0l6q6-77u4dy~.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js","/litellm-asset-prefix/_next/static/chunks/0mqbd99.ej13v.js","/litellm-asset-prefix/_next/static/chunks/16ufy1iyybswo.js","/litellm-asset-prefix/_next/static/chunks/0ayum-x.hkww~.js","/litellm-asset-prefix/_next/static/chunks/15jl-1gcakfwa.js","/litellm-asset-prefix/_next/static/chunks/003_1s9xbht43.js","/litellm-asset-prefix/_next/static/chunks/0u.r3vzo30ofk.js","/litellm-asset-prefix/_next/static/chunks/0g4qcx-c9gsxn.js","/litellm-asset-prefix/_next/static/chunks/14a-un1blorp~.js","/litellm-asset-prefix/_next/static/chunks/02_q4881cz6h~.js","/litellm-asset-prefix/_next/static/chunks/0lg.6rbfsd-l9.js","/litellm-asset-prefix/_next/static/chunks/0wdlbe750tuzr.js","/litellm-asset-prefix/_next/static/chunks/043q3g5-5-aju.js","/litellm-asset-prefix/_next/static/chunks/0nht59ws0elww.js","/litellm-asset-prefix/_next/static/chunks/03zkt5iyjiqcz.js","/litellm-asset-prefix/_next/static/chunks/0l02mpo6za6ie.js","/litellm-asset-prefix/_next/static/chunks/1667t2pcy0iqm.js","/litellm-asset-prefix/_next/static/chunks/0-_m4km7b1~oe.js","/litellm-asset-prefix/_next/static/chunks/0sx3mu2_l9g_y.js","/litellm-asset-prefix/_next/static/chunks/0sqw622fcvsv4.js","/litellm-asset-prefix/_next/static/chunks/0t8el_ijoskx..js","/litellm-asset-prefix/_next/static/chunks/0el08tticy_20.js","/litellm-asset-prefix/_next/static/chunks/0a.ljputcx8g5.js","/litellm-asset-prefix/_next/static/chunks/02-u6qtmsnqn0.js","/litellm-asset-prefix/_next/static/chunks/076.vm.7w-x2..js","/litellm-asset-prefix/_next/static/chunks/069dx5~5osue0.js","/litellm-asset-prefix/_next/static/chunks/0onea0n77pqw1.js","/litellm-asset-prefix/_next/static/chunks/16jfov1k2wrj0.js","/litellm-asset-prefix/_next/static/chunks/0l7em-5kjv49e.js","/litellm-asset-prefix/_next/static/chunks/0mh1wnrvmv_y7.js","/litellm-asset-prefix/_next/static/chunks/01reddhq423_f.js","/litellm-asset-prefix/_next/static/chunks/0h80lrrstjswl.js","/litellm-asset-prefix/_next/static/chunks/0cmepm.jkel-i.js","/litellm-asset-prefix/_next/static/chunks/0q6~n4y84cejn.js","/litellm-asset-prefix/_next/static/chunks/04jv9e6~9vi.l.js","/litellm-asset-prefix/_next/static/chunks/0d_sm.._5mw-p.js","/litellm-asset-prefix/_next/static/chunks/0elr0ye86.44-.js"],"default"]
|
||||
6:I[897367,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0l6q6-77u4dy~.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"OutletBoundary"]
|
||||
2:I[347257,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/3c02m_kr-u94p.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"ClientPageRoot"]
|
||||
3:I[871135,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/3c02m_kr-u94p.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/1ioy8obpggx93.js","/litellm-asset-prefix/_next/static/chunks/3_3dj4vdy-3xy.js","/litellm-asset-prefix/_next/static/chunks/1zr7rrk4wkmju.js","/litellm-asset-prefix/_next/static/chunks/2cngn5bal3278.js","/litellm-asset-prefix/_next/static/chunks/2up3bks93iqds.js","/litellm-asset-prefix/_next/static/chunks/0fk0i3e2aixp7.js","/litellm-asset-prefix/_next/static/chunks/0zduf1gntl_f8.js","/litellm-asset-prefix/_next/static/chunks/0dbvgsc7ha049.js","/litellm-asset-prefix/_next/static/chunks/0g_w4tf2inv3i.js","/litellm-asset-prefix/_next/static/chunks/1vquuz09jxl5_.js","/litellm-asset-prefix/_next/static/chunks/17-6zku8f68gf.js","/litellm-asset-prefix/_next/static/chunks/1iakmimqrlpn0.js","/litellm-asset-prefix/_next/static/chunks/1a0bgy7kzrj91.js","/litellm-asset-prefix/_next/static/chunks/0f5fel02jwglw.js","/litellm-asset-prefix/_next/static/chunks/0g8wwba6umbim.js","/litellm-asset-prefix/_next/static/chunks/1di-caw05k3tq.js","/litellm-asset-prefix/_next/static/chunks/00g6xfr4yow7h.js","/litellm-asset-prefix/_next/static/chunks/2kcxwg1mpncp6.js","/litellm-asset-prefix/_next/static/chunks/1fmx49l6q8v39.js","/litellm-asset-prefix/_next/static/chunks/0ww76lz_0cphv.js","/litellm-asset-prefix/_next/static/chunks/2uc2pi4ob086w.js","/litellm-asset-prefix/_next/static/chunks/2hu1vyy-5pv13.js","/litellm-asset-prefix/_next/static/chunks/2l25bmiiw9ixp.js","/litellm-asset-prefix/_next/static/chunks/1uz3jt-tj9lkf.js","/litellm-asset-prefix/_next/static/chunks/3drq2_k-jeio2.js","/litellm-asset-prefix/_next/static/chunks/0dsiq_ok1yngk.js","/litellm-asset-prefix/_next/static/chunks/3srzg1la93pwv.js","/litellm-asset-prefix/_next/static/chunks/17nqbxvhztf3k.js","/litellm-asset-prefix/_next/static/chunks/2c90xukbd3il6.js","/litellm-asset-prefix/_next/static/chunks/0kap_rdm2-lem.js","/litellm-asset-prefix/_next/static/chunks/09l_m9l1emin2.js","/litellm-asset-prefix/_next/static/chunks/1cea03gg5a_c7.js","/litellm-asset-prefix/_next/static/chunks/323l6h8s7ahat.js","/litellm-asset-prefix/_next/static/chunks/112n0hv3cc2rg.js","/litellm-asset-prefix/_next/static/chunks/199uwr871eene.js","/litellm-asset-prefix/_next/static/chunks/105643dvf00hu.js","/litellm-asset-prefix/_next/static/chunks/12wsfsljxg4xv.js","/litellm-asset-prefix/_next/static/chunks/22iools_e0k44.js","/litellm-asset-prefix/_next/static/chunks/0am68mi9t9cb6.js","/litellm-asset-prefix/_next/static/chunks/23-g73xaw3kap.js"],"default"]
|
||||
6:I[897367,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/3c02m_kr-u94p.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"OutletBoundary"]
|
||||
7:"$Sreact.suspense"
|
||||
0:{"rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/1667t2pcy0iqm.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/0-_m4km7b1~oe.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/0sx3mu2_l9g_y.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/0sqw622fcvsv4.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/0t8el_ijoskx..js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/0el08tticy_20.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/0a.ljputcx8g5.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/02-u6qtmsnqn0.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/076.vm.7w-x2..js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/069dx5~5osue0.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/0onea0n77pqw1.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/16jfov1k2wrj0.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/0l7em-5kjv49e.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/0mh1wnrvmv_y7.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/01reddhq423_f.js","async":true}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/0h80lrrstjswl.js","async":true}],["$","script","script-16",{"src":"/litellm-asset-prefix/_next/static/chunks/0cmepm.jkel-i.js","async":true}],["$","script","script-17",{"src":"/litellm-asset-prefix/_next/static/chunks/0q6~n4y84cejn.js","async":true}],["$","script","script-18",{"src":"/litellm-asset-prefix/_next/static/chunks/04jv9e6~9vi.l.js","async":true}],["$","script","script-19",{"src":"/litellm-asset-prefix/_next/static/chunks/0d_sm.._5mw-p.js","async":true}],["$","script","script-20",{"src":"/litellm-asset-prefix/_next/static/chunks/0elr0ye86.44-.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"DSHomUr6Sq46Bm2WLdUas"}
|
||||
0:{"rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/00g6xfr4yow7h.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/2kcxwg1mpncp6.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/1fmx49l6q8v39.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/0ww76lz_0cphv.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/2uc2pi4ob086w.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/2hu1vyy-5pv13.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/2l25bmiiw9ixp.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/1uz3jt-tj9lkf.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/3drq2_k-jeio2.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/0dsiq_ok1yngk.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/3srzg1la93pwv.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/17nqbxvhztf3k.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/2c90xukbd3il6.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/0kap_rdm2-lem.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/09l_m9l1emin2.js","async":true}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/1cea03gg5a_c7.js","async":true}],["$","script","script-16",{"src":"/litellm-asset-prefix/_next/static/chunks/323l6h8s7ahat.js","async":true}],["$","script","script-17",{"src":"/litellm-asset-prefix/_next/static/chunks/112n0hv3cc2rg.js","async":true}],["$","script","script-18",{"src":"/litellm-asset-prefix/_next/static/chunks/199uwr871eene.js","async":true}],["$","script","script-19",{"src":"/litellm-asset-prefix/_next/static/chunks/105643dvf00hu.js","async":true}],["$","script","script-20",{"src":"/litellm-asset-prefix/_next/static/chunks/12wsfsljxg4xv.js","async":true}],["$","script","script-21",{"src":"/litellm-asset-prefix/_next/static/chunks/22iools_e0k44.js","async":true}],["$","script","script-22",{"src":"/litellm-asset-prefix/_next/static/chunks/0am68mi9t9cb6.js","async":true}],["$","script","script-23",{"src":"/litellm-asset-prefix/_next/static/chunks/23-g73xaw3kap.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"qXutWsQW5C1Pf62WxTkEI"}
|
||||
4:{}
|
||||
5:"$0:rsc:props:children:0:props:serverProvidedParams:params"
|
||||
8:null
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
1:"$Sreact.fragment"
|
||||
2:I[92825,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0l6q6-77u4dy~.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"ClientSegmentRoot"]
|
||||
3:I[216370,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0l6q6-77u4dy~.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js","/litellm-asset-prefix/_next/static/chunks/0mqbd99.ej13v.js","/litellm-asset-prefix/_next/static/chunks/16ufy1iyybswo.js","/litellm-asset-prefix/_next/static/chunks/0ayum-x.hkww~.js","/litellm-asset-prefix/_next/static/chunks/15jl-1gcakfwa.js","/litellm-asset-prefix/_next/static/chunks/003_1s9xbht43.js","/litellm-asset-prefix/_next/static/chunks/0u.r3vzo30ofk.js","/litellm-asset-prefix/_next/static/chunks/0g4qcx-c9gsxn.js","/litellm-asset-prefix/_next/static/chunks/14a-un1blorp~.js","/litellm-asset-prefix/_next/static/chunks/02_q4881cz6h~.js","/litellm-asset-prefix/_next/static/chunks/0lg.6rbfsd-l9.js","/litellm-asset-prefix/_next/static/chunks/0wdlbe750tuzr.js","/litellm-asset-prefix/_next/static/chunks/043q3g5-5-aju.js","/litellm-asset-prefix/_next/static/chunks/0nht59ws0elww.js","/litellm-asset-prefix/_next/static/chunks/03zkt5iyjiqcz.js","/litellm-asset-prefix/_next/static/chunks/0l02mpo6za6ie.js"],"default"]
|
||||
4:I[339756,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0l6q6-77u4dy~.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"default"]
|
||||
5:I[837457,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0l6q6-77u4dy~.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"default"]
|
||||
0:{"rsc":["$","$1","c",{"children":[[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/0mqbd99.ej13v.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/16ufy1iyybswo.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/0ayum-x.hkww~.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/15jl-1gcakfwa.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/003_1s9xbht43.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/0u.r3vzo30ofk.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/0g4qcx-c9gsxn.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/14a-un1blorp~.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/02_q4881cz6h~.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/0lg.6rbfsd-l9.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/0wdlbe750tuzr.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/043q3g5-5-aju.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/0nht59ws0elww.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/03zkt5iyjiqcz.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/0l02mpo6za6ie.js","async":true}]],["$","$L2",null,{"Component":"$3","slots":{"children":["$","$L4",null,{"parallelRouterKey":"children","template":["$","$L5",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]},"serverProvidedParams":{"params":{},"promises":["$@6"]}}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"DSHomUr6Sq46Bm2WLdUas"}
|
||||
2:I[92825,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/3c02m_kr-u94p.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"ClientSegmentRoot"]
|
||||
3:I[216370,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/3c02m_kr-u94p.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/1ioy8obpggx93.js","/litellm-asset-prefix/_next/static/chunks/3_3dj4vdy-3xy.js","/litellm-asset-prefix/_next/static/chunks/1zr7rrk4wkmju.js","/litellm-asset-prefix/_next/static/chunks/2cngn5bal3278.js","/litellm-asset-prefix/_next/static/chunks/2up3bks93iqds.js","/litellm-asset-prefix/_next/static/chunks/0fk0i3e2aixp7.js","/litellm-asset-prefix/_next/static/chunks/0zduf1gntl_f8.js","/litellm-asset-prefix/_next/static/chunks/0dbvgsc7ha049.js","/litellm-asset-prefix/_next/static/chunks/0g_w4tf2inv3i.js","/litellm-asset-prefix/_next/static/chunks/1vquuz09jxl5_.js","/litellm-asset-prefix/_next/static/chunks/17-6zku8f68gf.js","/litellm-asset-prefix/_next/static/chunks/1iakmimqrlpn0.js","/litellm-asset-prefix/_next/static/chunks/1a0bgy7kzrj91.js","/litellm-asset-prefix/_next/static/chunks/0f5fel02jwglw.js","/litellm-asset-prefix/_next/static/chunks/0g8wwba6umbim.js","/litellm-asset-prefix/_next/static/chunks/1di-caw05k3tq.js"],"default"]
|
||||
4:I[339756,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/3c02m_kr-u94p.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"default"]
|
||||
5:I[837457,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/3c02m_kr-u94p.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"default"]
|
||||
0:{"rsc":["$","$1","c",{"children":[[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/1ioy8obpggx93.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/3_3dj4vdy-3xy.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/1zr7rrk4wkmju.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/2cngn5bal3278.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/2up3bks93iqds.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/0fk0i3e2aixp7.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/0zduf1gntl_f8.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/0dbvgsc7ha049.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/0g_w4tf2inv3i.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/1vquuz09jxl5_.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/17-6zku8f68gf.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/1iakmimqrlpn0.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/1a0bgy7kzrj91.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/0f5fel02jwglw.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/0g8wwba6umbim.js","async":true}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/1di-caw05k3tq.js","async":true}]],["$","$L2",null,{"Component":"$3","slots":{"children":["$","$L4",null,{"parallelRouterKey":"children","template":["$","$L5",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]},"serverProvidedParams":{"params":{},"promises":["$@6"]}}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"qXutWsQW5C1Pf62WxTkEI"}
|
||||
6:"$0:rsc:props:children:1:props:serverProvidedParams:params"
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,6 @@
|
|||
1:"$Sreact.fragment"
|
||||
2:I[897367,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0l6q6-77u4dy~.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"ViewportBoundary"]
|
||||
3:I[897367,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0l6q6-77u4dy~.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"MetadataBoundary"]
|
||||
2:I[897367,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/3c02m_kr-u94p.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"ViewportBoundary"]
|
||||
3:I[897367,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/3c02m_kr-u94p.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"MetadataBoundary"]
|
||||
4:"$Sreact.suspense"
|
||||
5:I[27201,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0l6q6-77u4dy~.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"IconMark"]
|
||||
0:{"rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"LiteLLM Dashboard"}],["$","meta","1",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0~dgapwhi~75y.ico","sizes":"48x48","type":"image/x-icon"}],["$","link","3",{"rel":"icon","href":"/get_favicon"}],["$","$L5","4",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"DSHomUr6Sq46Bm2WLdUas"}
|
||||
5:I[27201,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/3c02m_kr-u94p.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"IconMark"]
|
||||
0:{"rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"LiteLLM Dashboard"}],["$","meta","1",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.3arlap5n8tyzg.ico","sizes":"48x48","type":"image/x-icon"}],["$","link","3",{"rel":"icon","href":"/get_favicon"}],["$","$L5","4",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"qXutWsQW5C1Pf62WxTkEI"}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
1:"$Sreact.fragment"
|
||||
2:I[867271,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0l6q6-77u4dy~.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"default"]
|
||||
3:I[71195,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0l6q6-77u4dy~.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"default"]
|
||||
4:I[557951,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0l6q6-77u4dy~.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"AuthProvider"]
|
||||
5:I[339756,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0l6q6-77u4dy~.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"default"]
|
||||
6:I[837457,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0l6q6-77u4dy~.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"default"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/05qmwjqau64bz.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/0aapv6n5bztwf.css","style"]
|
||||
0:{"rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/05qmwjqau64bz.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/0aapv6n5bztwf.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/0l6q6-77u4dy~.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"inter_5972bc34-module__OU16Qa__className","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"children":["$","$L5",null,{"parallelRouterKey":"children","template":["$","$L6",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]}]}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"DSHomUr6Sq46Bm2WLdUas"}
|
||||
2:I[867271,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/3c02m_kr-u94p.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"default"]
|
||||
3:I[71195,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/3c02m_kr-u94p.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"default"]
|
||||
4:I[557951,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/3c02m_kr-u94p.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"AuthProvider"]
|
||||
5:I[339756,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/3c02m_kr-u94p.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"default"]
|
||||
6:I[837457,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/3c02m_kr-u94p.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"default"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/1kid9zr1--h6y.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/3254j4ut19q6_.css","style"]
|
||||
0:{"rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/1kid9zr1--h6y.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/3254j4ut19q6_.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/3c02m_kr-u94p.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"inter_5972bc34-module__OU16Qa__className","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"children":["$","$L5",null,{"parallelRouterKey":"children","template":["$","$L6",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]}]}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"qXutWsQW5C1Pf62WxTkEI"}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
:HL["/litellm-asset-prefix/_next/static/chunks/05qmwjqau64bz.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/0aapv6n5bztwf.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/media/83afe278b6a6bb3c-s.p.0q-301v4kxxnr.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
||||
0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"(dashboard)","param":null,"prefetchHints":0,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}}}},"staleTime":300,"buildId":"DSHomUr6Sq46Bm2WLdUas"}
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/1kid9zr1--h6y.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/3254j4ut19q6_.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/media/83afe278b6a6bb3c-s.p.2bn3s6zvc0dyp.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
||||
0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"(dashboard)","param":null,"prefetchHints":0,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}}}},"staleTime":300,"buildId":"qXutWsQW5C1Pf62WxTkEI"}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue