Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/elegant-edison-bf44a2

This commit is contained in:
Yuneng Jiang 2026-07-10 11:56:35 -07:00
commit 94f27810fb
No known key found for this signature in database
361 changed files with 7001 additions and 920 deletions

View file

@ -0,0 +1,48 @@
name: "Detect backend-relevant changes"
description: >-
Classify the pull request's changed files with .circleci/scripts/classify_changes.sh
and expose decision=run|skip. decision=skip means only ui/**, **.md or **.mdx files
changed, so callers can short-circuit expensive steps while the job still completes
successfully and satisfies its required status check. The decision defaults to run for
any non pull_request event or whenever the changed set cannot be resolved, so tests are
never skipped when the classification is uncertain.
outputs:
decision:
description: "run when backend-relevant files changed, otherwise skip"
value: ${{ steps.classify.outputs.decision }}
runs:
using: composite
steps:
- id: classify
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -uo pipefail
if [ -z "${BASE_SHA:-}" ]; then
echo "detect-backend-changes: not a pull_request event; running job"
echo "decision=run" >> "${GITHUB_OUTPUT}"
exit 0
fi
if ! git fetch --no-tags --depth=1 origin "${BASE_SHA}" >/dev/null 2>&1; then
echo "detect-backend-changes: could not fetch base ${BASE_SHA}; running job"
echo "decision=run" >> "${GITHUB_OUTPUT}"
exit 0
fi
changed="$(git diff --name-only "${BASE_SHA}" HEAD 2>/dev/null)" || {
echo "detect-backend-changes: git diff failed; running job"
echo "decision=run" >> "${GITHUB_OUTPUT}"
exit 0
}
if [ -z "${changed}" ]; then
echo "detect-backend-changes: no changed files vs ${BASE_SHA}; skipping job"
echo "decision=skip" >> "${GITHUB_OUTPUT}"
exit 0
fi
echo "detect-backend-changes: changed files vs ${BASE_SHA}:"
printf '%s\n' "${changed}" | sed 's/^/ /'
decision="$(printf '%s\n' "${changed}" | bash .circleci/scripts/classify_changes.sh backend)" || decision="run"
echo "detect-backend-changes: decision=${decision}"
echo "decision=${decision}" >> "${GITHUB_OUTPUT}"

View file

@ -45,12 +45,18 @@ jobs:
name: Run tests
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout-minutes }}
outputs:
decision: ${{ steps.changes.outputs.decision }}
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Detect backend-relevant changes
id: changes
uses: ./.github/actions/detect-backend-changes
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
@ -72,16 +78,19 @@ jobs:
${{ runner.os }}-uv-
- name: Install dependencies
if: steps.changes.outputs.decision != 'skip'
run: |
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router
- name: Generate Prisma client
if: steps.changes.outputs.decision != 'skip'
env:
PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache
run: |
uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
- name: Run tests
if: steps.changes.outputs.decision != 'skip'
env:
TEST_PATH: ${{ inputs.test-path }}
MAX_FAILURES: ${{ inputs.max-failures }}
@ -114,7 +123,7 @@ jobs:
fi
- name: Save coverage report
if: always()
if: always() && steps.changes.outputs.decision != 'skip'
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: coverage-${{ inputs.artifact-name }}-${{ github.run_id }}-${{ github.run_attempt }}
@ -124,7 +133,7 @@ jobs:
upload-coverage:
name: Upload coverage to Codecov
needs: run
if: always()
if: always() && needs.run.outputs.decision != 'skip'
runs-on: ubuntu-latest
permissions:
contents: read

View file

@ -32,6 +32,10 @@ jobs:
path: docs/my-website
persist-credentials: false
- name: Detect backend-relevant changes
id: changes
uses: ./.github/actions/detect-backend-changes
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
@ -53,10 +57,12 @@ jobs:
${{ runner.os }}-uv-
- name: Install dependencies
if: steps.changes.outputs.decision != 'skip'
run: |
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router
- name: Generate Prisma client
if: steps.changes.outputs.decision != 'skip'
env:
PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache
run: |
@ -64,6 +70,7 @@ jobs:
# Run the same documentation tests that CircleCI ran (as direct Python scripts)
- name: Run documentation validation tests
if: steps.changes.outputs.decision != 'skip'
run: |
uv run --no-sync python ./tests/documentation_tests/test_env_keys.py
uv run --no-sync python ./tests/documentation_tests/test_router_settings.py

View file

@ -49,6 +49,10 @@ jobs:
with:
persist-credentials: false
- name: Detect backend-relevant changes
id: changes
uses: ./.github/actions/detect-backend-changes
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
@ -70,16 +74,19 @@ jobs:
${{ runner.os }}-uv-
- name: Install dependencies
if: steps.changes.outputs.decision != 'skip'
run: |
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router
- name: Generate Prisma client
if: steps.changes.outputs.decision != 'skip'
env:
PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache
run: |
uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
- name: Run tests - ${{ matrix.test-group.name }}
if: steps.changes.outputs.decision != 'skip'
env:
TEST_PATH: ${{ matrix.test-group.path }}
run: |

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "dcr_bridge" BOOLEAN;

View file

@ -339,6 +339,7 @@ model LiteLLM_MCPServerTable {
available_on_public_internet Boolean @default(true)
delegate_auth_to_upstream Boolean @default(false)
oauth_passthrough Boolean @default(false)
dcr_bridge Boolean?
is_byok Boolean @default(false)
byok_description String[] @default([])
byok_api_key_help_url String?

View file

@ -715,6 +715,7 @@ openai_compatible_endpoints: List = [
"https://api.clarifai.com/v2/ext/openai/v1",
"https://api.libertai.io/v1",
"https://pinstripes.io/v1",
"https://api.meta.ai/v1",
]
@ -781,6 +782,7 @@ openai_compatible_providers: List = [
"ragflow",
"pinstripes", # Pinstripes - JSON-configured provider
"darkbloom",
"meta", # Meta Model API (Muse Spark) - JSON-configured provider
]
openai_text_completion_compatible_providers: List = [ # providers that support `/v1/completions`
"together_ai",

View file

@ -757,6 +757,12 @@ class CustomGuardrail(CustomLogger):
# raw provider JSON so redaction is not duplicated upstream).
clean_guardrail_response = redact_nested_match_and_regex_keys(clean_guardrail_response)
from litellm.litellm_core_utils.sensitive_data_masker import (
mask_credentials_in_payload,
)
clean_guardrail_response = mask_credentials_in_payload(clean_guardrail_response)
slg = StandardLoggingGuardrailInformation(
guardrail_name=self.guardrail_name,
guardrail_provider=guardrail_provider,

View file

@ -346,6 +346,9 @@ def get_llm_provider(
elif endpoint == "https://pinstripes.io/v1":
custom_llm_provider = "pinstripes"
dynamic_api_key = get_secret_str("PINSTRIPES_API_KEY")
elif endpoint == "https://api.meta.ai/v1":
custom_llm_provider = "meta"
dynamic_api_key = get_secret_str("META_API_KEY")
if api_base is not None and not isinstance(api_base, str):
raise Exception("api base needs to be a string. api_base={}".format(api_base))

View file

@ -1,6 +1,8 @@
from collections.abc import Mapping
from typing import Any, Dict, List, Optional, Set
from pydantic import BaseModel
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER
@ -153,6 +155,39 @@ def mask_sensitive_structure(data: object) -> object:
return _error_masker.mask(data)
def mask_credentials_in_payload(data: object) -> object:
"""Return a copy of ``data`` where string values under sensitive-named keys
are masked but every other value (``None``, ``int``, ``float``, ``bool``,
``bytes``, ``datetime``, tuples, sets, typed objects) is preserved by
identity, and dicts/lists are rebuilt structurally.
Use this for logging payloads that carry response data through to
SpendLogs / OTel / Langfuse, where :meth:`SensitiveDataMasker.mask`'s
config-dump semantics (``None`` -> ``"None"``, tuples stringified,
objects flattened via ``__dict__``) would silently distort the record.
Sensitive-key detection is delegated to the shared
:class:`SensitiveDataMasker` so pattern updates stay in one place.
"""
return _walk_payload(data, key_is_sensitive=False, depth=0)
def _walk_payload(node: object, key_is_sensitive: bool, depth: int) -> object:
if depth >= DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER:
return node
if isinstance(node, Mapping):
return {k: _walk_payload(v, _default_masker.is_sensitive_key(k), depth + 1) for k, v in node.items()}
if isinstance(node, list):
return [_walk_payload(item, key_is_sensitive, depth + 1) for item in node]
if isinstance(node, tuple):
return tuple(_walk_payload(item, key_is_sensitive, depth + 1) for item in node)
if isinstance(node, BaseModel):
return _walk_payload(node.model_dump(), key_is_sensitive, depth)
if key_is_sensitive and isinstance(node, str) and node:
return _default_masker._mask_value(node)
return node
def mask_sensitive_keys(data: Dict[str, Any], sensitive_fields: Set[str]) -> Dict[str, Any]:
"""Return a new dict with values masked for keys listed in ``sensitive_fields``.

View file

@ -91,7 +91,7 @@ def create_config_class(provider: SimpleProviderConfig):
def get_supported_openai_params(self, model: str) -> list:
"""Get supported OpenAI params, excluding tool-related params for models
that don't support function calling."""
from litellm.utils import supports_function_calling
from litellm.utils import supports_function_calling, supports_reasoning
supported_params = super().get_supported_openai_params(model=model)
@ -113,6 +113,10 @@ def create_config_class(provider: SimpleProviderConfig):
f"function calling — removed tool-related params from supported params."
)
_supports_reasoning = supports_reasoning(model=model, custom_llm_provider=provider.slug)
if _supports_reasoning and "reasoning_effort" not in supported_params:
supported_params.append("reasoning_effort")
return supported_params
def map_openai_params(

View file

@ -1,8 +1,11 @@
from typing import Any, Optional
import litellm
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
AnthropicMessagesConfig,
)
from litellm.llms.openai_like.json_loader import SimpleProviderConfig
from litellm.secret_managers.main import get_secret_str
DEFAULT_ANTHROPIC_API_VERSION = "2023-06-01"
@ -67,3 +70,65 @@ class OpenAILikeAnthropicMessagesConfig(AnthropicMessagesConfig):
if base.endswith("/v1"):
base = base[: -len("/v1")]
return f"{base}/v1/messages"
class JSONProviderAnthropicMessagesConfig(OpenAILikeAnthropicMessagesConfig):
"""
Provider-level native Anthropic Messages passthrough for JSON-configured
OpenAI-compatible providers whose ``supported_endpoints`` in providers.json
includes ``"/v1/messages"``. Resolves the api key and api base from the
provider's configured env vars, then forwards the Anthropic payload
untranslated like ``OpenAILikeAnthropicMessagesConfig``.
"""
def __init__(self, provider: SimpleProviderConfig):
super().__init__()
self._provider = provider
def should_strip_billing_metadata(self) -> bool:
return True
def _resolve_api_key(self, api_key: Optional[str]) -> Optional[str]:
return api_key or get_secret_str(self._provider.api_key_env) or litellm.api_key
def _resolve_api_base(self, api_base: Optional[str]) -> str:
env_api_base = get_secret_str(self._provider.api_base_env) if self._provider.api_base_env else None
return api_base or env_api_base or self._provider.base_url
def validate_anthropic_messages_environment(
self,
headers: dict[str, str],
model: str,
messages: list[Any],
optional_params: dict,
litellm_params: dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
) -> tuple[dict[str, str], Optional[str]]:
return super().validate_anthropic_messages_environment(
headers=headers,
model=model,
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
api_key=self._resolve_api_key(api_key),
api_base=api_base,
)
def get_complete_url(
self,
api_base: Optional[str],
api_key: Optional[str],
model: str,
optional_params: dict,
litellm_params: dict,
stream: Optional[bool] = None,
) -> str:
return super().get_complete_url(
api_base=self._resolve_api_base(api_base),
api_key=api_key,
model=model,
optional_params=optional_params,
litellm_params=litellm_params,
stream=stream,
)

View file

@ -168,6 +168,13 @@
},
"supported_endpoints": ["/v1/chat/completions", "/v1/responses"]
},
"meta": {
"base_url": "https://api.meta.ai/v1",
"api_key_env": "META_API_KEY",
"api_base_env": "META_API_BASE",
"base_class": "openai_gpt",
"supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages"]
},
"pinstripes": {
"base_url": "https://pinstripes.io/v1",
"api_key_env": "PINSTRIPES_API_KEY",

View file

@ -6012,6 +6012,522 @@
"supports_vision": true,
"supports_web_search": true
},
"azure/gpt-5.6": {
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
"cache_read_input_token_cost_priority": 1e-06,
"cache_read_input_token_cost_above_272k_tokens_priority": 2e-06,
"input_cost_per_token": 5e-06,
"input_cost_per_token_above_272k_tokens": 1e-05,
"input_cost_per_token_priority": 1e-05,
"input_cost_per_token_above_272k_tokens_priority": 2e-05,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 3e-05,
"output_cost_per_token_above_272k_tokens": 4.5e-05,
"output_cost_per_token_priority": 6e-05,
"output_cost_per_token_above_272k_tokens_priority": 9e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/gpt-5.6-sol": {
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
"cache_read_input_token_cost_priority": 1e-06,
"cache_read_input_token_cost_above_272k_tokens_priority": 2e-06,
"input_cost_per_token": 5e-06,
"input_cost_per_token_above_272k_tokens": 1e-05,
"input_cost_per_token_priority": 1e-05,
"input_cost_per_token_above_272k_tokens_priority": 2e-05,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 3e-05,
"output_cost_per_token_above_272k_tokens": 4.5e-05,
"output_cost_per_token_priority": 6e-05,
"output_cost_per_token_above_272k_tokens_priority": 9e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/gpt-5.6-terra": {
"cache_read_input_token_cost": 2.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 5e-07,
"cache_read_input_token_cost_priority": 5e-07,
"cache_read_input_token_cost_above_272k_tokens_priority": 1e-06,
"input_cost_per_token": 2.5e-06,
"input_cost_per_token_above_272k_tokens": 5e-06,
"input_cost_per_token_priority": 5e-06,
"input_cost_per_token_above_272k_tokens_priority": 1e-05,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1.5e-05,
"output_cost_per_token_above_272k_tokens": 2.25e-05,
"output_cost_per_token_priority": 3e-05,
"output_cost_per_token_above_272k_tokens_priority": 4.5e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/gpt-5.6-luna": {
"cache_read_input_token_cost": 1e-07,
"cache_read_input_token_cost_above_272k_tokens": 2e-07,
"cache_read_input_token_cost_priority": 2e-07,
"cache_read_input_token_cost_above_272k_tokens_priority": 4e-07,
"input_cost_per_token": 1e-06,
"input_cost_per_token_above_272k_tokens": 2e-06,
"input_cost_per_token_priority": 2e-06,
"input_cost_per_token_above_272k_tokens_priority": 4e-06,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 6e-06,
"output_cost_per_token_above_272k_tokens": 9e-06,
"output_cost_per_token_priority": 1.2e-05,
"output_cost_per_token_above_272k_tokens_priority": 1.8e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/us/gpt-5.6": {
"cache_read_input_token_cost": 5.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1.1e-06,
"cache_read_input_token_cost_priority": 1.375e-06,
"input_cost_per_token": 5.5e-06,
"input_cost_per_token_above_272k_tokens": 1.1e-05,
"input_cost_per_token_priority": 1.375e-05,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 3.3e-05,
"output_cost_per_token_above_272k_tokens": 4.95e-05,
"output_cost_per_token_priority": 8.25e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/us/gpt-5.6-sol": {
"cache_read_input_token_cost": 5.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1.1e-06,
"cache_read_input_token_cost_priority": 1.375e-06,
"input_cost_per_token": 5.5e-06,
"input_cost_per_token_above_272k_tokens": 1.1e-05,
"input_cost_per_token_priority": 1.375e-05,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 3.3e-05,
"output_cost_per_token_above_272k_tokens": 4.95e-05,
"output_cost_per_token_priority": 8.25e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/us/gpt-5.6-terra": {
"cache_read_input_token_cost": 2.75e-07,
"cache_read_input_token_cost_above_272k_tokens": 5.5e-07,
"cache_read_input_token_cost_priority": 6.875e-07,
"input_cost_per_token": 2.75e-06,
"input_cost_per_token_above_272k_tokens": 5.5e-06,
"input_cost_per_token_priority": 6.875e-06,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1.65e-05,
"output_cost_per_token_above_272k_tokens": 2.475e-05,
"output_cost_per_token_priority": 4.125e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/us/gpt-5.6-luna": {
"cache_read_input_token_cost": 1.1e-07,
"cache_read_input_token_cost_above_272k_tokens": 2.2e-07,
"cache_read_input_token_cost_priority": 2.75e-07,
"input_cost_per_token": 1.1e-06,
"input_cost_per_token_above_272k_tokens": 2.2e-06,
"input_cost_per_token_priority": 2.75e-06,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 6.6e-06,
"output_cost_per_token_above_272k_tokens": 9.9e-06,
"output_cost_per_token_priority": 1.65e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/eu/gpt-5.6": {
"cache_read_input_token_cost": 5.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1.1e-06,
"cache_read_input_token_cost_priority": 1.375e-06,
"input_cost_per_token": 5.5e-06,
"input_cost_per_token_above_272k_tokens": 1.1e-05,
"input_cost_per_token_priority": 1.375e-05,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 3.3e-05,
"output_cost_per_token_above_272k_tokens": 4.95e-05,
"output_cost_per_token_priority": 8.25e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/eu/gpt-5.6-sol": {
"cache_read_input_token_cost": 5.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1.1e-06,
"cache_read_input_token_cost_priority": 1.375e-06,
"input_cost_per_token": 5.5e-06,
"input_cost_per_token_above_272k_tokens": 1.1e-05,
"input_cost_per_token_priority": 1.375e-05,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 3.3e-05,
"output_cost_per_token_above_272k_tokens": 4.95e-05,
"output_cost_per_token_priority": 8.25e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/eu/gpt-5.6-terra": {
"cache_read_input_token_cost": 2.75e-07,
"cache_read_input_token_cost_above_272k_tokens": 5.5e-07,
"cache_read_input_token_cost_priority": 6.875e-07,
"input_cost_per_token": 2.75e-06,
"input_cost_per_token_above_272k_tokens": 5.5e-06,
"input_cost_per_token_priority": 6.875e-06,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1.65e-05,
"output_cost_per_token_above_272k_tokens": 2.475e-05,
"output_cost_per_token_priority": 4.125e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/eu/gpt-5.6-luna": {
"cache_read_input_token_cost": 1.1e-07,
"cache_read_input_token_cost_above_272k_tokens": 2.2e-07,
"cache_read_input_token_cost_priority": 2.75e-07,
"input_cost_per_token": 1.1e-06,
"input_cost_per_token_above_272k_tokens": 2.2e-06,
"input_cost_per_token_priority": 2.75e-06,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 6.6e-06,
"output_cost_per_token_above_272k_tokens": 9.9e-06,
"output_cost_per_token_priority": 1.65e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/gpt-5.5": {
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
@ -25501,6 +26017,42 @@
"supports_function_calling": true,
"supports_tool_choice": false
},
"meta/muse-spark-1.1": {
"cache_read_input_token_cost": 1.5e-07,
"input_cost_per_token": 1.25e-06,
"litellm_provider": "meta",
"max_input_tokens": 1048576,
"max_output_tokens": 131072,
"max_tokens": 131072,
"mode": "chat",
"output_cost_per_token": 4.25e-06,
"source": "https://dev.meta.ai/docs/getting-started/pricing-rate-limits",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/responses",
"/v1/messages"
],
"supported_modalities": [
"text",
"image",
"video"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_minimal_reasoning_effort": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_xhigh_reasoning_effort": true
},
"meta_llama/Llama-3.3-70B-Instruct": {
"litellm_provider": "meta_llama",
"max_input_tokens": 128000,

View file

@ -96,6 +96,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
available_on_public_internet: bool = True
delegate_auth_to_upstream: bool = False
oauth_passthrough: bool = False
dcr_bridge: Optional[bool] = None
is_byok: bool = False
byok_description: List[str] = Field(default_factory=list)
byok_api_key_help_url: Optional[str] = None

View file

@ -3,7 +3,7 @@ import binascii
import hashlib
import json
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Set, Union, cast
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterable, List, Optional, Set, Union, cast
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
@ -52,6 +52,7 @@ _AUTH_FLOW_SCOPED_FIELDS: frozenset = frozenset(
"token_url",
"registration_url",
"oauth2_flow",
"dcr_bridge",
"token_exchange_endpoint",
"audience",
"subject_token_type",
@ -558,7 +559,11 @@ async def delete_mcp_server_from_virtualkey():
pass
async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Optional[LiteLLM_MCPServerTable]:
async def delete_mcp_server(
prisma_client: PrismaClient,
server_id: str,
invalidate_token_cache: Optional[Callable[[str, str], Awaitable[None]]] = None,
) -> Optional[LiteLLM_MCPServerTable]:
"""
Delete the mcp server from the db by server_id
@ -569,6 +574,12 @@ async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Opti
caller-visible error. Each table is cleaned independently so a failure on one
still attempts the other.
Each enumerated credential row's user also gets their cached per-user token
invalidated (legacy cache + v2 store, via invalidate_token_cache, defaulting
to the manager's shared invalidation): the caches are keyed by
(user_id, server_id), so without this a re-created server reusing the same
server_id would serve tokens minted for the deleted server until TTL.
Returns the deleted mcp server record if it exists, otherwise None
"""
deleted_server = await MCPServerRepository(prisma_client).table.delete(
@ -577,6 +588,18 @@ async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Opti
},
)
if deleted_server is not None:
credential_user_ids: List[str] = []
try:
credential_rows = await prisma_client.db.litellm_mcpusercredentials.find_many(
where={"server_id": server_id}
)
credential_user_ids = [row.user_id for row in credential_rows]
except Exception as e: # noqa: BLE001 - enumeration is best-effort; cached tokens expire by TTL
verbose_proxy_logger.warning(
"MCP server %s deleted but per-user credential enumeration failed; cached tokens expire by TTL: %s",
server_id,
e,
)
for model, label in (
(prisma_client.db.litellm_mcpusercredentials, "credential"),
(prisma_client.db.litellm_mcpuserenvvars, "env var"),
@ -591,6 +614,15 @@ async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Opti
label,
e,
)
if credential_user_ids:
if invalidate_token_cache is None:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
invalidate_token_cache = global_mcp_server_manager.invalidate_user_oauth_token_cache
for user_id in credential_user_ids:
await invalidate_token_cache(user_id, server_id)
return deleted_server
@ -1070,6 +1102,103 @@ async def list_user_oauth_credentials(
return results
def _decrypted_credential_field(creds: Dict[str, object], field: str) -> object:
"""Return one credential field decrypted with the global salt key; non-string and legacy
plaintext values come back unchanged (decrypt_value_helper returns the original on failure)."""
value = creds.get(field)
if not isinstance(value, str):
return value
return decrypt_value_helper(
value=value,
key=field,
exception_type="debug",
return_original_value=True,
)
def mcp_oauth_token_identity(server: object) -> tuple[object, ...]:
"""The upstream-OAuth-token-determining fields of an MCP server: the resource/audience (url, or
spec_path for OpenAPI servers), the OAuth mode/grant (auth_type, oauth2_flow), the
authorization-server endpoints, and the OAuth client + scopes. Mirrors the dashboard's
getOAuthAuthorizationIdentity. When any of these change on a server update, previously stored
per-user tokens were minted for the old identity and are stale. Excludes transport and
delegate_auth_to_upstream, which do not affect what token is minted (RFC 8707/8693).
client_id/client_secret are compared decrypted: stored values are NaCl-encrypted with a fresh
nonce on every write, so comparing ciphertext would flag every routine save as an identity
change and purge tokens that are still valid."""
creds = getattr(server, "credentials", None)
if isinstance(creds, str):
try:
parsed: object = json.loads(creds)
except ValueError:
parsed = None
else:
parsed = creds
creds_dict: Dict[str, object] = parsed if isinstance(parsed, dict) else {}
return (
getattr(server, "url", None),
getattr(server, "spec_path", None),
getattr(server, "auth_type", None),
getattr(server, "oauth2_flow", None),
getattr(server, "authorization_url", None),
getattr(server, "token_url", None),
getattr(server, "registration_url", None),
_decrypted_credential_field(creds_dict, "client_id"),
_decrypted_credential_field(creds_dict, "client_secret"),
creds_dict.get("scopes"),
)
async def purge_user_oauth_credentials_for_server(
prisma_client: PrismaClient,
server_id: str,
invalidate_token_cache: Optional[Callable[[str, str], Awaitable[None]]] = None,
) -> int:
"""Delete every stored per-user OAuth token for a server and invalidate each user's cached
token everywhere it can be served from (the legacy per-user token cache and the v2 per-user OAuth
token store), so no user keeps a token minted for a superseded configuration. Called when a server
update changes a mint-relevant field (see mcp_oauth_token_identity). Returns the number of rows
removed.
LiteLLM_MCPUserCredentials also stores BYOK API keys in the same column; only rows whose payload
decodes as an OAuth2 credential (see _decode_oauth_payload) are deleted, because a config change
only invalidates minted tokens, never a user's own stored key. Rows are therefore deleted per
(user_id, server_id) pair rather than by a blanket server_id filter. An OAuth row inserted while
the purge runs for a user not yet enumerated survives; a re-auth completing in the window for an
already-enumerated user is deleted along with the stale row (the pair delete cannot tell them
apart), which costs that user one extra re-auth and nothing else.
invalidate_token_cache is injectable for tests; it defaults to the manager's shared
invalidate_user_oauth_token_cache, the single invalidation point for per-user tokens."""
repo = MCPUserCredentialsRepository(prisma_client)
rows = await repo.table.find_many(where={"server_id": server_id})
oauth_rows = [row for row in rows if _decode_oauth_payload(row.credential_b64) is not None]
if not oauth_rows:
return 0
deleted_count = await repo.table.delete_many(
where={"server_id": server_id, "user_id": {"in": [row.user_id for row in oauth_rows]}}
)
if invalidate_token_cache is None:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
invalidate_token_cache = global_mcp_server_manager.invalidate_user_oauth_token_cache
for row in oauth_rows:
await invalidate_token_cache(row.user_id, server_id)
if deleted_count != len(oauth_rows):
verbose_proxy_logger.warning(
"MCP server %s: purge removed %d OAuth credential row(s) but %d were enumerated; "
"row(s) were deleted concurrently during the purge",
server_id,
deleted_count,
len(oauth_rows),
)
return deleted_count
async def refresh_user_oauth_token(
prisma_client: PrismaClient,
user_id: str,

View file

@ -471,7 +471,8 @@ def _raise_if_not_oauth2(mcp_server: MCPServer) -> None:
through: the caller owns the upstream token, and this relayed flow is how a browser obtains
one against the upstream IdP (the admin UI's browser-only Authorize uses it). The minted
token is upstream-audienced and held by the caller; the gateway persists nothing for these
modes (DCR persistence is opt-in and never enabled on this path).
modes (``_persist_dcr_client_registration`` skips them unconditionally, so even the admin
Authorize path with ``persist_credentials`` enabled writes nothing to the server row).
"""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # circular import with mcp_server_manager at module load
_UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES,
@ -807,7 +808,7 @@ async def _reuse_persisted_dcr_client_if_available(mcp_server: MCPServer) -> boo
return bool(mcp_server.client_id)
DcrRegistrationPersistenceResult = Literal["persisted", "reused", "failed"]
DcrRegistrationPersistenceResult = Literal["persisted", "reused", "skipped", "failed"]
async def _persist_dcr_client_registration(
@ -821,7 +822,16 @@ async def _persist_dcr_client_registration(
full re-authorization instead of a silent refresh. Mirrors the ``encrypt_credentials``
write that ``client_credentials`` and token exchange already use. Failures are logged,
never raised: registration still returns to the caller even when persistence fails.
The client-forwarded token modes (``true_passthrough`` / ``oauth_delegate``) are skipped
unconditionally: the caller holds the upstream token and the gateway must hold no OAuth
client identity for these servers. Persisting here would stamp ``oauth2_flow`` and a
``client_id`` onto a server whose mode promises the gateway stores nothing, making a
fresh pass-through server read as gateway-authorized.
"""
if mcp_server.is_true_passthrough or mcp_server.is_oauth_delegate:
return "skipped"
try:
registration = _DcrClientRegistration.model_validate(registration_response)
except ValidationError as exc:

View file

@ -57,7 +57,11 @@ from litellm.proxy._experimental.mcp_server.elicitation_handler import (
from litellm.proxy._experimental.mcp_server.sampling_handler import (
MCP_SAMPLING_AVAILABLE,
)
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import resolve_mcp_auth
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import (
MCPPerUserTokenCache,
mcp_per_user_token_cache,
resolve_mcp_auth,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials import (
Error,
Ok,
@ -799,10 +803,12 @@ class MCPServerManager:
self,
cred_provider: Optional[UpstreamCredentialProvider] = None,
per_user_oauth_token_store: Optional[InvalidatableOAuthTokenStore] = None,
per_user_token_cache: Optional[MCPPerUserTokenCache] = None,
):
self._per_user_oauth_token_store = per_user_oauth_token_store or LazyPerUserOAuthTokenStore(
self.get_mcp_server_by_id
)
self._per_user_token_cache = per_user_token_cache or mcp_per_user_token_cache
self._cred_provider = cred_provider or UpstreamCredentialProvider(
oauth_token_store=self._per_user_oauth_token_store,
token_exchanger=build_token_exchanger(),
@ -1038,6 +1044,24 @@ class MCPServerManager:
"browser sign-in, including delegate_auth_to_upstream)."
)
config_dcr_bridge = server_config.get("dcr_bridge", None)
if config_dcr_bridge is not None and not isinstance(config_dcr_bridge, bool):
raise ValueError(
f"Invalid config for MCP server '{server_name or server_id}': dcr_bridge "
f"must be a boolean (got {config_dcr_bridge!r})."
)
if config_dcr_bridge and auth_type not in (
MCPAuth.true_passthrough,
MCPAuth.oauth_delegate,
):
raise ValueError(
f"Invalid config for MCP server '{server_name or server_id}': dcr_bridge is only "
f"supported for auth_type true_passthrough or oauth_delegate (got {auth_type!r}). "
"The DCR bridge serves gateway-hosted OAuth discovery for the client-forwarded "
"token modes; interactive oauth2 servers already run the gateway "
"authorization-code flow."
)
new_server = MCPServer(
server_id=server_id,
name=name_for_prefix,
@ -1073,6 +1097,7 @@ class MCPServerManager:
available_on_public_internet=bool(server_config.get("available_on_public_internet", True)),
delegate_auth_to_upstream=bool(server_config.get("delegate_auth_to_upstream", False)),
oauth_passthrough=bool(server_config.get("oauth_passthrough", False)),
dcr_bridge=config_dcr_bridge,
# AWS SigV4 fields
aws_access_key_id=server_config.get("aws_access_key_id", None),
aws_secret_access_key=server_config.get("aws_secret_access_key", None),
@ -1448,6 +1473,7 @@ class MCPServerManager:
available_on_public_internet=bool(getattr(mcp_server, "available_on_public_internet", True)),
delegate_auth_to_upstream=bool(getattr(mcp_server, "delegate_auth_to_upstream", False)),
oauth_passthrough=bool(getattr(mcp_server, "oauth_passthrough", False)),
dcr_bridge=getattr(mcp_server, "dcr_bridge", None),
created_at=getattr(mcp_server, "created_at", None),
updated_at=getattr(mcp_server, "updated_at", None),
tool_name_to_display_name=_deserialize_json_dict(getattr(mcp_server, "tool_name_to_display_name", None)),
@ -4053,10 +4079,13 @@ class MCPServerManager:
return await self._cred_provider.has_user_token(to_subject(user_api_key_auth, None), spec)
async def invalidate_user_oauth_token_cache(self, user_id: str, server_id: str) -> None:
"""Drop the v2 chain's cached token for ``(user_id, server_id)`` after the credential row
changes (re-auth, revoke), so the next resolve reads the new row instead of serving the
replaced token until its cache TTL. Best-effort: a cache-drop failure is logged, never
raised, because the DB write already succeeded and the TTL remains the backstop.
"""Drop every cached token for ``(user_id, server_id)`` after the credential row changes
(re-auth, revoke, config-change purge): the v2 chain's cache and the legacy per-user token
cache, so the next resolve reads the new row instead of serving the replaced token until its
cache TTL, whichever path resolves it. This is the single invalidation point for per-user
OAuth tokens; callers must not evict individual caches directly. Best-effort: a cache-drop
failure is logged, never raised, because the DB write already succeeded and the TTL remains
the backstop.
"""
try:
await self._per_user_oauth_token_store.invalidate(user_id, server_id)
@ -4064,6 +4093,12 @@ class MCPServerManager:
verbose_logger.warning(
"Failed to invalidate cached MCP OAuth token for user=%s server=%s: %s", user_id, server_id, exc
)
try:
await self._per_user_token_cache.delete(user_id, server_id)
except Exception as exc: # noqa: BLE001 - cache drop is best-effort; TTL is the backstop
verbose_logger.warning(
"Failed to drop legacy cached MCP OAuth token for user=%s server=%s: %s", user_id, server_id, exc
)
async def _resolve_oauth2_headers_for_tool_call(
self,
@ -4785,6 +4820,7 @@ class MCPServerManager:
token_url=server.token_url,
registration_url=server.registration_url,
oauth2_flow=server.oauth2_flow,
dcr_bridge=server.dcr_bridge,
token_exchange_endpoint=server.token_exchange_endpoint,
audience=server.audience,
subject_token_type=server.subject_token_type,
@ -4901,6 +4937,7 @@ class MCPServerManager:
available_on_public_internet=server.available_on_public_internet,
delegate_auth_to_upstream=server.delegate_auth_to_upstream,
oauth_passthrough=getattr(server, "oauth_passthrough", False),
dcr_bridge=server.dcr_bridge,
is_byok=server.is_byok,
byok_description=server.byok_description,
byok_api_key_help_url=server.byok_api_key_help_url,

View file

@ -26,6 +26,7 @@ from litellm.types.llms.openai import (
ResponsesAPIResponse,
)
from litellm.types.mcp import (
MCPAuth,
MCPAuthType,
MCPCredentials,
MCPTransport,
@ -1229,6 +1230,14 @@ from litellm.models.mcp_server import ( # noqa: E402
# MCP Proxy Request Types
def _dcr_bridge_auth_type_error(auth_type: object) -> ValueError:
return ValueError(
f"dcr_bridge is only supported for auth_type true_passthrough or oauth_delegate (got {auth_type!r}). "
"The DCR bridge serves gateway-hosted OAuth discovery for the client-forwarded token modes; "
"interactive oauth2 servers already run the gateway authorization-code flow."
)
class NewMCPServerRequest(LiteLLMPydanticObjectBase):
server_id: Optional[str] = None
server_name: Optional[str] = None
@ -1268,6 +1277,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
available_on_public_internet: bool = True
delegate_auth_to_upstream: bool = False
oauth_passthrough: bool = False
dcr_bridge: Optional[bool] = None
is_byok: bool = False
byok_description: List[str] = Field(default_factory=list)
byok_api_key_help_url: Optional[str] = None
@ -1322,6 +1332,16 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
"""
return values
@model_validator(mode="before")
@classmethod
def validate_dcr_bridge_auth_type(cls, values):
if not isinstance(values, dict) or not values.get("dcr_bridge"):
return values
auth_type = values.get("auth_type")
if auth_type in (MCPAuth.true_passthrough, MCPAuth.oauth_delegate):
return values
raise _dcr_bridge_auth_type_error(auth_type)
class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
server_id: str
@ -1362,6 +1382,7 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
available_on_public_internet: bool = True
delegate_auth_to_upstream: bool = False
oauth_passthrough: bool = False
dcr_bridge: Optional[bool] = None
is_byok: bool = False
byok_description: List[str] = Field(default_factory=list)
byok_api_key_help_url: Optional[str] = None
@ -1391,6 +1412,21 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
raise ValueError("url or spec_path is required for HTTP/SSE transport")
return values
@model_validator(mode="before")
@classmethod
def validate_dcr_bridge_auth_type(cls, values):
"""Partial updates omit auth_type; that case is validated against the stored row by the
update endpoint, which can read the database. This validator covers payloads that carry
both fields."""
if not isinstance(values, dict) or not values.get("dcr_bridge"):
return values
if "auth_type" not in values:
return values
auth_type = values.get("auth_type")
if auth_type in (MCPAuth.true_passthrough, MCPAuth.oauth_delegate):
return values
raise _dcr_bridge_auth_type_error(auth_type)
from litellm.models.mcp_server import ( # noqa: E402
LiteLLM_MCPServerTable as LiteLLM_MCPServerTable,

View file

@ -98,7 +98,10 @@ from litellm.repositories.user_repository import UserRepository
from litellm.router import Router
from litellm.utils import get_utc_datetime
from .auth_checks_organization import organization_role_based_access_check
from .auth_checks_organization import (
add_team_org_context_to_request_body,
organization_role_based_access_check,
)
from .auth_utils import get_model_from_request
if TYPE_CHECKING:
@ -707,10 +710,28 @@ async def common_checks(
# 10 [OPTIONAL] Organization RBAC checks
organization_role_based_access_check(user_object=user_object, route=route, request_body=request_body)
async def _fetch_team_org_id(team_id: str) -> Optional[str]:
try:
team = await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
except HTTPException:
return None
return team.organization_id
request_body_for_route_check = await add_team_org_context_to_request_body(
route=route,
request_body=request_body,
fetch_team_org_id=_fetch_team_org_id,
)
_is_route_allowed = _is_api_route_allowed(
route=route,
request=request,
request_data=request_body,
request_data=request_body_for_route_check,
valid_token=valid_token,
user_obj=user_object,
)

View file

@ -2,7 +2,7 @@
Auth Checks for Organizations
"""
from typing import Dict, List, Optional, Tuple
from typing import Awaitable, Callable, Dict, List, Optional, Tuple
from fastapi import status
@ -170,3 +170,33 @@ def _user_is_org_admin(
# User must be admin of ALL requested orgs, not just any one
return all(org_id in admin_org_ids for org_id in candidate_org_ids)
TEAM_ORG_CONTEXT_ROUTES = frozenset({"/team/update"})
async def add_team_org_context_to_request_body(
route: str,
request_body: dict,
fetch_team_org_id: Callable[[str], Awaitable[Optional[str]]],
) -> dict:
"""
Return a copy of request_body with organization_id resolved from the target
team when the route identifies the team by team_id and the caller did not
pass organization_id. This lets an org admin of the team's own org reach the
org-scoped branch of the route gate (which keys off organization_id) without
the client having to send it. Returns request_body unchanged when it does
not apply, so callers that already pass organization_id and non-team routes
are untouched.
"""
if route not in TEAM_ORG_CONTEXT_ROUTES:
return request_body
if request_body.get("organization_id"):
return request_body
team_id = request_body.get("team_id")
if not isinstance(team_id, str) or not team_id:
return request_body
org_id = await fetch_team_org_id(team_id)
if not org_id:
return request_body
return {**request_body, "organization_id": org_id}

View file

@ -125,7 +125,9 @@ if MCP_AVAILABLE:
get_user_env_vars_bulk,
get_user_oauth_credential,
list_user_oauth_credentials,
mcp_oauth_token_identity,
merge_user_env_vars,
purge_user_oauth_credentials_for_server,
reject_mcp_server,
store_user_credential,
store_user_oauth_credential,
@ -2318,6 +2320,41 @@ if MCP_AVAILABLE:
},
)
# Snapshot the pre-update identity so we can detect a mint-relevant change below. The read is
# advisory (it only feeds the stale-token purge decision), so a failure skips the purge with a
# warning instead of failing the edit, whose primary job is the update itself.
try:
old_server_record = await get_mcp_server(prisma_client, payload.server_id)
old_server_record_read_failed = False
except Exception as exc: # noqa: BLE001 - advisory read; invalidation is best-effort end-to-end
verbose_logger.warning(
"MCP server %s: could not snapshot the pre-update record; skipping the stale-token check: %s",
payload.server_id,
exc,
)
old_server_record = None
old_server_record_read_failed = True
if (
payload.dcr_bridge
and payload.auth_type is None
and (old_server_record is not None or old_server_record_read_failed)
):
stored_auth_type = old_server_record.auth_type if old_server_record else None
stored_auth_type_name = getattr(stored_auth_type, "value", stored_auth_type)
if stored_auth_type not in (MCPAuth.true_passthrough, MCPAuth.oauth_delegate):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": (
"dcr_bridge is only supported for auth_type true_passthrough or "
f"oauth_delegate (stored auth_type: {stored_auth_type_name!r}). Include "
"the server's auth_type in the update payload or configure one of the "
"client-forwarded token modes first."
)
},
)
# try to update the mcp server
mcp_server_record_updated = await update_mcp_server(
prisma_client,
@ -2336,6 +2373,30 @@ if MCP_AVAILABLE:
# Ensure registry is up to date by reloading from database
await global_mcp_server_manager.reload_servers_from_database()
# If a field that determines which upstream OAuth token gets minted changed (url/audience, OAuth
# mode/grant, authorization-server endpoints, or the OAuth client + scopes), every stored per-user
# token was minted for the old configuration and is stale. Purge them (DB + cache) so the next
# tool call re-authorizes instead of forwarding a token for a resource/AS/client that no longer
# matches. Best-effort: a purge failure must not fail the update, whose primary job already
# succeeded.
if old_server_record is not None and mcp_oauth_token_identity(old_server_record) != mcp_oauth_token_identity(
mcp_server_record_updated
):
try:
purged = await purge_user_oauth_credentials_for_server(prisma_client, payload.server_id)
if purged:
verbose_logger.info(
"MCP server %s: purged %d stale per-user OAuth token(s) after a mint-relevant config change",
payload.server_id,
purged,
)
except Exception as exc: # noqa: BLE001 - purge is best-effort; the server update already succeeded
verbose_logger.warning(
"MCP server %s: failed to purge stale per-user OAuth tokens after config change: %s",
payload.server_id,
exc,
)
# TODO: Enterprise: Finish audit log trail
if litellm.store_audit_logs:
pass

View file

@ -339,6 +339,7 @@ model LiteLLM_MCPServerTable {
available_on_public_internet Boolean @default(true)
delegate_auth_to_upstream Boolean @default(false)
oauth_passthrough Boolean @default(false)
dcr_bridge Boolean?
is_byok Boolean @default(false)
byok_description String[] @default([])
byok_api_key_help_url String?

View file

@ -3392,7 +3392,7 @@ async def _build_ui_spend_logs_response(
)
count_map = {r["session_id"]: r["_count"]["session_id"] for r in counts if r.get("session_id")}
mcp_spend_map: dict[str, dict[str, Union[int, float]]] = {}
session_spend_map: dict[str, dict[str, Union[int, float]]] = {}
if enrich_session_counts and session_ids:
from prisma.errors import PrismaError
@ -3410,19 +3410,24 @@ async def _build_ui_spend_logs_response(
rows = await prisma_client.db.query_raw(
"""
SELECT session_id,
COUNT(*)::int AS mcp_tool_call_count,
COALESCE(SUM(spend), 0)::double precision AS mcp_tool_call_spend
COALESCE(SUM(spend), 0)::double precision AS session_total_spend,
COUNT(*) FILTER (
WHERE call_type IN ('call_mcp_tool', 'list_mcp_tools')
)::int AS mcp_tool_call_count,
COALESCE(SUM(spend) FILTER (
WHERE call_type IN ('call_mcp_tool', 'list_mcp_tools')
), 0)::double precision AS mcp_tool_call_spend
FROM "LiteLLM_SpendLogs"
WHERE session_id = ANY($1::text[])
AND api_key = ANY($2::text[])
AND call_type IN ('call_mcp_tool', 'list_mcp_tools')
GROUP BY session_id
""",
session_ids,
authorized_api_keys,
)
mcp_spend_map = {
session_spend_map = {
row["session_id"]: {
"session_total_spend": float(row.get("session_total_spend") or 0.0),
"mcp_tool_call_count": int(row.get("mcp_tool_call_count") or 0),
"mcp_tool_call_spend": float(row.get("mcp_tool_call_spend") or 0.0),
}
@ -3431,7 +3436,7 @@ async def _build_ui_spend_logs_response(
}
except PrismaError:
verbose_proxy_logger.debug(
"Failed to enrich MCP session spend aggregates for spend logs UI",
"Failed to enrich session spend aggregates for spend logs UI",
exc_info=True,
)
@ -3441,10 +3446,12 @@ async def _build_ui_spend_logs_response(
row_dict = dict(row) if isinstance(row, dict) else row.model_dump()
sid = row_dict.get("session_id")
row_dict["session_total_count"] = count_map.get(sid, 1) if sid else 1
mcp_stats = mcp_spend_map.get(sid) if sid else None
if mcp_stats:
row_dict["mcp_tool_call_count"] = mcp_stats["mcp_tool_call_count"]
row_dict["mcp_tool_call_spend"] = mcp_stats["mcp_tool_call_spend"]
session_stats = session_spend_map.get(sid) if sid else None
if session_stats:
row_dict["session_total_spend"] = session_stats["session_total_spend"]
if session_stats["mcp_tool_call_count"]:
row_dict["mcp_tool_call_count"] = session_stats["mcp_tool_call_count"]
row_dict["mcp_tool_call_spend"] = session_stats["mcp_tool_call_spend"]
enriched.append(row_dict)
response_data: list = enriched
else:

View file

@ -136,7 +136,7 @@ def _get_spend_logs_metadata(
clean_metadata["vector_store_request_metadata"] = _get_vector_store_request_for_spend_logs_payload(
vector_store_request_metadata
)
clean_metadata["guardrail_information"] = guardrail_information
clean_metadata["guardrail_information"] = _sanitize_guardrail_information_for_spend_logs(guardrail_information)
clean_metadata["usage_object"] = usage_object
clean_metadata["model_map_information"] = model_map_information
clean_metadata["cold_storage_object_key"] = cold_storage_object_key
@ -868,6 +868,51 @@ def _redact_prompt_leaks_in_error_string(text: str) -> str:
return "".join(out)
def _sanitize_guardrail_information_for_spend_logs(
guardrail_information: Optional[List[StandardLoggingGuardrailInformation]],
) -> Optional[List[StandardLoggingGuardrailInformation]]:
"""
When ``store_prompts_in_spend_logs`` is False, redact prompt-carrying fields
(``guardrail_request``, ``guardrail_response``, ``match_details``,
``classification``) before they land in ``LiteLLM_SpendLogs.metadata``.
Guardrail hooks may echo the LLM request payload back into
``guardrail_response``, and two first-party hooks
(``block_code_execution``, ``litellm_content_filter``) inline user-prompt
substrings into ``match_details`` / ``classification`` too, so the flag
must cover all four fields. Every other typed field on the entry (name,
provider, mode, status, timings, action, violation_categories, risk_score,
masked_entity_count, ...) is preserved so guardrail dashboards keep
working.
``guardrail_information`` is typed ``Optional[List[...]]`` but at least
one writer (``xecguard``) assigns a bare dict, so normalize to a list
here to match OTEL's defensive read pattern; otherwise iteration would
yield the dict's keys and crash the whole spend-log write.
"""
if guardrail_information is None or _should_store_prompts_and_responses_in_spend_logs():
return guardrail_information
entries = [guardrail_information] if isinstance(guardrail_information, dict) else guardrail_information
return [_redact_prompt_fields_in_guardrail_entry(entry) for entry in entries if isinstance(entry, dict)]
_PROMPT_CARRYING_GUARDRAIL_FIELDS = (
"guardrail_request",
"guardrail_response",
"match_details",
"classification",
)
def _redact_prompt_fields_in_guardrail_entry(
entry: StandardLoggingGuardrailInformation,
) -> StandardLoggingGuardrailInformation:
return {
**entry,
**{key: REDACTED_BY_LITELM_STRING for key in _PROMPT_CARRYING_GUARDRAIL_FIELDS if key in entry},
}
def _sanitize_error_information_for_spend_logs(
error_information: Optional[StandardLoggingPayloadErrorInformation],
) -> Optional[StandardLoggingPayloadErrorInformation]:

View file

@ -103,6 +103,7 @@ class MCPServer(BaseModel):
# ``Authorization`` for non-OAuth reasons (e.g. static bearer tokens). Must
# be set explicitly to avoid regressing servers that did not opt in.
oauth_passthrough: bool = False
dcr_bridge: Optional[bool] = None
is_byok: bool = False
byok_description: List[str] = []
byok_api_key_help_url: Optional[str] = None
@ -164,6 +165,15 @@ class MCPServer(BaseModel):
JWT) but forwards the caller's separate upstream ``Authorization`` unchanged, minting nothing."""
return self.auth_type == MCPAuth.oauth_delegate
@property
def is_dcr_bridge(self) -> bool:
"""True when this client-forwarded-token server serves the gateway-hosted DCR front door
(gateway-self protected-resource and authorization-server metadata plus the register,
authorize, and token relays) instead of relaying the upstream's own OAuth discovery
verbatim. ``dcr_bridge`` is rejected on every other auth type at create, update, and
config load, so the mode gate here only defends rows edited outside those paths."""
return bool(self.dcr_bridge) and (self.is_true_passthrough or self.is_oauth_delegate)
@property
def requires_per_user_auth(self) -> bool:
"""

View file

@ -2533,9 +2533,10 @@ class StandardLoggingMCPToolCall(TypedDict, total=False):
mcp_server_resource: Optional[str]
"""
The upstream MCP server resource identifier (scheme + host + path) the tool call was
forwarded to. Redacted for logging: userinfo, query string, and fragment are stripped so an
upstream URL carrying an embedded token or secret query parameter never reaches log metadata.
The origin (scheme + host + port) of the upstream MCP server the tool call was forwarded
to. Redacted for logging: userinfo, the path, the query string, and the fragment are all
stripped, because hosted MCP servers routinely embed the credential in the URL path and
this value is readable by callers via request logs.
Records which upstream received a relayed request; never a credential.
"""
@ -2697,7 +2698,7 @@ class StandardLoggingGuardrailInformation(TypedDict, total=False):
guardrail_name: Optional[str]
guardrail_provider: Optional[str]
guardrail_mode: Optional[Union[GuardrailEventHooks, List[GuardrailEventHooks], GuardrailMode]]
guardrail_request: Optional[dict]
guardrail_request: Optional[Union[str, dict]]
guardrail_response: Optional[Union[dict, str, List[dict]]]
guardrail_status: GuardrailStatus
start_time: Optional[float]
@ -2728,10 +2729,10 @@ class StandardLoggingGuardrailInformation(TypedDict, total=False):
confidence_score: Optional[float]
"""For LLM-judge guardrails: confidence score 0.0-1.0"""
classification: Optional[dict]
classification: Optional[Union[str, dict]]
"""For LLM-judge guardrails: structured classification output"""
match_details: Optional[List[dict]]
match_details: Optional[Union[str, List[dict]]]
"""Detailed match information for each detected pattern"""
patterns_checked: Optional[int]
@ -3397,6 +3398,7 @@ class LlmProviders(str, Enum):
LIBERTAI = "libertai"
PINSTRIPES = "pinstripes"
DARKBLOOM = "darkbloom"
META = "meta"
LITELLM_AGENT = "litellm_agent"
CURSOR = "cursor"
BEDROCK_MANTLE = "bedrock_mantle"

View file

@ -8028,6 +8028,16 @@ class ProviderConfigManager:
)
return GithubCopilotAnthropicMessagesConfig()
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
json_provider = JSONProviderRegistry.get(provider.value)
if json_provider is not None and "/v1/messages" in json_provider.supported_endpoints:
from litellm.llms.openai_like.messages.transformation import (
JSONProviderAnthropicMessagesConfig,
)
return JSONProviderAnthropicMessagesConfig(json_provider)
return None
@staticmethod

View file

@ -6012,6 +6012,522 @@
"supports_vision": true,
"supports_web_search": true
},
"azure/gpt-5.6": {
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
"cache_read_input_token_cost_priority": 1e-06,
"cache_read_input_token_cost_above_272k_tokens_priority": 2e-06,
"input_cost_per_token": 5e-06,
"input_cost_per_token_above_272k_tokens": 1e-05,
"input_cost_per_token_priority": 1e-05,
"input_cost_per_token_above_272k_tokens_priority": 2e-05,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 3e-05,
"output_cost_per_token_above_272k_tokens": 4.5e-05,
"output_cost_per_token_priority": 6e-05,
"output_cost_per_token_above_272k_tokens_priority": 9e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/gpt-5.6-sol": {
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
"cache_read_input_token_cost_priority": 1e-06,
"cache_read_input_token_cost_above_272k_tokens_priority": 2e-06,
"input_cost_per_token": 5e-06,
"input_cost_per_token_above_272k_tokens": 1e-05,
"input_cost_per_token_priority": 1e-05,
"input_cost_per_token_above_272k_tokens_priority": 2e-05,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 3e-05,
"output_cost_per_token_above_272k_tokens": 4.5e-05,
"output_cost_per_token_priority": 6e-05,
"output_cost_per_token_above_272k_tokens_priority": 9e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/gpt-5.6-terra": {
"cache_read_input_token_cost": 2.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 5e-07,
"cache_read_input_token_cost_priority": 5e-07,
"cache_read_input_token_cost_above_272k_tokens_priority": 1e-06,
"input_cost_per_token": 2.5e-06,
"input_cost_per_token_above_272k_tokens": 5e-06,
"input_cost_per_token_priority": 5e-06,
"input_cost_per_token_above_272k_tokens_priority": 1e-05,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1.5e-05,
"output_cost_per_token_above_272k_tokens": 2.25e-05,
"output_cost_per_token_priority": 3e-05,
"output_cost_per_token_above_272k_tokens_priority": 4.5e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/gpt-5.6-luna": {
"cache_read_input_token_cost": 1e-07,
"cache_read_input_token_cost_above_272k_tokens": 2e-07,
"cache_read_input_token_cost_priority": 2e-07,
"cache_read_input_token_cost_above_272k_tokens_priority": 4e-07,
"input_cost_per_token": 1e-06,
"input_cost_per_token_above_272k_tokens": 2e-06,
"input_cost_per_token_priority": 2e-06,
"input_cost_per_token_above_272k_tokens_priority": 4e-06,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 6e-06,
"output_cost_per_token_above_272k_tokens": 9e-06,
"output_cost_per_token_priority": 1.2e-05,
"output_cost_per_token_above_272k_tokens_priority": 1.8e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/us/gpt-5.6": {
"cache_read_input_token_cost": 5.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1.1e-06,
"cache_read_input_token_cost_priority": 1.375e-06,
"input_cost_per_token": 5.5e-06,
"input_cost_per_token_above_272k_tokens": 1.1e-05,
"input_cost_per_token_priority": 1.375e-05,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 3.3e-05,
"output_cost_per_token_above_272k_tokens": 4.95e-05,
"output_cost_per_token_priority": 8.25e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/us/gpt-5.6-sol": {
"cache_read_input_token_cost": 5.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1.1e-06,
"cache_read_input_token_cost_priority": 1.375e-06,
"input_cost_per_token": 5.5e-06,
"input_cost_per_token_above_272k_tokens": 1.1e-05,
"input_cost_per_token_priority": 1.375e-05,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 3.3e-05,
"output_cost_per_token_above_272k_tokens": 4.95e-05,
"output_cost_per_token_priority": 8.25e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/us/gpt-5.6-terra": {
"cache_read_input_token_cost": 2.75e-07,
"cache_read_input_token_cost_above_272k_tokens": 5.5e-07,
"cache_read_input_token_cost_priority": 6.875e-07,
"input_cost_per_token": 2.75e-06,
"input_cost_per_token_above_272k_tokens": 5.5e-06,
"input_cost_per_token_priority": 6.875e-06,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1.65e-05,
"output_cost_per_token_above_272k_tokens": 2.475e-05,
"output_cost_per_token_priority": 4.125e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/us/gpt-5.6-luna": {
"cache_read_input_token_cost": 1.1e-07,
"cache_read_input_token_cost_above_272k_tokens": 2.2e-07,
"cache_read_input_token_cost_priority": 2.75e-07,
"input_cost_per_token": 1.1e-06,
"input_cost_per_token_above_272k_tokens": 2.2e-06,
"input_cost_per_token_priority": 2.75e-06,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 6.6e-06,
"output_cost_per_token_above_272k_tokens": 9.9e-06,
"output_cost_per_token_priority": 1.65e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/eu/gpt-5.6": {
"cache_read_input_token_cost": 5.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1.1e-06,
"cache_read_input_token_cost_priority": 1.375e-06,
"input_cost_per_token": 5.5e-06,
"input_cost_per_token_above_272k_tokens": 1.1e-05,
"input_cost_per_token_priority": 1.375e-05,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 3.3e-05,
"output_cost_per_token_above_272k_tokens": 4.95e-05,
"output_cost_per_token_priority": 8.25e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/eu/gpt-5.6-sol": {
"cache_read_input_token_cost": 5.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1.1e-06,
"cache_read_input_token_cost_priority": 1.375e-06,
"input_cost_per_token": 5.5e-06,
"input_cost_per_token_above_272k_tokens": 1.1e-05,
"input_cost_per_token_priority": 1.375e-05,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 3.3e-05,
"output_cost_per_token_above_272k_tokens": 4.95e-05,
"output_cost_per_token_priority": 8.25e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/eu/gpt-5.6-terra": {
"cache_read_input_token_cost": 2.75e-07,
"cache_read_input_token_cost_above_272k_tokens": 5.5e-07,
"cache_read_input_token_cost_priority": 6.875e-07,
"input_cost_per_token": 2.75e-06,
"input_cost_per_token_above_272k_tokens": 5.5e-06,
"input_cost_per_token_priority": 6.875e-06,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1.65e-05,
"output_cost_per_token_above_272k_tokens": 2.475e-05,
"output_cost_per_token_priority": 4.125e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/eu/gpt-5.6-luna": {
"cache_read_input_token_cost": 1.1e-07,
"cache_read_input_token_cost_above_272k_tokens": 2.2e-07,
"cache_read_input_token_cost_priority": 2.75e-07,
"input_cost_per_token": 1.1e-06,
"input_cost_per_token_above_272k_tokens": 2.2e-06,
"input_cost_per_token_priority": 2.75e-06,
"litellm_provider": "azure",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 6.6e-06,
"output_cost_per_token_above_272k_tokens": 9.9e-06,
"output_cost_per_token_priority": 1.65e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"azure/gpt-5.5": {
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
@ -25659,6 +26175,42 @@
"supports_function_calling": true,
"supports_tool_choice": false
},
"meta/muse-spark-1.1": {
"cache_read_input_token_cost": 1.5e-07,
"input_cost_per_token": 1.25e-06,
"litellm_provider": "meta",
"max_input_tokens": 1048576,
"max_output_tokens": 131072,
"max_tokens": 131072,
"mode": "chat",
"output_cost_per_token": 4.25e-06,
"source": "https://dev.meta.ai/docs/getting-started/pricing-rate-limits",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/responses",
"/v1/messages"
],
"supported_modalities": [
"text",
"image",
"video"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_minimal_reasoning_effort": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_xhigh_reasoning_effort": true
},
"meta_llama/Llama-3.3-70B-Instruct": {
"litellm_provider": "meta_llama",
"max_input_tokens": 128000,

View file

@ -1984,6 +1984,23 @@
"interactions": true
}
},
"meta": {
"display_name": "Meta Model API (`meta`)",
"url": "https://docs.litellm.ai/docs/providers/meta",
"endpoints": {
"chat_completions": true,
"messages": true,
"responses": true,
"embeddings": false,
"image_generations": false,
"audio_transcriptions": false,
"audio_speech": false,
"moderations": false,
"batches": false,
"rerank": false,
"a2a": false
}
},
"pinstripes": {
"display_name": "Pinstripes (`pinstripes`)",
"url": "https://docs.litellm.ai/docs/providers/pinstripes",

View file

@ -339,6 +339,7 @@ model LiteLLM_MCPServerTable {
available_on_public_internet Boolean @default(true)
delegate_auth_to_upstream Boolean @default(false)
oauth_passthrough Boolean @default(false)
dcr_bridge Boolean?
is_byok Boolean @default(false)
byok_description String[] @default([])
byok_api_key_help_url String?

View file

@ -36,6 +36,7 @@ IGNORE_FUNCTIONS = [
"_collect_argument_paths", # max depth set.
"_split_text", # max depth set.
"_mask_sequence", # max depth set.
"_walk_payload", # max depth set (DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER).
"_delete_nested_value_custom", # max depth set (bounded by number of path segments).
"filter_exceptions_from_params", # max depth set (default 20) to prevent infinite recursion.
"__getattr__", # lazy loading pattern in litellm/__init__.py with proper caching to prevent infinite recursion.

View file

@ -16,8 +16,8 @@ failures are hard test failures (see `tests/e2e/CLAUDE.md`).
|-----------|--------|----------|--------|------|--------------|
| OpenAI | yes | yes | yes | yes | OpenAI Files |
| Azure | yes | yes | yes | yes | Azure Files |
| Vertex AI | yes | yes | yes | yes | GCS bucket (`GCS_BUCKET_NAME` via files_settings) |
| Bedrock | yes | yes | no (limited upstream) | no | S3 bucket (`AWS_BATCH_S3_BUCKET` + `AWS_BATCH_ROLE_ARN` on model) |
| Vertex AI | yes | yes | yes | yes | GCS (`gcs_bucket_name` / `GCS_BUCKET_NAME` on model) |
| Bedrock | yes (unified only) | yes | no (limited upstream) | no | S3 (`s3_bucket_name` + `aws_*` + `AWS_BATCH_ROLE_ARN` on model) |
Bedrock cancel is unreliable upstream and list is unsupported, so both are gated off
(`can_cancel=False`, `can_list=False`) when that provider is enabled in the matrix.

View file

@ -1,20 +1,22 @@
"""The declarative provider x routing-scenario matrix the lifecycle test runs.
One Capability per supported (provider, scenario) pair, so the parametrized test
has no dead/skipped cells. `provider` is litellm's custom_llm_provider, used to
route provider-fallback calls to /{provider}/v1/... and to assert the raw batch id
shape (the only scenario whose id is not re-encoded by the proxy). Operations that
a provider does not support (Bedrock: no cancel, no list) are gated per row.
"""
"""Provider x routing-scenario matrix for the batches lifecycle e2e."""
from __future__ import annotations
import base64
import os
from dataclasses import dataclass
from typing import Literal
from models import LiteLLMParamsBody
def _env_ref(*names: str) -> str:
for name in names:
value = os.environ.get(name)
if value is not None and value.strip() != "":
return f"os.environ/{name}"
return f"os.environ/{names[0]}"
Scenario = Literal["encoded", "unified", "model_param", "provider_fallback"]
IdShape = Literal["managed", "model_encoded", "raw"]
@ -44,10 +46,10 @@ class Provider:
)
case "azure":
return LiteLLMParamsBody(
model="azure/gpt-4.1-mini-batch",
model="azure/gpt-5.4-mini-batch",
api_base="os.environ/AZURE_API_BASE",
api_key="os.environ/AZURE_API_KEY",
api_version="2024-07-01-preview",
api_version="2025-04-01-preview",
)
case "vertex_ai":
return LiteLLMParamsBody(
@ -55,14 +57,19 @@ class Provider:
vertex_project="os.environ/VERTEXAI_PROJECT",
vertex_location="us-central1",
vertex_credentials="os.environ/VERTEXAI_CREDENTIALS",
gcs_bucket_name="os.environ/GCS_BUCKET_NAME",
bucket_name="os.environ/GCS_BUCKET_NAME",
)
case "bedrock":
return LiteLLMParamsBody(
model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
aws_access_key_id="os.environ/AWS_ACCESS_KEY_ID",
aws_secret_access_key="os.environ/AWS_SECRET_ACCESS_KEY",
aws_region_name="os.environ/AWS_REGION",
s3_region_name="os.environ/AWS_REGION",
s3_bucket_name=_env_ref("AWS_BATCH_S3_BUCKET", "AWS_S3_BUCKET_NAME"),
s3_access_key_id="os.environ/AWS_ACCESS_KEY_ID",
s3_secret_access_key="os.environ/AWS_SECRET_ACCESS_KEY",
s3_region_name="os.environ/AWS_REGION",
s3_bucket_name="os.environ/AWS_BATCH_S3_BUCKET",
aws_batch_role_arn="os.environ/AWS_BATCH_ROLE_ARN",
)
case _:
@ -84,20 +91,12 @@ class Capability:
@property
def jsonl_model(self) -> str:
"""Model name embedded in the uploaded JSONL ``body.model``.
Only the unified upload path rewrites JSONL on upload
(``target_model_names`` ``llm_router.acreate_file``
``replace_model_in_jsonl``), so that scenario can use the LiteLLM alias
and rely on the proxy to swap it to the deployment model. Every other
scenario uploads raw JSONL with no rewrite, so the provider's real
deployment name is required or create fails upstream validation."""
return self.model if self.scenario == "unified" else self.raw_model
PROVIDERS: tuple[Provider, ...] = (
Provider("openai", "openai-batch", "gpt-4o-mini", can_cancel=True, can_list=True),
Provider("azure", "azure-batch", "gpt-4.1-mini-batch", can_cancel=True, can_list=True),
Provider("azure", "azure-batch", "gpt-5.4-mini-batch", can_cancel=True, can_list=True),
Provider(
"vertex_ai", "vertex-batch", "gemini-2.5-flash", can_cancel=True, can_list=True
),
@ -110,7 +109,7 @@ PROVIDERS: tuple[Provider, ...] = (
),
)
BEDROCK_SCENARIOS: tuple[Scenario, ...] = ("encoded", "unified")
BEDROCK_SCENARIOS: tuple[Scenario, ...] = ("unified",)
def scenarios_for_provider(provider: Provider) -> tuple[Scenario, ...]:
@ -127,8 +126,6 @@ CAPABILITIES: tuple[Capability, ...] = tuple(
def raw_id_matches_provider(provider: str, batch_id: str) -> bool:
"""The provider-fallback path returns the provider's native batch id (unencoded),
so its shape discriminates which provider actually handled the batch."""
if provider in ("openai", "azure"):
return batch_id.startswith("batch")
if provider == "vertex_ai":
@ -166,12 +163,10 @@ def _b64_decode(value: str) -> str:
def is_managed_id(id_str: str) -> bool:
"""A litellm managed unified file/batch id base64-decodes to a litellm_proxy marker."""
return _b64_decode(id_str).startswith("litellm_proxy")
def is_model_encoded_id(id_str: str) -> bool:
"""A model-encoded id keeps the provider prefix and base64-encodes litellm:<id>;model,<m>."""
for prefix in ("file-", "batch_"):
if id_str.startswith(prefix):
decoded = _b64_decode(id_str[len(prefix) :])

View file

@ -142,10 +142,12 @@ def quietly(action: Callable[[], object]) -> Callable[[], None]:
return run
def assert_file_object(file: FileObject) -> None:
def assert_file_object(file: FileObject, *, provider: str) -> None:
assert file.object == "file", f"file.object={file.object!r}"
assert file.purpose == "batch", f"file.purpose={file.purpose!r}"
assert file.bytes is not None and file.bytes > 0, f"file.bytes={file.bytes!r}"
assert file.bytes is not None, f"file.bytes={file.bytes!r}"
if provider != "bedrock":
assert file.bytes > 0, f"file.bytes={file.bytes!r}"
assert file.status, "file.status missing"
assert (
file.created_at is not None and file.created_at > 0
@ -179,7 +181,7 @@ def test_batch_lifecycle(
resources.defer(
quietly(lambda: client.delete_file(file.id, key=key, provider=provider))
)
assert_file_object(file)
assert_file_object(file, provider=cap.provider)
assert matches_id_shape(
FILE_ID_SHAPE[cap.scenario], file.id
), f"{cap.id}: file id {file.id!r} is not a {FILE_ID_SHAPE[cap.scenario]} id"
@ -234,10 +236,31 @@ def test_batch_lifecycle(
)
if cap.can_list:
listed = unwrap(client.list_batches(key=key, provider=provider))
list_result = client.list_batches(key=key, provider=provider)
managed_filter_unsupported = False
match list_result:
case UnknownApiError(body=body) if (
"Filtering by 'provider' is not supported when using managed batches" in body
):
managed_filter_unsupported = True
listed = unwrap(client.list_batches(key=key, provider=None))
case _:
listed = unwrap(list_result)
if listed.object is not None:
assert listed.object == "list", f"list envelope object={listed.object!r}"
match = next((b for b in listed.data if b.id == batch.id), None)
if (
match is None
and managed_filter_unsupported
and cap.scenario == "provider_fallback"
):
# provider_fallback keeps the provider's raw batch id (not re-encoded
# into a managed/proxy id). When the gateway rejects provider-scoped
# list, the only available list is the unfiltered managed view, which
# does not index raw provider ids. Membership cannot be asserted here;
# create + retrieve (and raw_id_matches_provider above) already pin
# routing for this scenario.
return
assert match is not None, "created batch absent from list"
assert match.object == "batch"
@ -289,7 +312,7 @@ def test_file_upload_and_delete_outputs(
key=key,
)
)
assert_file_object(file)
assert_file_object(file, provider="openai")
deleted = unwrap(client.delete_file(file.id, key=key))
assert deleted.id, "delete response has no id"

View file

@ -10,12 +10,13 @@ and response models are co-located here because only this suite uses them.
from __future__ import annotations
import time
from dataclasses import dataclass
from pydantic import AliasPath, BaseModel, Field, RootModel
from e2e_gateway import Gateway, build_gateway
from e2e_http import NoBody, StreamingResponse, Success, unwrap
from e2e_http import NoBody, Result, StreamingResponse, Success, unwrap
from models import (
AnthropicMessagesBody,
BudgetWindow,
@ -26,6 +27,9 @@ from models import (
ModelBudgetEntry,
)
_TEAM_READY_ATTEMPTS = 15
_TEAM_READY_SLEEP_SECONDS = 0.4
class UserNewBody(BaseModel):
max_budget: float
@ -299,7 +303,7 @@ class BudgetClient:
organization_id: str | None = None,
budget_limits: list[BudgetWindow] | None = None,
) -> str:
return unwrap(
team_id = unwrap(
self.gateway.transport.post(
"/team/new",
headers=self.gateway.transport.master,
@ -312,6 +316,8 @@ class BudgetClient:
response_type=TeamNewResponse,
)
).team_id
self._wait_for_team(team_id)
return team_id
def delete_team(self, team_id: str) -> None:
_ = self.gateway.transport.post(
@ -321,17 +327,43 @@ class BudgetClient:
response_type=NoBody,
)
def _wait_for_team(self, team_id: str) -> None:
last: Result[TeamInfoResponse] | None = None
for _ in range(_TEAM_READY_ATTEMPTS):
last = self.gateway.transport.get(
"/team/info",
headers=self.gateway.transport.master,
params=TeamInfoParams(team_id=team_id),
response_type=TeamInfoResponse,
)
match last:
case Success():
return
case _:
time.sleep(_TEAM_READY_SLEEP_SECONDS)
assert last is not None
_ = unwrap(last)
def add_team_member(self, team_id: str, user_id: str, *, max_budget_in_team: float | None = None) -> None:
resp = self.gateway.transport.send(
"/team/member_add",
headers=self.gateway.transport.master,
json=TeamMemberAddBody(
team_id=team_id,
member=TeamMember(role="user", user_id=user_id),
max_budget_in_team=max_budget_in_team,
),
)
assert resp.ok, resp.body
last_body = ""
for attempt in range(_TEAM_READY_ATTEMPTS):
resp = self.gateway.transport.send(
"/team/member_add",
headers=self.gateway.transport.master,
json=TeamMemberAddBody(
team_id=team_id,
member=TeamMember(role="user", user_id=user_id),
max_budget_in_team=max_budget_in_team,
),
)
if resp.ok:
return
last_body = resp.body
if "doesn't exist" in resp.body and attempt + 1 < _TEAM_READY_ATTEMPTS:
time.sleep(_TEAM_READY_SLEEP_SECONDS)
continue
break
assert False, last_body
def update_team_member(
self,

View file

@ -0,0 +1,228 @@
"""Live e2e: regression guards for #25109 (budget resets stopped working).
The existing test_budget_reset_e2e.py / test_multi_window_budget_e2e.py prove a
blocked key flows again after its window. #25109 stored multi-budget-window data
in nullable JSON columns and filtered eligible rows with a `not: None`-style Prisma
filter that misbehaves on a nullable JSON column, so due rows were either skipped
(budget_reset_at stayed pinned, spend never cleared) or the reset path errored
(a non-budget 5xx leaked to callers). These tests assert the precise invariants
that bug broke, built up START-SLOW from scheduling -> enforcement -> the reset
strictly advancing -> the JSON-backed multi-window / team-member edges -> the
error path. They EXTEND the happy-path modules rather than duplicate them: each
asserts a delta (before<after timestamp, independent windows, block-not-error)
that the happy-path "calls flow again" check alone does not pin down.
"""
import time
from datetime import datetime
import pytest
from budget_client import BudgetClient, is_budget_block
from e2e_config import unique_marker
from e2e_http import require_successful_call
from lifecycle import ResourceManager
from models import BudgetWindow
pytestmark = pytest.mark.e2e
WINDOW_SECONDS = 30
RESET_DEADLINE_SECONDS = 150
TINY_CAP = 3e-6
def _call(client: BudgetClient, key: str):
return client.chat(key, "claude-haiku-4-5", f"advance {unique_marker()}", max_tokens=16)
def _as_datetime(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _drive_to_block(client: BudgetClient, key: str) -> None:
"""Spend until the cap blocks; fails loudly if enforcement never trips."""
for _ in range(20):
result = _call(client, key)
if is_budget_block(result):
return
require_successful_call(result)
time.sleep(2)
pytest.fail("budget never enforced before block")
# ---- Rung 1: scheduling exists at creation -----------------------------------
def test_key_with_budget_duration_schedules_reset_at_creation(
client: BudgetClient, resources: ResourceManager
) -> None:
"""Baseline: a key created with a budget_duration has budget_reset_at populated
immediately. The reset job can only advance a timestamp that was scheduled in
the first place; everything below depends on this."""
key = client.generate_key(max_budget=TINY_CAP, budget_duration=f"{WINDOW_SECONDS}s")
resources.defer(lambda: client.delete_key(key))
info = client.gateway.key_info(key)
assert info.budget_reset_at is not None, "budget_duration set no budget_reset_at"
assert _as_datetime(info.budget_reset_at) > _as_datetime("1970-01-01T00:00:00Z")
# ---- Rung 2: enforcement trips at the cap ------------------------------------
def test_key_spend_blocks_at_cap(client: BudgetClient, resources: ResourceManager) -> None:
"""Sanity that the tiny cap is enforced before we test that it resets: spend
accrues across calls and eventually returns budget_exceeded, never a 5xx."""
key = client.generate_key(max_budget=TINY_CAP, budget_duration=f"{WINDOW_SECONDS}s")
resources.defer(lambda: client.delete_key(key))
# _drive_to_block is the enforcement proof: it fails unless a budget_exceeded
# block follows successful (non-5xx) calls. key_info.spend is deliberately not
# asserted - it is the DB-persisted field that flushes ~60s later
# (proxy_batch_write_at), so reading it right after the block races to 0.0.
_drive_to_block(client, key)
# ---- Rung 3: the core regression - reset_at strictly advances + spend zeroes --
def test_key_budget_reset_at_advances_after_window(
client: BudgetClient, resources: ResourceManager
) -> None:
"""The core #25109 guard: after the window elapses the reset job must move
budget_reset_at strictly forward AND zero key.spend. The broken nullable-JSON
filter left eligible rows untouched, so the timestamp stayed pinned and spend
never cleared. Asserting before<after (not merely "a call succeeded") kills a
mutation that no-ops the reset while leaving enforcement intact."""
key = client.generate_key(max_budget=TINY_CAP, budget_duration=f"{WINDOW_SECONDS}s")
resources.defer(lambda: client.delete_key(key))
before_raw = client.gateway.key_info(key).budget_reset_at
assert before_raw is not None, "no budget_reset_at scheduled at creation"
before = _as_datetime(before_raw)
_drive_to_block(client, key)
deadline = time.monotonic() + RESET_DEADLINE_SECONDS
while time.monotonic() < deadline:
time.sleep(5)
result = _call(client, key)
if not result.ok:
assert is_budget_block(result), f"non-budget error during reset wait: {result.body[:200]}"
continue
info = client.gateway.key_info(key)
assert info.budget_reset_at is not None, "budget_reset_at cleared by reset"
assert _as_datetime(info.budget_reset_at) > before, (
"budget_reset_at did not advance past the pre-reset value"
)
assert (info.spend or 0.0) < TINY_CAP, f"spend not cleared after reset: {info.spend}"
return
pytest.fail(f"key budget never reset within {RESET_DEADLINE_SECONDS}s")
# ---- Rung 4: multi-window - tight window resets, roomy window keeps spend -----
def test_multi_window_key_resets_each_window_independently(
client: BudgetClient, resources: ResourceManager
) -> None:
"""The JSON-backed path #25109 specifically touched. A tight 30s window and a
roomy 1m window: the tight window must reset on its own boundary while the roomy
window keeps its accumulated spend (independent per-window reset). The
nullable-JSON filter bug skipped these JSON-backed rows entirely, so the tight
window never came back; a job that ERRORS on the JSON column would surface here
as a non-budget 5xx, which we reject throughout the wait."""
key = client.generate_key(
budget_limits=[
BudgetWindow(budget_duration=f"{WINDOW_SECONDS}s", max_budget=TINY_CAP),
BudgetWindow(budget_duration="1m", max_budget=1.0),
]
)
resources.defer(lambda: client.delete_key(key))
start = time.monotonic()
_drive_to_block(client, key)
spend_at_block = client.gateway.key_info(key).spend or 0.0
deadline = time.monotonic() + RESET_DEADLINE_SECONDS
while time.monotonic() < deadline:
time.sleep(5)
result = _call(client, key)
if result.ok:
elapsed = time.monotonic() - start
assert elapsed < WINDOW_SECONDS + 90, (
f"tight window reset took {elapsed:.0f}s - too long for {WINDOW_SECONDS}s"
)
assert (client.gateway.key_info(key).spend or 0.0) >= spend_at_block, (
"roomy window spend was wiped when only the tight window should reset"
)
return
assert is_budget_block(result), f"non-budget error during reset wait: {result.body[:200]}"
pytest.fail(f"tight window never reset within {RESET_DEADLINE_SECONDS}s")
# ---- Rung 5: team-member window advances (JSON-backed per-team budget) --------
def test_team_member_budget_reset_at_advances(
client: BudgetClient, resources: ResourceManager
) -> None:
"""Per-team member windows are also JSON-backed. member_budget_reset_at must
advance after the window; the explicit before<after assertion is the #25109
regression guard (the existing reset test only checks "it eventually moved",
this pins it strictly past the value recorded before the window)."""
team_id = client.create_team(alias=f"e2e-member-advance-{unique_marker()}", max_budget=100.0)
resources.defer(lambda: client.delete_team(team_id))
user_id = client.create_user(max_budget=100.0)
resources.defer(lambda: client.delete_user(user_id))
client.add_team_member(team_id, user_id, max_budget_in_team=1.0)
client.update_team_member(
team_id, user_id, max_budget_in_team=1.0, budget_duration=f"{WINDOW_SECONDS}s"
)
before_raw = client.member_budget_reset_at(team_id, user_id)
assert before_raw, "updating the member with a budget_duration set no budget_reset_at"
before = _as_datetime(before_raw)
key = client.generate_key(team_id=team_id, user_id=user_id)
resources.defer(lambda: client.delete_key(key))
require_successful_call(_call(client, key))
deadline = time.monotonic() + RESET_DEADLINE_SECONDS
while time.monotonic() < deadline:
time.sleep(5)
current = client.member_budget_reset_at(team_id, user_id)
if current and _as_datetime(current) > before:
return
pytest.fail(f"member budget_reset_at never advanced past {before.isoformat()} in {RESET_DEADLINE_SECONDS}s")
# ---- Rung 6: error-path edge - resets surface as blocks, never 5xx -----------
def test_reset_wait_never_yields_non_budget_error(
client: BudgetClient, resources: ResourceManager
) -> None:
"""The other #25109 failure mode: a reset job that ERRORS on the nullable-JSON
column surfaces to the caller as a non-budget 5xx. Across the whole reset wait
every non-ok response must be a budget block (is_budget_block) and never a
server error; this guards the error path independently of whether the reset
eventually fires."""
key = client.generate_key(max_budget=TINY_CAP, budget_duration=f"{WINDOW_SECONDS}s")
resources.defer(lambda: client.delete_key(key))
_drive_to_block(client, key)
saw_reset = False
deadline = time.monotonic() + RESET_DEADLINE_SECONDS
while time.monotonic() < deadline:
time.sleep(5)
result = _call(client, key)
if result.ok:
saw_reset = True
break
assert is_budget_block(result), (
f"reset wait yielded a non-budget error (likely a JSON-column reset crash): {result.body[:200]}"
)
assert saw_reset, f"key budget never reset within {RESET_DEADLINE_SECONDS}s"

View file

@ -64,25 +64,47 @@ def _redis() -> "redis.Redis[str] | RedisCluster[str]":
)
def _parse_counter(raw: object) -> float | None:
if raw is None:
return None
if isinstance(raw, (int, float)):
return float(raw)
text = str(raw).strip()
if not text:
return None
try:
return float(text)
except ValueError:
try:
import json
return float(json.loads(text))
except Exception:
return None
def _spend_counter(rds: "redis.Redis[str] | RedisCluster[str]", key: str) -> float | None:
"""The shared spend counter for `key`, or None if it is cold. A cluster client
can't run a keyspace SCAN that spans shards, so read the key directly - the stage
gateway sets no cache namespace, so the key is the bare ``spend:key:{sha256(key)}``.
A standalone client matches by suffix, so the local cache namespace (litellm.caching)
need not be hard-coded here."""
"""The shared spend counter for `key`, or None if it is cold.
The gateway keys counters as ``spend:key:{sha256(raw_sk)}``, optionally under a
redis namespace prefix. Cluster mode cannot SCAN all shards, so try the bare key
and a few common namespaces; standalone redis uses a suffix SCAN.
"""
from redis.cluster import RedisCluster
digest = hashlib.sha256(key.encode()).hexdigest()
suffix = f"spend:key:{digest}"
if isinstance(rds, RedisCluster):
raw = rds.get(suffix)
return float(raw) if raw is not None else None
for candidate in (suffix, f"litellm:{suffix}", f"litellm.caching:{suffix}"):
parsed = _parse_counter(rds.get(candidate))
if parsed is not None:
return parsed
return None
matches = list(rds.scan_iter(match=f"*{suffix}"))
if not matches:
return None
raw = rds.get(matches[0])
return float(raw) if raw is not None else None
return _parse_counter(rds.get(matches[0]))
def _chat(client: BudgetClient, key: str) -> StreamingResponse:
@ -97,19 +119,6 @@ def _accumulate(client: BudgetClient, key: str, count: int) -> None:
list(pool.map(one, range(count)))
def _burst(client: BudgetClient, key: str, count: int) -> None:
"""Fire `count` requests that start together, so multiple workers reseed the cold
counter concurrently rather than one warming it before the others arrive."""
barrier = Barrier(count)
def one(_: int) -> StreamingResponse:
barrier.wait()
return _chat(client, key)
with ThreadPoolExecutor(max_workers=count) as pool:
list(pool.map(one, range(count)))
def test_cold_counter_reseed_keeps_counter_equal_to_db_spend(
client: BudgetClient, resources: ResourceManager
) -> None:
@ -132,10 +141,28 @@ def test_cold_counter_reseed_keeps_counter_equal_to_db_spend(
db_spend = client.gateway.key_info(key).spend or 0.0
assert db_spend > 0, f"no DB spend accumulated from real calls: {db_spend}"
_burst(client, key, BURST)
time.sleep(3)
burst_results = []
barrier = Barrier(BURST)
def one(_: int) -> StreamingResponse:
barrier.wait()
return _chat(client, key)
with ThreadPoolExecutor(max_workers=BURST) as pool:
burst_results = list(pool.map(one, range(BURST)))
assert all(r.ok for r in burst_results), (
"some burst calls failed; cannot exercise concurrent reseed. "
f"statuses={[r.status_code for r in burst_results]}"
)
counter: float | None = None
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
counter = _spend_counter(rds, key)
if counter is not None:
break
time.sleep(0.5)
counter = _spend_counter(rds, key)
assert counter is not None, "the burst did not reseed the cold counter"
assert db_spend * 0.95 <= counter < db_spend * 1.7, (
f"redis spend counter {counter} does not equal DB spend {db_spend} (expected ~equal "

View file

@ -26,7 +26,7 @@ def _tagged_call(client: BudgetClient, key: str, tag: str):
"claude-haiku-4-5",
f"hi {unique_marker()}",
tags=[tag],
max_tokens=16,
max_tokens=64,
)
if not result.ok and not is_budget_block(result):
require_successful_call(result)
@ -40,17 +40,20 @@ def test_tag_budget_blocks_tagged_requests(
client.create_tag(budgeted_tag, max_budget=TINY_BUDGET)
resources.defer(lambda: client.delete_tag(budgeted_tag))
# Requests under the budgeted tag get blocked once its spend is exceeded.
blocked = False
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
if is_budget_block(_tagged_call(client, scoped_key, budgeted_tag)):
blocked = True
break
time.sleep(1)
first = _tagged_call(client, scoped_key, budgeted_tag)
if is_budget_block(first):
blocked = True
else:
require_successful_call(first)
blocked = False
deadline = time.monotonic() + 120
while time.monotonic() < deadline:
if is_budget_block(_tagged_call(client, scoped_key, budgeted_tag)):
blocked = True
break
time.sleep(1)
assert blocked, f"tag budget for {budgeted_tag!r} never enforced"
# A request with an unbudgeted tag on the same key is unaffected.
free_tag = f"e2e-free-tag-{unique_marker()}"
other = _tagged_call(client, scoped_key, free_tag)
assert not is_budget_block(other), (

View file

@ -41,19 +41,19 @@ def test_team_short_window_blocks_then_resets(client: BudgetClient, resources: R
],
)
resources.defer(lambda: client.delete_team(team_id))
key = client.generate_key(team_id=team_id)
key = client.generate_key(team_id=team_id, models=["claude-haiku-4-5"])
resources.defer(lambda: client.delete_key(key))
# 1. exhaust the tight window -> litellm returns budget_exceeded
start = time.monotonic()
blocked = False
for _ in range(20):
for _ in range(30):
result = _call(client, key)
if is_budget_block(result):
blocked = True
break
require_successful_call(result)
time.sleep(2)
time.sleep(1)
assert blocked, f"team {WINDOW_SECONDS}s window never enforced"
# 2. the window resets at the next wall-clock-aligned boundary (up to a window

View file

@ -6,6 +6,8 @@ configs:
master_key: os.environ/LITELLM_MASTER_KEY
database_url: os.environ/DATABASE_URL
store_prompts_in_spend_logs: true
proxy_budget_rescheduler_min_time: 5
proxy_budget_rescheduler_max_time: 10
litellm_settings:
drop_params: true
@ -29,6 +31,14 @@ configs:
- custom_llm_provider: openai
api_key: os.environ/OPENAI_API_KEY
files_settings:
- custom_llm_provider: openai
api_key: os.environ/OPENAI_API_KEY
- custom_llm_provider: azure
api_base: os.environ/AZURE_API_BASE
api_key: os.environ/AZURE_API_KEY
api_version: "2024-05-01-preview"
model_list:
- model_name: gpt-5.5
litellm_params:
@ -64,6 +74,19 @@ services:
DATABASE_URL: postgresql://litellm:litellm@db:5432/litellm
UI_USERNAME: admin
UI_PASSWORD: sk-1234
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-${AWS_BATCH_S3_BUCKET:-}}
AWS_BATCH_S3_BUCKET: ${AWS_BATCH_S3_BUCKET:-${AWS_S3_BUCKET_NAME:-}}
AWS_BATCH_ROLE_ARN: ${AWS_BATCH_ROLE_ARN:-}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AWS_REGION: ${AWS_REGION:-us-east-1}
GCS_BUCKET_NAME: ${GCS_BUCKET_NAME:-}
VERTEXAI_PROJECT: ${VERTEXAI_PROJECT:-}
VERTEXAI_CREDENTIALS: ${VERTEXAI_CREDENTIALS:-}
GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-}
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
AZURE_API_BASE: ${AZURE_API_BASE:-}
AZURE_API_KEY: ${AZURE_API_KEY:-}
ports:
- "4000:4000"
configs:

View file

@ -43,7 +43,7 @@ at call time. The provider table below is the source of truth; edit `PROVIDERS`
| gemini | `gemini-realtime` | `gemini/gemini-3.1-flash-live-preview` |
| vertex_ai | `vertex-realtime` | `vertex_ai/gemini-live-2.5-flash-preview-native-audio-09-2025` |
Bedrock and xai (`xai/grok-4-1-fast-non-reasoning`) are supported by the proxy but
Bedrock and xai (`xai/grok-4-1-fast`) are supported by the proxy but
kept commented out in `PROVIDERS` until they pass end-to-end here; re-enable them by
uncommenting their entry.

View file

@ -95,7 +95,7 @@ PROVIDERS = (
# "xai",
# "xai-realtime",
# LiteLLMParamsBody(
# model="xai/grok-4-1-fast-non-reasoning",
# model="xai/grok-4-1-fast",
# api_key="os.environ/XAI_API_KEY",
# ),
# ), # TODO: Enable once xai Grok Voice realtime is passing end-to-end here

View file

@ -38,6 +38,13 @@ pytestmark = pytest.mark.e2e
pytest.importorskip("pipecat", reason="pipecat-ai not installed")
try:
import nltk
nltk.data.find("tokenizers/punkt_tab")
except LookupError:
pytest.skip("NLTK punkt_tab data is not installed", allow_module_level=True)
from pipecat.adapters.schemas.function_schema import FunctionSchema # noqa: E402
from pipecat.adapters.schemas.tools_schema import ToolsSchema # noqa: E402
from pipecat.frames.frames import ( # noqa: E402
@ -96,7 +103,7 @@ SERVER_VAD_SETTINGS = rt_events.SessionProperties(
noise_reduction=rt_events.InputAudioNoiseReduction(type="near_field"),
turn_detection=rt_events.TurnDetection(
type="server_vad",
threshold=0.8,
threshold=0.5,
prefix_padding_ms=300,
silence_duration_ms=700,
),

View file

@ -0,0 +1,163 @@
"""Live e2e: provider-specific /chat/completions features take real effect.
Each case asserts the feature actually happened, not just a 200. Coverage matrix
(register-on-demand deployments, deleted on teardown):
- Bedrock (anthropic claude-haiku-4-5): prompt caching. A large cacheable prefix
marked with ``cache_control`` is sent twice; the second call must report
cache-read usage tokens > 0. service_tier is out of scope for Bedrock; AWS
Bedrock does not expose an OpenAI-style request service tier, so that cell is
intentionally not covered here.
- Vertex (gemini-2.5-flash): prompt caching via ``cache_control`` context
caching; the second identical call must report cached prompt tokens > 0.
service_tier lives in test_provider_features_e2e.py.
The provider-native cache_control request shape is not expressible with the
shared ``ChatBody`` (whose content is a plain string), so the cacheable body is
modelled locally with typed content blocks.
"""
from __future__ import annotations
import time
import pytest
from pydantic import BaseModel
from e2e_config import unique_marker
from e2e_http import Result, unwrap
from lifecycle import ResourceManager
from models import ChatResponse, LiteLLMParamsBody, Usage
from passthrough_client import PassthroughClient
import os
pytestmark = pytest.mark.e2e
BEDROCK_MODEL = "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0"
VERTEX_MODEL = "vertex_ai/gemini-2.5-flash"
class CacheControl(BaseModel):
type: str = "ephemeral"
class TextBlock(BaseModel):
type: str = "text"
text: str
cache_control: CacheControl | None = None
class RichMessage(BaseModel):
role: str
content: list[TextBlock]
class CacheChatBody(BaseModel):
model: str
messages: list[RichMessage]
max_tokens: int = 64
cache: dict[str, bool] = {"no-cache": True}
def _cacheable_prefix() -> str:
"""A prefix long enough to clear provider minimum cacheable sizes (Haiku is
2048 tokens), unique per run so the first call writes and the second reads."""
marker = unique_marker()
body = " ".join(
f"Cacheable reference paragraph {index} for run {marker}." for index in range(600)
)
return f"{body}\nEnd of reference material {marker}."
def _cached_read_tokens(usage: Usage | None) -> int:
"""Cache-read tokens however the provider reports them: Anthropic-style
``cache_read_input_tokens`` or OpenAI-style ``prompt_tokens_details.cached_tokens``."""
if usage is None:
return 0
if usage.cache_read_input_tokens:
return usage.cache_read_input_tokens
if usage.prompt_tokens_details and usage.prompt_tokens_details.cached_tokens:
return usage.prompt_tokens_details.cached_tokens
return 0
def _cache_chat(
client: PassthroughClient, key: str, model: str, prefix: str
) -> Result[ChatResponse]:
body = CacheChatBody(
model=model,
messages=[
RichMessage(
role="system",
content=[TextBlock(text=prefix, cache_control=CacheControl())],
),
RichMessage(role="user", content=[TextBlock(text="Reply with one word.")]),
],
)
return client.gateway.transport.post(
"/chat/completions",
headers=client.gateway.transport.bearer(key),
json=body,
response_type=ChatResponse,
)
def _assert_cache_read_on_second_call(
client: PassthroughClient, key: str, model: str
) -> None:
prefix = _cacheable_prefix()
first = unwrap(_cache_chat(client, key, model, prefix))
assert first.choices, f"{model}: first cache-priming call returned no choices: {first}"
read_tokens = 0
deadline = time.monotonic() + 30.0
while time.monotonic() < deadline:
second = unwrap(_cache_chat(client, key, model, prefix))
read_tokens = _cached_read_tokens(second.usage)
if read_tokens > 0:
break
time.sleep(3.0)
assert read_tokens > 0, (
f"{model}: second identical call reported no cache-read tokens "
f"({second.usage}); prompt caching did not take effect"
)
class TestCacheControl:
@pytest.mark.covers(
"llm.chat_completions.bedrock_converse.prompt_cache_5m.nonstream.works",
exercised_on=[],
)
def test_bedrock_prompt_caching_reads_cache(
self, client: PassthroughClient, resources: ResourceManager
) -> None:
model = f"e2e-bedrock-cache-{unique_marker()}"
model_id = client.gateway.create_model(
model,
LiteLLMParamsBody(model=BEDROCK_MODEL, aws_region_name="us-east-1"),
)
resources.defer(lambda: client.gateway.delete_model(model_id))
_assert_cache_read_on_second_call(client, resources.key(), model)
@pytest.mark.covers(
"llm.chat_completions.vertex.prompt_cache_5m.nonstream.works",
exercised_on=[],
)
def test_vertex_prompt_caching_reads_cache(
self, client: PassthroughClient, resources: ResourceManager
) -> None:
model = f"e2e-vertex-cache-{unique_marker()}"
model_id = client.gateway.create_model(
model,
LiteLLMParamsBody(
model=VERTEX_MODEL,
vertex_project=os.environ.get("VERTEXAI_PROJECT"),
vertex_location="us-central1",
vertex_credentials=os.environ.get("VERTEXAI_CREDENTIALS"),
),
)
resources.defer(lambda: client.gateway.delete_model(model_id))
_assert_cache_read_on_second_call(client, resources.key(), model)

View file

@ -1,4 +1,4 @@
"""Live e2e for model-specific request features: service_tier and prompt caching.
"""Live e2e for model-specific request features: service_tier.
Each case asserts the feature took effect, not just a 200.
@ -11,81 +11,22 @@ avoided here because it is capacity-constrained and returns a transient 429 when
flex resources are unavailable. Bedrock and Vertex do not accept service_tier, so
that cell is OpenAI-only by design.
Prompt caching is asserted through provider prompt-cache usage tokens. The
deterministic path is explicit ``cache_control`` on an Anthropic-family model
(here Bedrock's Claude): a large cacheable prefix is sent twice and the second
call must report ``cache_read_input_tokens > 0``. OpenAI and Gemini only offer
implicit automatic caching, which does not deterministically produce a cache read
within a test window (verified: repeated >3k-token prompts kept
``prompt_tokens_details.cached_tokens`` at 0), so those caching cells are out of
scope here and covered only by the explicit-cache-control Bedrock case.
Prompt caching lives in test_cache_control.py.
"""
from __future__ import annotations
import pytest
from pydantic import BaseModel, ConfigDict, Field
from e2e_config import unique_marker
from e2e_http import unwrap
from lifecycle import ResourceManager
from models import ChatBody, ChatMessage, ChatResponse, LiteLLMParamsBody
from models import ChatBody, ChatMessage, LiteLLMParamsBody
from passthrough_client import PassthroughClient
pytestmark = pytest.mark.e2e
SERVICE_TIER = "priority"
CACHE_MIN_READ_TOKENS = 1
class CacheControl(BaseModel):
type: str = "ephemeral"
class CacheTextBlock(BaseModel):
type: str = "text"
text: str
cache_control: CacheControl | None = None
class RichMessage(BaseModel):
role: str
content: list[CacheTextBlock]
class CacheDirective(BaseModel):
"""litellm per-request cache control. ``no-cache`` forces the proxy to skip its
own response cache and make a fresh provider call, so the second identical
request actually reaches Bedrock and reads the provider prompt cache instead of
being served the first response verbatim (which would report cache_read=0)."""
model_config = ConfigDict(populate_by_name=True)
no_cache: bool = Field(default=True, alias="no-cache")
class CacheChatBody(BaseModel):
model: str
messages: list[RichMessage]
max_tokens: int
cache: CacheDirective = CacheDirective()
def cacheable_prefix() -> str:
return (
"You are a policy compliance auditor. The following corpus is the immutable "
"reference the assistant must consult on every turn. "
) + ("Clause: obey all safety, formatting, and citation rules exactly. " * 400)
def post_chat(client: PassthroughClient, key: str, body: BaseModel) -> ChatResponse:
return unwrap(
client.gateway.transport.post(
"/chat/completions",
headers=client.gateway.transport.bearer(key),
json=body,
response_type=ChatResponse,
)
)
class TestServiceTier:
@ -120,50 +61,3 @@ class TestServiceTier:
f"service_tier not honored: sent {SERVICE_TIER!r}, response reported "
f"{response.service_tier!r} ({response})"
)
class TestPromptCaching:
@pytest.mark.covers(
"llm.chat_completions.bedrock_converse.prompt_cache_5m.nonstream.works",
exercised_on=[],
)
def test_bedrock_cache_control_produces_cache_read(
self, client: PassthroughClient, resources: ResourceManager
) -> None:
model = f"e2e-bedrock-cache-{unique_marker()}"
model_id = client.gateway.create_model(
model,
LiteLLMParamsBody(
model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
aws_region_name="us-east-1",
),
)
resources.defer(lambda: client.gateway.delete_model(model_id))
key = resources.key()
body = CacheChatBody(
model=model,
max_tokens=32,
messages=[
RichMessage(
role="user",
content=[
CacheTextBlock(
text=cacheable_prefix(), cache_control=CacheControl()
),
CacheTextBlock(text="Answer in one word: acknowledged?"),
],
)
],
)
first = post_chat(client, key, body)
assert first.usage is not None, f"first call reported no usage: {first}"
second = post_chat(client, key, body)
assert second.usage is not None, f"second call reported no usage: {second}"
cache_read = second.usage.cache_read_input_tokens
assert cache_read is not None and cache_read >= CACHE_MIN_READ_TOKENS, (
"second identical request did not read the prompt cache: "
f"cache_read_input_tokens={cache_read!r} (usage={second.usage})"
)

View file

@ -6,10 +6,11 @@ llm-only key hitting a management route).
from __future__ import annotations
import time
from dataclasses import dataclass
from e2e_gateway import Gateway, build_gateway
from e2e_http import NoBody, ProbeResult, StreamingResponse, unwrap
from e2e_http import NoBody, ProbeResult, Result, StreamingResponse, Success, UnknownApiError, unwrap
from models import (
ChatBody,
ChatMessage,
@ -43,6 +44,8 @@ from models import (
MODEL_ACCESS_DENIED_MARKER = "key_model_access_denied"
ROUTE_NOT_ALLOWED_MARKER = "not allowed to call this route"
_TEAM_READY_ATTEMPTS = 15
_TEAM_READY_SLEEP_SECONDS = 0.4
@dataclass(frozen=True, slots=True)
@ -53,14 +56,26 @@ class ManagementClient:
return self.gateway.generate_key(KeyGenerateBody(models=[], allowed_routes=["llm_api_routes"]))
def update_key_models(self, key: str, models: list[str]) -> None:
_ = unwrap(
self.gateway.transport.post(
last: Result[NoBody] | None = None
for attempt in range(5):
last = self.gateway.transport.post(
"/key/update",
headers=self.gateway.transport.master,
json=KeyUpdateBody(key=key, models=models),
response_type=NoBody,
)
)
match last:
case Success():
return
case UnknownApiError(body=body) if (
"connecting to redis" in body.lower() or "name resolution" in body.lower()
):
time.sleep(0.5 * (attempt + 1))
continue
case _:
break
assert last is not None
_ = unwrap(last)
def delete_key_strict(self, key: str) -> None:
"""Strict delete for the act phase of a test: a failed delete is a hard
@ -85,7 +100,7 @@ class ManagementClient:
).total_count
def create_team(self, body: TeamNewBody) -> str:
return unwrap(
team_id = unwrap(
self.gateway.transport.post(
"/team/new",
headers=self.gateway.transport.master,
@ -93,6 +108,8 @@ class ManagementClient:
response_type=TeamNewResponse,
)
).team_id
self._wait_for_team(team_id)
return team_id
def delete_team(self, team_id: str) -> None:
_ = self.gateway.transport.post(
@ -115,15 +132,44 @@ class ManagementClient:
def team_info_status(self, team_id: str) -> ProbeResult:
return self.gateway.transport.probe("/team/info", params=TeamInfoParams(team_id=team_id))
def _wait_for_team(self, team_id: str) -> None:
last: Result[TeamInfoResponse] | None = None
for _ in range(_TEAM_READY_ATTEMPTS):
last = self.gateway.transport.get(
"/team/info",
headers=self.gateway.transport.master,
params=TeamInfoParams(team_id=team_id),
response_type=TeamInfoResponse,
)
match last:
case Success():
return
case _:
time.sleep(_TEAM_READY_SLEEP_SECONDS)
assert last is not None
_ = unwrap(last)
def add_team_member(self, team_id: str, user_id: str) -> None:
_ = unwrap(
self.gateway.transport.post(
last: Result[NoBody] | None = None
for attempt in range(_TEAM_READY_ATTEMPTS):
last = self.gateway.transport.post(
"/team/member_add",
headers=self.gateway.transport.master,
json=TeamMemberAddBody(team_id=team_id, member=TeamMemberEntry(role="user", user_id=user_id)),
response_type=NoBody,
)
)
match last:
case Success():
return
case UnknownApiError(body=body) if (
"doesn't exist" in body and attempt + 1 < _TEAM_READY_ATTEMPTS
):
time.sleep(_TEAM_READY_SLEEP_SECONDS)
continue
case _:
break
assert last is not None
_ = unwrap(last)
def delete_team_member(self, team_id: str, user_id: str) -> None:
_ = unwrap(

View file

@ -377,10 +377,13 @@ class LiteLLMParamsBody(BaseModel):
api_base: str | None = None
api_version: str | None = None
realtime_protocol: str | None = None
aws_access_key_id: str | None = None
aws_secret_access_key: str | None = None
aws_region_name: str | None = None
vertex_project: str | None = None
vertex_location: str | None = None
vertex_credentials: str | None = None
gcs_bucket_name: str | None = None
bucket_name: str | None = None
s3_bucket_name: str | None = None
s3_region_name: str | None = None

View file

@ -105,27 +105,35 @@ async def test_team_update_authz_matrix(
assert row.team_alias != MARKER_ALIAS, "denied but team mutated"
async def test_team_update_requires_proxy_admin_without_org_context(
async def test_team_update_org_admin_resolved_from_team_without_org_context(
proxy_client, prisma, scratch, world
):
"""With no organization_id in the body the route gate has no org context
and falls back to proxy-admin-only: an org admin of the team's own org
is 401, PROXY_ADMIN is 200."""
"""With no organization_id in the body the route gate resolves the target
team's org from team_id, so an org admin of the team's own org is allowed
(200), same as PROXY_ADMIN. A team admin of that same team stays denied
(401): the resolution grants org admins access, not team admins."""
await _seed_target(prisma, world, "alpha", scratch.prefix)
denied = await proxy_client.post(
allowed_org_admin = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {world.keys[Actor.ORG_ADMIN].cleartext}"},
json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS},
)
assert denied.status_code == 401, denied.text
assert allowed_org_admin.status_code == 200, allowed_org_admin.text
allowed = await proxy_client.post(
allowed_proxy_admin = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS},
)
assert allowed.status_code == 200, allowed.text
assert allowed_proxy_admin.status_code == 200, allowed_proxy_admin.text
denied_team_admin = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {world.keys[Actor.TEAM_ADMIN].cleartext}"},
json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS},
)
assert denied_team_admin.status_code == 401, denied_team_admin.text
# Relocation gate — moving a team to a different org. The scratch team starts

View file

@ -833,6 +833,173 @@ class TestGuardrailSensitiveFieldStripping:
assert "sk-secret" not in serialized
class TestGuardrailResponseCredentialMasking:
"""LIT-4314 issue B regression: credentials embedded in guardrail_response
(via team callback_vars flowing through data["metadata"]) must be masked at
the construction seam so every downstream sink (SpendLogs, OTel, Langfuse,
custom loggers) sees masked values rather than plaintext.
"""
def _make_guardrail(self):
from litellm.types.guardrails import GuardrailEventHooks
return CustomGuardrail(
guardrail_name="test_guardrail",
event_hook=GuardrailEventHooks.pre_call,
)
def test_callback_vars_api_key_is_masked(self):
import json
guardrail = self._make_guardrail()
request_data: dict = {"metadata": {}}
plaintext_key = "lsv2_pt_abcdef1234567890"
guardrail.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "hi"}],
"metadata_snapshot": {
"callback_vars": {
"langsmith_api_key": plaintext_key,
"langsmith_project": "proj-name",
}
},
},
request_data=request_data,
guardrail_status="success",
duration=1.0,
)
logged = request_data["metadata"]["standard_logging_guardrail_information"][0][
"guardrail_response"
]
masked_key = logged["metadata_snapshot"]["callback_vars"]["langsmith_api_key"]
assert masked_key != plaintext_key
assert "*" in masked_key
assert plaintext_key not in json.dumps(request_data)
assert logged["model"] == "gpt-4o-mini"
assert logged["messages"] == [{"role": "user", "content": "hi"}]
assert (
logged["metadata_snapshot"]["callback_vars"]["langsmith_project"]
== "proj-name"
)
def test_nested_user_api_key_auth_metadata_is_masked(self):
import json
guardrail = self._make_guardrail()
request_data: dict = {"metadata": {}}
token_value = "1b01552f6e52e0d41963dd6a185bd6b074624e330999534ca7ff5adfdf622dfc"
guardrail.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response={
"evaluated_metadata": {
"user_api_key_auth": {
"token": token_value,
"api_key": token_value,
"metadata": {
"callback_vars": {
"langsmith_api_key": "lsv2_pt_super_secret_value_1234",
}
},
}
}
},
request_data=request_data,
guardrail_status="success",
)
serialized = json.dumps(request_data)
assert token_value not in serialized
assert "lsv2_pt_super_secret_value_1234" not in serialized
def test_secret_fields_pop_still_runs(self):
import json
guardrail = self._make_guardrail()
request_data: dict = {"metadata": {}}
guardrail.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response={
"model": "gpt-4",
"secret_fields": {
"raw_headers": {
"authorization": "Bearer sk-live-should-not-appear",
}
},
},
request_data=request_data,
guardrail_status="success",
)
serialized = json.dumps(request_data)
assert "secret_fields" not in serialized
assert "sk-live-should-not-appear" not in serialized
def test_match_and_regex_redaction_still_runs(self):
guardrail = self._make_guardrail()
request_data: dict = {"metadata": {}}
guardrail.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response={
"filters": [{"regex": r"\d{3}-\d{2}-\d{4}", "action": "BLOCKED"}]
},
request_data=request_data,
guardrail_status="success",
)
slg = request_data["metadata"]["standard_logging_guardrail_information"][0]
assert slg["guardrail_response"]["filters"][0]["regex"] == "[REDACTED]"
def test_scalar_types_pass_through_unchanged(self):
guardrail = self._make_guardrail()
request_data: dict = {"metadata": {}}
guardrail.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response={
"flagged": True,
"score": 0.94,
"tokens_used": 42,
"categories": ["pii", "toxicity"],
},
request_data=request_data,
guardrail_status="success",
)
logged = request_data["metadata"]["standard_logging_guardrail_information"][0][
"guardrail_response"
]
assert logged["flagged"] is True
assert logged["score"] == 0.94
assert logged["tokens_used"] == 42
assert logged["categories"] == ["pii", "toxicity"]
def test_masking_reveals_prefix_and_suffix(self):
guardrail = self._make_guardrail()
request_data: dict = {"metadata": {}}
plaintext = "lsv2_pt_abcdef1234567890"
guardrail.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response={
"metadata_snapshot": {
"callback_vars": {"langsmith_api_key": plaintext}
}
},
request_data=request_data,
guardrail_status="success",
)
masked = request_data["metadata"]["standard_logging_guardrail_information"][0][
"guardrail_response"
]["metadata_snapshot"]["callback_vars"]["langsmith_api_key"]
assert masked != plaintext
assert masked.startswith(plaintext[:4])
assert masked.endswith(plaintext[-4:])
class TestCustomGuardrailPassthroughSupport:
"""Tests for passthrough endpoint guardrail support - Issue fixes."""

View file

@ -562,6 +562,49 @@ def test_generic_cost_per_token_gpt56(
assert round(completion_cost, 10) == round(output_cost * completion_tokens, 10)
@pytest.mark.parametrize(
"model,input_cost,output_cost,cache_read_cost",
[
("azure/gpt-5.6", 5e-6, 3e-5, 5e-7),
("azure/gpt-5.6-sol", 5e-6, 3e-5, 5e-7),
("azure/gpt-5.6-terra", 2.5e-6, 1.5e-5, 2.5e-7),
("azure/gpt-5.6-luna", 1e-6, 6e-6, 1e-7),
("azure/us/gpt-5.6", 5.5e-6, 3.3e-5, 5.5e-7),
("azure/eu/gpt-5.6-terra", 2.75e-6, 1.65e-5, 2.75e-7),
("azure/eu/gpt-5.6-luna", 1.1e-6, 6.6e-6, 1.1e-7),
],
)
def test_generic_cost_per_token_azure_gpt56(
model, input_cost, output_cost, cache_read_cost
):
"""Azure gpt-5.6 (global + us/eu regional): pricing mirrors the openai
family for global deployments and carries the standard 10% regional uplift.
"""
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
model_cost_map = litellm.model_cost[model]
assert model_cost_map["litellm_provider"] == "azure"
assert model_cost_map["input_cost_per_token"] == input_cost
assert model_cost_map["output_cost_per_token"] == output_cost
assert model_cost_map["cache_read_input_token_cost"] == cache_read_cost
prompt_tokens = 1000
completion_tokens = 500
usage = Usage(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
)
prompt_cost, completion_cost = generic_cost_per_token(
model=model,
usage=usage,
custom_llm_provider="azure",
)
assert round(prompt_cost, 10) == round(input_cost * prompt_tokens, 10)
assert round(completion_cost, 10) == round(output_cost * completion_tokens, 10)
@pytest.mark.parametrize(
"model,expected_none,expected_xhigh,expected_minimal",
[

View file

@ -240,3 +240,78 @@ def test_mask_sensitive_structure_masks_credentials_nested_in_config_shape():
[{"primary-group": [{"model": "gpt-4o", "api_key": secret}]}]
)
assert secret not in str(masked)
def test_mask_credentials_in_payload_preserves_none_and_scalars():
"""The payload variant does not distort JSON-shaped values: None stays None,
ints/floats/bools stay themselves, lists stay lists. This is what makes it
safe for logging pipelines that persist the record verbatim."""
from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload
result = mask_credentials_in_payload(
{
"reason": None,
"confidence": 0.42,
"flagged": True,
"tokens_used": 17,
"categories": ["pii", "toxicity"],
"nested": {"end_user_id": None},
}
)
assert result == {
"reason": None,
"confidence": 0.42,
"flagged": True,
"tokens_used": 17,
"categories": ["pii", "toxicity"],
"nested": {"end_user_id": None},
}
def test_mask_credentials_in_payload_masks_inside_pydantic_models():
"""A Pydantic model reached during the walk gets dumped to a dict so its
sensitive-named string fields are masked. Without this the credentials
inside a nested ``UserAPIKeyAuth`` in a guardrail_response reach the
logging pipeline unmasked once JSON serialization flattens it."""
from pydantic import BaseModel
from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload
class Auth(BaseModel):
token: str = "1b01552f6e52e0d41963dd6a185bd6b074624e330999534ca7ff5adfdf622dfc"
team_alias: str = "acme"
result = mask_credentials_in_payload({"user_api_key_auth": Auth()})
auth_dict = result["user_api_key_auth"]
assert isinstance(auth_dict, dict)
assert auth_dict["team_alias"] == "acme"
assert (
auth_dict["token"]
!= "1b01552f6e52e0d41963dd6a185bd6b074624e330999534ca7ff5adfdf622dfc"
)
assert "*" in auth_dict["token"]
def test_mask_credentials_in_payload_masks_only_sensitive_string_leaves():
"""Sensitive-named string leaves get masked; sibling non-string values
(including None) under the same key stay verbatim."""
from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload
plaintext = "lsv2_pt_abcdef1234567890"
result = mask_credentials_in_payload(
{
"model": "gpt-4o-mini",
"callback_vars": {
"langsmith_api_key": plaintext,
"langsmith_project": "proj",
"extra_token_count": 5,
},
}
)
assert result["model"] == "gpt-4o-mini"
assert result["callback_vars"]["langsmith_project"] == "proj"
assert result["callback_vars"]["extra_token_count"] == 5
masked = result["callback_vars"]["langsmith_api_key"]
assert masked != plaintext
assert masked.startswith(plaintext[:4])
assert masked.endswith(plaintext[-4:])

View file

@ -0,0 +1,224 @@
"""
Tests for the Meta Model API (Muse Spark) provider configuration and integration.
"""
import litellm
class TestMetaProviderConfig:
def test_meta_in_provider_list(self):
from litellm import LlmProviders
assert hasattr(LlmProviders, "META")
assert LlmProviders.META.value == "meta"
assert "meta" in litellm.provider_list
def test_meta_json_config_exists(self):
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
assert JSONProviderRegistry.exists("meta")
meta = JSONProviderRegistry.get("meta")
assert meta is not None
assert meta.base_url == "https://api.meta.ai/v1"
assert meta.api_key_env == "META_API_KEY"
assert meta.api_base_env == "META_API_BASE"
def test_meta_supports_responses_api(self):
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
assert JSONProviderRegistry.supports_responses_api("meta")
def test_meta_in_openai_compatible_providers(self):
from litellm.constants import openai_compatible_providers
assert "meta" in openai_compatible_providers
def test_meta_provider_resolution(self):
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
model, provider, api_key, api_base = get_llm_provider(
model="meta/muse-spark-1.1",
custom_llm_provider=None,
api_base=None,
api_key="sk-test",
)
assert model == "muse-spark-1.1"
assert provider == "meta"
assert api_base == "https://api.meta.ai/v1"
def test_meta_api_base_override(self):
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
model, provider, api_key, api_base = get_llm_provider(
model="meta/muse-spark-1.1",
custom_llm_provider=None,
api_base="https://custom.meta.ai/v1",
api_key="sk-test",
)
assert provider == "meta"
assert api_base == "https://custom.meta.ai/v1"
assert api_key == "sk-test"
def test_meta_url_autodetection(self):
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
model, provider, api_key, api_base = get_llm_provider(
model="muse-spark-1.1",
custom_llm_provider=None,
api_base="https://api.meta.ai/v1",
api_key=None,
)
assert provider == "meta"
assert api_base == "https://api.meta.ai/v1"
def test_meta_router_config(self):
from litellm import Router
router = Router(
model_list=[
{
"model_name": "muse-spark",
"litellm_params": {
"model": "meta/muse-spark-1.1",
"api_key": "test-key",
},
}
]
)
assert len(router.model_list) == 1
assert router.model_list[0]["model_name"] == "muse-spark"
class TestMetaReasoningParams:
def test_muse_spark_supports_reasoning_effort(self):
params = litellm.get_supported_openai_params(
model="muse-spark-1.1", custom_llm_provider="meta"
)
assert params is not None
assert "reasoning_effort" in params
def test_reasoning_effort_mapped_through(self):
cfg = litellm.ProviderConfigManager.get_provider_chat_config(
model="muse-spark-1.1", provider=litellm.LlmProviders.META
)
assert cfg is not None
mapped = cfg.map_openai_params(
non_default_params={"reasoning_effort": "xhigh"},
optional_params={},
model="muse-spark-1.1",
drop_params=False,
)
assert mapped["reasoning_effort"] == "xhigh"
def test_reasoning_effort_gated_on_capability(self):
"""A meta model without reasoning metadata must not advertise reasoning_effort."""
params = litellm.get_supported_openai_params(
model="some-non-reasoning-model", custom_llm_provider="meta"
)
assert params is not None
assert "reasoning_effort" not in params
class TestMetaAnthropicMessages:
def test_meta_resolves_native_messages_config(self):
from litellm.llms.openai_like.messages.transformation import (
JSONProviderAnthropicMessagesConfig,
)
cfg = litellm.ProviderConfigManager.get_provider_anthropic_messages_config(
model="muse-spark-1.1", provider=litellm.LlmProviders.META
)
assert isinstance(cfg, JSONProviderAnthropicMessagesConfig)
def test_json_provider_without_messages_endpoint_resolves_none(self):
cfg = litellm.ProviderConfigManager.get_provider_anthropic_messages_config(
model="some-model", provider=litellm.LlmProviders.PINSTRIPES
)
assert cfg is None
def test_complete_url_defaults_to_meta_base(self):
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
from litellm.llms.openai_like.messages.transformation import (
JSONProviderAnthropicMessagesConfig,
)
provider = JSONProviderRegistry.get("meta")
assert provider is not None
cfg = JSONProviderAnthropicMessagesConfig(provider)
url = cfg.get_complete_url(
api_base=None,
api_key="sk-test",
model="muse-spark-1.1",
optional_params={},
litellm_params={},
)
assert url == "https://api.meta.ai/v1/messages"
override_url = cfg.get_complete_url(
api_base="https://custom.meta.ai/v1",
api_key="sk-test",
model="muse-spark-1.1",
optional_params={},
litellm_params={},
)
assert override_url == "https://custom.meta.ai/v1/messages"
def test_api_key_resolved_from_env(self, monkeypatch):
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
from litellm.llms.openai_like.messages.transformation import (
JSONProviderAnthropicMessagesConfig,
)
monkeypatch.setenv("META_API_KEY", "sk-env-key")
provider = JSONProviderRegistry.get("meta")
assert provider is not None
cfg = JSONProviderAnthropicMessagesConfig(provider)
headers, _ = cfg.validate_anthropic_messages_environment(
headers={},
model="muse-spark-1.1",
messages=[{"role": "user", "content": "hi"}],
optional_params={},
litellm_params={},
api_key=None,
api_base=None,
)
assert headers["authorization"] == "Bearer sk-env-key"
assert headers["anthropic-version"] == "2023-06-01"
class TestMuseSparkModelInfo:
def test_muse_spark_pricing_and_capabilities(self):
info = litellm.get_model_info("meta/muse-spark-1.1")
assert info["litellm_provider"] == "meta"
assert info["input_cost_per_token"] == 1.25e-06
assert info["output_cost_per_token"] == 4.25e-06
assert info["cache_read_input_token_cost"] == 1.5e-07
assert info["max_input_tokens"] == 1048576
assert info["supports_reasoning"] is True
assert info["supports_web_search"] is True
assert info["supports_vision"] is True
assert info["supports_function_calling"] is True
assert info["supports_prompt_caching"] is True
def test_muse_spark_cost_calculation(self):
from litellm import completion_cost
from litellm.types.utils import ModelResponse, Usage
response = ModelResponse(
model="muse-spark-1.1",
usage=Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500),
)
cost = completion_cost(
completion_response=response,
model="meta/muse-spark-1.1",
custom_llm_provider="meta",
)
expected = 1000 * 1.25e-06 + 500 * 4.25e-06
assert abs(cost - expected) < 1e-12

View file

@ -149,6 +149,29 @@ async def test_authorization_code_isolates_by_subject():
assert isinstance(bob, Error) and bob.error.tag == "unauthorized"
@pytest.mark.asyncio
async def test_authorization_code_isolates_by_server_id_even_when_servers_share_a_url():
"""A token stored for one server must be invisible to a different server_id pointing at the
same upstream URL: credentials bind to the server entry they were authorized for, so a
recreated or duplicated server starts unauthorized instead of inheriting the old grant. Guards
against any future token lookup keyed on the resource URL instead of (user_id, server_id) --
both the egress resolve and the has_user_token discovery check must agree."""
shared_url = "https://upstream.example.com"
store = _FakeTokenStore({("alice", "server-a"): OAuthToken(access_token="at-alice")})
provider = UpstreamCredentialProvider(oauth_token_store=store)
subject = Subject(tenant_id="", subject_id="alice")
spec_a = ServerSpec(server_id="server-a", resource=shared_url, config=AuthorizationCodeConfig())
spec_b = ServerSpec(server_id="server-b", resource=shared_url, config=AuthorizationCodeConfig())
granted = await provider.resolve_credentials(subject, spec_a)
fresh = await provider.resolve_credentials(subject, spec_b)
assert isinstance(granted, Ok) and _emitted(granted.ok)["Authorization"] == "Bearer at-alice"
assert isinstance(fresh, Error) and fresh.error.tag == "unauthorized"
assert await provider.has_user_token(subject, spec_a) is True
assert await provider.has_user_token(subject, spec_b) is False
@pytest.mark.asyncio
async def test_has_user_token_reflects_the_stored_token():
present = UpstreamCredentialProvider(

View file

@ -11,6 +11,7 @@ keeps a plain-base64 fallback on read so existing rows continue to work.
import base64
import json
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
@ -63,6 +64,264 @@ def _legacy_row(payload: str):
return row
def _identity_server(**overrides):
base = dict(
url="https://up.example.com/mcp",
auth_type="oauth2",
oauth2_flow="authorization_code",
authorization_url="https://idp.example.com/authorize",
token_url="https://idp.example.com/token",
registration_url="https://idp.example.com/register",
credentials={"client_id": "cid", "client_secret": "csec", "scopes": ["a"]},
server_name="srv",
description="d",
)
base.update(overrides)
return SimpleNamespace(**base)
@pytest.mark.parametrize(
"overrides",
[
{"url": "https://other.example.com/mcp"},
{"spec_path": "https://up.example.com/openapi.json"},
{"auth_type": "oauth_delegate"},
{"oauth2_flow": "client_credentials"},
{"authorization_url": "https://other.example.com/authorize"},
{"token_url": "https://other.example.com/token"},
{"registration_url": "https://other.example.com/register"},
{"credentials": {"client_id": "new", "client_secret": "csec", "scopes": ["a"]}},
{"credentials": {"client_id": "cid", "client_secret": "rotated", "scopes": ["a"]}},
{"credentials": {"client_id": "cid", "client_secret": "csec", "scopes": ["b"]}},
],
)
def test_mcp_oauth_token_identity_changes_on_mint_relevant_fields(overrides):
from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity
assert mcp_oauth_token_identity(_identity_server()) != mcp_oauth_token_identity(_identity_server(**overrides))
@pytest.mark.parametrize(
"overrides",
[
{"server_name": "renamed"},
{"description": "changed"},
],
)
def test_mcp_oauth_token_identity_stable_on_non_mint_fields(overrides):
from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity
assert mcp_oauth_token_identity(_identity_server()) == mcp_oauth_token_identity(_identity_server(**overrides))
def _encrypted_creds_json(client_id: str = "cid", client_secret: str = "csec") -> str:
from litellm.proxy._experimental.mcp_server.db import encrypt_credentials
encrypted = encrypt_credentials(
credentials={"client_id": client_id, "client_secret": client_secret, "scopes": ["a"]},
encryption_key=None,
)
return json.dumps(encrypted)
def test_mcp_oauth_token_identity_stable_across_reencryption():
"""Stored client_id/client_secret are NaCl-encrypted with a fresh nonce on every write, so two
saves of the SAME plaintext produce different ciphertext. The identity must compare decrypted
values; comparing ciphertext would flag every routine save as a mint-relevant change and purge
per-user tokens that are still valid."""
from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity
first = _encrypted_creds_json()
second = _encrypted_creds_json()
assert first != second
assert mcp_oauth_token_identity(_identity_server(credentials=first)) == mcp_oauth_token_identity(
_identity_server(credentials=second)
)
def test_mcp_oauth_token_identity_detects_change_under_encryption():
from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity
unchanged = _identity_server(credentials=_encrypted_creds_json())
changed = _identity_server(credentials=_encrypted_creds_json(client_id="other"))
assert mcp_oauth_token_identity(unchanged) != mcp_oauth_token_identity(changed)
def _oauth_row(user_id: str, server_id: str = "srv-1"):
"""A stored per-user OAuth token row (payload tagged type=oauth2, legacy plain-base64 encoding)."""
row = _legacy_row(json.dumps({"type": "oauth2", "access_token": "tok-" + user_id}))
row.user_id = user_id
row.server_id = server_id
return row
def _byok_row(user_id: str, server_id: str = "srv-1"):
"""A stored BYOK API key row: the same column, but the payload is a plain string, not OAuth JSON."""
row = _legacy_row("sk-byok-" + user_id)
row.user_id = user_id
row.server_id = server_id
return row
@pytest.mark.asyncio
async def test_purge_user_oauth_credentials_for_server_invalidates_each_user():
"""The purge must route each (user, server) row through the invalidator exactly once."""
from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server
prisma = MagicMock()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice"), _oauth_row("bob")])
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=2)
invalidations = []
async def record_invalidation(user_id: str, server_id: str) -> None:
invalidations.append((user_id, server_id))
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1", invalidate_token_cache=record_invalidation)
assert purged == 2
prisma.db.litellm_mcpusercredentials.delete_many.assert_awaited_once_with(
where={"server_id": "srv-1", "user_id": {"in": ["alice", "bob"]}}
)
assert set(invalidations) == {("alice", "srv-1"), ("bob", "srv-1")}
@pytest.mark.asyncio
async def test_purge_user_oauth_credentials_for_server_spares_byok_rows():
"""Regression: the purge used to delete_many on server_id alone, wiping BYOK API keys that share
the LiteLLM_MCPUserCredentials table. Only rows holding an OAuth2 payload may be deleted (one
batched query filtered to their user_ids), and only their users' token caches invalidated."""
from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server
prisma = MagicMock()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_byok_row("carol"), _oauth_row("alice")])
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=1)
invalidations = []
async def record_invalidation(user_id: str, server_id: str) -> None:
invalidations.append((user_id, server_id))
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1", invalidate_token_cache=record_invalidation)
assert purged == 1
prisma.db.litellm_mcpusercredentials.delete_many.assert_awaited_once_with(
where={"server_id": "srv-1", "user_id": {"in": ["alice"]}}
)
assert invalidations == [("alice", "srv-1")]
@pytest.mark.asyncio
async def test_purge_user_oauth_credentials_for_server_all_byok_is_noop():
"""An api_key (BYOK-only) server whose identity tuple changes (e.g. its url) must purge nothing."""
from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server
prisma = MagicMock()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_byok_row("carol"), _byok_row("dave")])
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock()
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1")
assert purged == 0
prisma.db.litellm_mcpusercredentials.delete_many.assert_not_awaited()
@pytest.mark.asyncio
async def test_purge_user_oauth_credentials_for_server_defaults_to_manager_invalidator(monkeypatch):
"""When no invalidator is injected, the purge must resolve to the manager's shared
invalidate_user_oauth_token_cache, the single point covering both the legacy per-user token cache
and the v2 per-user OAuth token store; a wrong or no-op default silently leaves every cache
serving tokens minted for the superseded config."""
from litellm.proxy._experimental.mcp_server import mcp_server_manager
from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server
prisma = MagicMock()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice")])
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=1)
shared_invalidator = AsyncMock()
monkeypatch.setattr(
mcp_server_manager.global_mcp_server_manager,
"invalidate_user_oauth_token_cache",
shared_invalidator,
)
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1")
assert purged == 1
shared_invalidator.assert_awaited_once_with("alice", "srv-1")
@pytest.mark.asyncio
async def test_purge_user_oauth_credentials_for_server_logs_raced_rows(monkeypatch):
from litellm.proxy._experimental.mcp_server import db as db_module
from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server
prisma = MagicMock()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice")])
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=0)
warning = MagicMock()
monkeypatch.setattr(db_module.verbose_proxy_logger, "warning", warning)
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1", invalidate_token_cache=AsyncMock())
assert purged == 0
warning.assert_called_once()
@pytest.mark.asyncio
async def test_delete_mcp_server_invalidates_cached_tokens_for_enumerated_users():
"""Deleting a server must invalidate each enumerated user's cached per-user token: the caches are
keyed by (user_id, server_id), so a re-created server reusing the same server_id would otherwise
serve tokens minted for the deleted server until TTL."""
from litellm.proxy._experimental.mcp_server.db import delete_mcp_server
prisma = MagicMock()
prisma.db.litellm_mcpservertable.delete = AsyncMock(return_value=MagicMock(server_id="srv-1"))
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice"), _byok_row("bob")])
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=2)
prisma.db.litellm_mcpuserenvvars.delete_many = AsyncMock(return_value=0)
invalidations = []
async def record_invalidation(user_id: str, server_id: str) -> None:
invalidations.append((user_id, server_id))
deleted = await delete_mcp_server(prisma, "srv-1", invalidate_token_cache=record_invalidation)
assert deleted is not None
assert set(invalidations) == {("alice", "srv-1"), ("bob", "srv-1")}
@pytest.mark.asyncio
async def test_delete_mcp_server_returns_none_without_cleanup_when_server_missing():
from litellm.proxy._experimental.mcp_server.db import delete_mcp_server
prisma = MagicMock()
prisma.db.litellm_mcpservertable.delete = AsyncMock(return_value=None)
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock()
deleted = await delete_mcp_server(prisma, "srv-1", invalidate_token_cache=AsyncMock())
assert deleted is None
prisma.db.litellm_mcpusercredentials.find_many.assert_not_awaited()
@pytest.mark.asyncio
async def test_purge_user_oauth_credentials_for_server_noop_when_empty():
from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server
prisma = MagicMock()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[])
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock()
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1")
assert purged == 0
prisma.db.litellm_mcpusercredentials.delete_many.assert_not_awaited()
def _stored_value(prisma) -> str:
"""Pull the credential_b64 value passed to the most recent upsert call."""
call = prisma.db.litellm_mcpusercredentials.upsert.call_args
@ -136,9 +395,7 @@ async def test_store_user_oauth_credential_does_not_persist_plaintext():
access_token = "ya29.a0AfH6SMBverysecretaccesstoken"
prisma = _make_prisma_with_existing(row=None)
await store_user_oauth_credential(
prisma, "alice", "srv-1", access_token, refresh_token="rfr-xyz"
)
await store_user_oauth_credential(prisma, "alice", "srv-1", access_token, refresh_token="rfr-xyz")
stored = _stored_value(prisma)
try:
@ -221,9 +478,7 @@ async def test_byok_guard_rejects_overwriting_encrypted_byok():
encrypted_row = MagicMock()
encrypted_row.credential_b64 = _stored_value(prisma)
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(
return_value=encrypted_row
)
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(return_value=encrypted_row)
with pytest.raises(ValueError, match="could not be verified as an OAuth2"):
await store_user_oauth_credential(prisma, "alice", "srv-1", "tok")
@ -265,18 +520,14 @@ async def test_list_oauth_credentials_filters_byok_and_returns_payloads():
"connected_at": "2024-01-01T00:00:00Z",
}
legacy_row = MagicMock()
legacy_row.credential_b64 = base64.urlsafe_b64encode(
json.dumps(legacy_payload).encode()
).decode()
legacy_row.credential_b64 = base64.urlsafe_b64encode(json.dumps(legacy_payload).encode()).decode()
legacy_row.server_id = "srv-legacy"
byok_row = MagicMock()
byok_row.credential_b64 = base64.urlsafe_b64encode(b"plain-byok-key").decode()
byok_row.server_id = "srv-byok"
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(
return_value=[encrypted_row, legacy_row, byok_row]
)
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[encrypted_row, legacy_row, byok_row])
results = await list_user_oauth_credentials(prisma, "alice")
@ -326,9 +577,7 @@ async def test_rotate_re_encrypts_byok_with_new_key(monkeypatch):
prisma.db.litellm_mcpusercredentials.update = AsyncMock()
new_master_key = "rotated-salt-key-9999-9999-9999-9999"
await rotate_mcp_user_credentials_master_key(
prisma_client=prisma, new_master_key=new_master_key
)
await rotate_mcp_user_credentials_master_key(prisma_client=prisma, new_master_key=new_master_key)
update_call = prisma.db.litellm_mcpusercredentials.update.call_args
new_stored = update_call.kwargs["data"]["credential_b64"]
@ -356,19 +605,13 @@ async def test_rotate_migrates_legacy_plaintext_rows(monkeypatch):
legacy_row.user_id = "alice"
legacy_row.server_id = "srv-legacy"
legacy_row.credential_b64 = base64.urlsafe_b64encode(b"legacy-plain").decode()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(
return_value=[legacy_row]
)
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[legacy_row])
prisma.db.litellm_mcpusercredentials.update = AsyncMock()
new_key = "another-rotation-key-aaaa-bbbb-cccc-dddd"
await rotate_mcp_user_credentials_master_key(
prisma_client=prisma, new_master_key=new_key
)
await rotate_mcp_user_credentials_master_key(prisma_client=prisma, new_master_key=new_key)
new_stored = prisma.db.litellm_mcpusercredentials.update.call_args.kwargs["data"][
"credential_b64"
]
new_stored = prisma.db.litellm_mcpusercredentials.update.call_args.kwargs["data"]["credential_b64"]
monkeypatch.setenv("LITELLM_SALT_KEY", new_key)
assert (
decrypt_value_helper(
@ -396,14 +639,10 @@ async def test_rotate_skips_undecodable_rows():
good_row.server_id = "srv-ok"
good_row.credential_b64 = base64.urlsafe_b64encode(b"good-byok").decode()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(
return_value=[bad_row, good_row]
)
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[bad_row, good_row])
prisma.db.litellm_mcpusercredentials.update = AsyncMock()
await rotate_mcp_user_credentials_master_key(
prisma_client=prisma, new_master_key="new-key-xxxx"
)
await rotate_mcp_user_credentials_master_key(prisma_client=prisma, new_master_key="new-key-xxxx")
# Only one update call — the good row.
assert prisma.db.litellm_mcpusercredentials.update.call_count == 1
@ -419,9 +658,7 @@ def _oauth_cred(access_token="at-live", refresh_token=None, expires_in_seconds=N
if refresh_token is not None:
cred["refresh_token"] = refresh_token
if expires_in_seconds is not None:
cred["expires_at"] = (
datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds)
).isoformat()
cred["expires_at"] = (datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds)).isoformat()
return cred
@ -438,12 +675,7 @@ def test_expiry_buffer_treats_soon_to_expire_as_expired():
cred = _oauth_cred(expires_in_seconds=30)
assert is_oauth_credential_expired(cred, buffer_seconds=60) is True
# A token comfortably beyond the buffer stays valid.
assert (
is_oauth_credential_expired(
_oauth_cred(expires_in_seconds=600), buffer_seconds=60
)
is False
)
assert is_oauth_credential_expired(_oauth_cred(expires_in_seconds=600), buffer_seconds=60) is False
def test_expiry_past_is_expired_regardless_of_buffer():
@ -465,9 +697,7 @@ async def test_resolve_returns_valid_token_without_refreshing(monkeypatch):
refresh = AsyncMock()
monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh)
cred = _oauth_cred(
access_token="at-live", refresh_token="rt-1", expires_in_seconds=600
)
cred = _oauth_cred(access_token="at-live", refresh_token="rt-1", expires_in_seconds=600)
result = await resolve_valid_user_oauth_token(
user_id="alice", server=MagicMock(), cred=cred, prisma_client=MagicMock()
)
@ -483,15 +713,11 @@ async def test_resolve_refreshes_expired_token_with_refresh_token(monkeypatch):
# new token rather than returning None (which left the UI tool list empty).
import litellm.proxy._experimental.mcp_server.db as db_mod
refreshed = _oauth_cred(
access_token="at-fresh", refresh_token="rt-2", expires_in_seconds=3600
)
refreshed = _oauth_cred(access_token="at-fresh", refresh_token="rt-2", expires_in_seconds=3600)
refresh = AsyncMock(return_value=refreshed)
monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh)
expired = _oauth_cred(
access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5
)
expired = _oauth_cred(access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5)
result = await resolve_valid_user_oauth_token(
user_id="alice", server=MagicMock(), cred=expired, prisma_client=MagicMock()
)
@ -510,9 +736,7 @@ async def test_resolve_refreshes_token_expiring_within_buffer(monkeypatch):
refresh = AsyncMock(return_value=refreshed)
monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh)
soon = _oauth_cred(
access_token="at-soon", refresh_token="rt-1", expires_in_seconds=30
)
soon = _oauth_cred(access_token="at-soon", refresh_token="rt-1", expires_in_seconds=30)
result = await resolve_valid_user_oauth_token(
user_id="alice", server=MagicMock(), cred=soon, prisma_client=MagicMock()
)
@ -546,9 +770,7 @@ async def test_resolve_returns_none_when_refresh_fails(monkeypatch):
refresh = AsyncMock(return_value=None)
monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh)
expired = _oauth_cred(
access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5
)
expired = _oauth_cred(access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5)
result = await resolve_valid_user_oauth_token(
user_id="alice", server=MagicMock(), cred=expired, prisma_client=MagicMock()
)
@ -565,9 +787,7 @@ async def test_resolve_returns_none_for_missing_credential(monkeypatch):
monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh)
assert (
await resolve_valid_user_oauth_token(
user_id="alice", server=MagicMock(), cred=None, prisma_client=MagicMock()
)
await resolve_valid_user_oauth_token(user_id="alice", server=MagicMock(), cred=None, prisma_client=MagicMock())
is None
)
assert (
@ -601,19 +821,13 @@ async def test_rotate_user_env_vars_re_encrypts_with_new_key(monkeypatch):
encrypted_old = encrypt_value_helper(json.dumps(values))
prisma = MagicMock()
prisma.db.litellm_mcpuserenvvars.find_many = AsyncMock(
return_value=[_env_var_row(encrypted_old)]
)
prisma.db.litellm_mcpuserenvvars.find_many = AsyncMock(return_value=[_env_var_row(encrypted_old)])
prisma.db.litellm_mcpuserenvvars.update = AsyncMock()
new_master_key = "rotated-env-key-1111-2222-3333-4444"
await rotate_mcp_user_env_vars_master_key(
prisma_client=prisma, new_master_key=new_master_key
)
await rotate_mcp_user_env_vars_master_key(prisma_client=prisma, new_master_key=new_master_key)
new_stored = prisma.db.litellm_mcpuserenvvars.update.call_args.kwargs["data"][
"values_b64"
]
new_stored = prisma.db.litellm_mcpuserenvvars.update.call_args.kwargs["data"]["values_b64"]
assert new_stored != encrypted_old, "rotation must produce different ciphertext"
monkeypatch.setenv("LITELLM_SALT_KEY", new_master_key)
@ -630,18 +844,14 @@ async def test_rotate_user_env_vars_re_encrypts_with_new_key(monkeypatch):
async def test_rotate_user_env_vars_skips_undecryptable_rows():
# A corrupt row must be skipped (not overwritten) so recoverable data is
# preserved and one bad row does not abort the rest of the rotation.
good = _env_var_row(
encrypt_value_helper(json.dumps({"A": "1"})), server_id="srv-ok"
)
good = _env_var_row(encrypt_value_helper(json.dumps({"A": "1"})), server_id="srv-ok")
bad = _env_var_row("!!! not encrypted !!!", server_id="srv-corrupt")
prisma = MagicMock()
prisma.db.litellm_mcpuserenvvars.find_many = AsyncMock(return_value=[bad, good])
prisma.db.litellm_mcpuserenvvars.update = AsyncMock()
await rotate_mcp_user_env_vars_master_key(
prisma_client=prisma, new_master_key="new-key-xxxx"
)
await rotate_mcp_user_env_vars_master_key(prisma_client=prisma, new_master_key="new-key-xxxx")
assert prisma.db.litellm_mcpuserenvvars.update.call_count == 1
where = prisma.db.litellm_mcpuserenvvars.update.call_args.kwargs["where"]
@ -669,9 +879,7 @@ async def test_refresh_user_oauth_token_uses_client_secret_basic(monkeypatch):
monkeypatch.setattr(db_mod, "get_async_httpx_client", lambda **kwargs: mock_client)
monkeypatch.setattr(db_mod, "store_user_oauth_credential", AsyncMock())
monkeypatch.setattr(
db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"})
)
monkeypatch.setattr(db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"}))
result = await db_mod.refresh_user_oauth_token(
prisma_client=MagicMock(),
@ -710,9 +918,7 @@ async def test_refresh_user_oauth_token_defaults_to_client_secret_post(monkeypat
monkeypatch.setattr(db_mod, "get_async_httpx_client", lambda **kwargs: mock_client)
monkeypatch.setattr(db_mod, "store_user_oauth_credential", AsyncMock())
monkeypatch.setattr(
db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"})
)
monkeypatch.setattr(db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"}))
await db_mod.refresh_user_oauth_token(
prisma_client=MagicMock(),

View file

@ -665,6 +665,178 @@ async def test_register_client_persists_dcr_client_identity():
mock_update_server.assert_called_once()
async def _register_persistence_attempted_for_auth_type(auth_type: MCPAuth) -> bool:
"""Run register_client_with_server with persist_credentials=True for a server of ``auth_type``
and report whether the DCR result was persisted onto the server row. The client-forwarded token
modes must skip the persist even on the admin path: writing it stamps oauth2_flow and a
client_id onto a server whose contract is that the gateway stores nothing, which makes a fresh
pass-through server read as gateway-authorized. The upstream registration must still be relayed
to the browser either way, since the caller needs the minted client to run its own flow."""
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._types import MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="pt_server",
name="pt_server",
server_name="pt_server",
alias="pt_server",
transport=MCPTransport.http,
auth_type=auth_type,
client_id=None,
client_secret=None,
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
registration_url="https://provider.example/oauth/register",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
mock_response = MagicMock()
mock_response.json.return_value = {
"client_id": "generated-client",
"client_secret": "generated-secret",
"token_endpoint_auth_method": "none",
}
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
mock_update = AsyncMock(return_value=MagicMock())
with (
patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch("litellm.proxy._experimental.mcp_server.db.update_mcp_server", new=mock_update),
patch.object(global_mcp_server_manager, "update_server", new=AsyncMock()),
):
response = await register_client_with_server(
request=mock_request,
mcp_server=server,
client_name="Litellm Proxy",
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="none",
persist_credentials=True,
)
assert json.loads(response.body.decode("utf-8")) == mock_response.json.return_value
return mock_update.await_count > 0
@pytest.mark.asyncio
@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
async def test_register_client_does_not_persist_for_client_forwarded_modes(auth_type):
"""The admin Authorize path (persist_credentials=True) must not write the DCR client onto a
true_passthrough / oauth_delegate server row: the browser still receives the registration, but
the gateway keeps no OAuth client identity for these modes."""
assert await _register_persistence_attempted_for_auth_type(auth_type) is False
@pytest.mark.asyncio
async def test_register_client_persist_discriminator_oauth2_persists():
"""Guard the no-persist assertion above against vacuity: the same helper run against a genuine
oauth2 server DOES persist, so a regression that silently disables persistence everywhere (or a
helper that never reaches the persist) fails here instead of passing both."""
assert await _register_persistence_attempted_for_auth_type(MCPAuth.oauth2) is True
@pytest.mark.asyncio
async def test_register_client_persists_only_to_its_own_row_when_another_server_shares_the_url():
"""A fresh server must mint and persist its OWN DCR client even when another server row with
the same upstream URL already holds one: both the reuse lookup and the persist are keyed by
server_id, never by URL, so OAuth client identity is not transferable between server entries.
If either side ever falls back to a URL match, this fails: the fresh server would skip the
upstream registration (adopting the sibling's client) or persist onto the wrong row."""
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._types import MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
shared_url = "https://provider.example/mcp"
fresh_server = MCPServer(
server_id="server-b",
name="server-b",
server_name="server-b",
alias="server-b",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
url=shared_url,
client_id=None,
client_secret=None,
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
registration_url="https://provider.example/oauth/register",
)
sibling_row_with_client = MagicMock(server_id="server-a", url=shared_url)
sibling_row_with_client.credentials = {"client_id": "client-a-do-not-adopt"}
own_row_without_client = MagicMock(server_id="server-b", url=shared_url)
own_row_without_client.credentials = {}
rows_by_server_id = {"server-a": sibling_row_with_client, "server-b": own_row_without_client}
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
mock_response = MagicMock()
mock_response.json.return_value = {"client_id": "fresh-client-b", "token_endpoint_auth_method": "none"}
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
mock_update = AsyncMock(return_value=MagicMock())
async def _get_row(prisma_client, server_id):
return rows_by_server_id.get(server_id)
with (
patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch("litellm.proxy._experimental.mcp_server.db.get_mcp_server", new=AsyncMock(side_effect=_get_row)),
patch("litellm.proxy._experimental.mcp_server.db.update_mcp_server", new=mock_update),
patch.object(global_mcp_server_manager, "update_server", new=AsyncMock()),
):
response = await register_client_with_server(
request=mock_request,
mcp_server=fresh_server,
client_name="Litellm Proxy",
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="none",
persist_credentials=True,
)
mock_async_client.post.assert_called_once()
body = json.loads(response.body.decode("utf-8"))
assert body["client_id"] == "fresh-client-b"
mock_update.assert_called_once()
update_data = mock_update.call_args.kwargs["data"]
assert update_data.server_id == "server-b"
assert update_data.credentials["client_id"] == "fresh-client-b"
@pytest.mark.asyncio
async def test_register_client_does_not_clobber_token_url_when_absent():
"""When the in-memory server has no token_url, the DCR persist must omit it from the

View file

@ -110,6 +110,42 @@ def test_is_oauth_passthrough_false_without_authorization_header():
assert server.is_oauth_passthrough is False
@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
def test_is_dcr_bridge_true_for_flagged_client_forwarded_modes(auth_type):
server = MCPServer(
server_id="s1",
name="s1",
transport=MCPTransport.http,
auth_type=auth_type,
dcr_bridge=True,
)
assert server.is_dcr_bridge is True
@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
def test_is_dcr_bridge_false_when_flag_unset(auth_type):
server = MCPServer(
server_id="s1",
name="s1",
transport=MCPTransport.http,
auth_type=auth_type,
)
assert server.dcr_bridge is None
assert server.is_dcr_bridge is False
@pytest.mark.parametrize("auth_type", [MCPAuth.oauth2, MCPAuth.none, MCPAuth.api_key, None])
def test_is_dcr_bridge_false_for_non_client_forwarded_auth_types(auth_type):
server = MCPServer(
server_id="s1",
name="s1",
transport=MCPTransport.http,
auth_type=auth_type,
dcr_bridge=True,
)
assert server.is_dcr_bridge is False
def test_is_oauth_passthrough_false_without_extra_headers():
server = MCPServer(
server_id="s1",

View file

@ -201,6 +201,7 @@ async def test_auth_type_switch_clears_stale_flow_scoped_fields():
"token_url",
"registration_url",
"oauth2_flow",
"dcr_bridge",
"token_exchange_endpoint",
"audience",
"subject_token_type",
@ -225,6 +226,20 @@ async def test_auth_type_switch_keeps_explicitly_provided_flow_fields():
assert data_dict["token_url"] is None
@pytest.mark.asyncio
async def test_auth_type_switch_to_client_forwarded_keeps_explicit_dcr_bridge():
data = UpdateMCPServerRequest(
server_id="my-test-server",
auth_type="true_passthrough",
dcr_bridge=True,
)
data_dict = await _run_update_with_existing(data, existing_auth_type="oauth2")
assert data_dict["dcr_bridge"] is True
assert data_dict["oauth2_flow"] is None
@pytest.mark.asyncio
async def test_auth_type_switch_back_to_oauth2_clears_token_exchange_fields():
"""The reverse switch must not leave token-exchange settings behind to
@ -256,6 +271,7 @@ async def test_unchanged_auth_type_does_not_clear_flow_fields():
"token_url",
"registration_url",
"oauth2_flow",
"dcr_bridge",
"token_exchange_endpoint",
"audience",
"subject_token_type",

View file

@ -6869,6 +6869,7 @@ async def test_call_tool_with_legacy_db_m2m_server_resolves_oauth2_flow():
(None, None),
("", None),
("not a url", None),
("http://[::1", None),
],
)
def test_redact_mcp_resource_url_strips_credentials(url, expected):

View file

@ -373,6 +373,66 @@ class TestMCPServerManager:
server = next(iter(manager.config_mcp_servers.values()))
assert server.oauth2_flow is None
def _client_forwarded_config(self, auth_type, **overrides):
base = {
"url": "https://example.com/mcp",
"transport": MCPTransport.http,
"auth_type": auth_type,
}
base.update(overrides)
return {"bridgeserver": base}
@pytest.mark.asyncio
async def test_load_servers_from_config_rejects_dcr_bridge_on_gateway_managed_auth_type(self):
manager = MCPServerManager()
with (
patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)),
pytest.raises(ValueError) as exc_info,
):
await manager.load_servers_from_config(
self._oauth2_config(oauth2_flow="authorization_code", dcr_bridge=True)
)
assert "dcr_bridge is only supported" in str(exc_info.value)
@pytest.mark.asyncio
async def test_load_servers_from_config_rejects_non_boolean_dcr_bridge(self):
manager = MCPServerManager()
with (
patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)),
pytest.raises(ValueError) as exc_info,
):
await manager.load_servers_from_config(
self._client_forwarded_config(MCPAuth.true_passthrough, dcr_bridge="yes")
)
assert "must be a boolean" in str(exc_info.value)
@pytest.mark.asyncio
@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
async def test_load_servers_from_config_accepts_dcr_bridge_on_client_forwarded_modes(self, auth_type):
manager = MCPServerManager()
with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)):
await manager.load_servers_from_config(self._client_forwarded_config(auth_type, dcr_bridge=True))
server = next(iter(manager.config_mcp_servers.values()))
assert server.dcr_bridge is True
assert server.is_dcr_bridge is True
@pytest.mark.asyncio
async def test_load_servers_from_config_dcr_bridge_defaults_off(self):
manager = MCPServerManager()
with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)):
await manager.load_servers_from_config(self._client_forwarded_config(MCPAuth.true_passthrough))
server = next(iter(manager.config_mcp_servers.values()))
assert server.dcr_bridge is None
assert server.is_dcr_bridge is False
@pytest.mark.asyncio
async def test_load_servers_from_config_coerces_cost_string_to_float(self):
"""YAML 1.1 parses `7e-05` as a string; ingest must coerce it to float."""
@ -3327,9 +3387,35 @@ class TestMCPServerManager:
await manager.invalidate_user_oauth_token_cache("alice", "srv-1")
assert store.invalidations == [("alice", "srv-1")]
@pytest.mark.asyncio
async def test_invalidate_user_oauth_token_cache_drops_legacy_cache_too(self):
"""A per-user token can be served from the legacy per-user token cache as well as the v2
store; the shared invalidation must evict both, or the path not evicted keeps serving a
token minted for a replaced credential row until its TTL."""
class _Store:
async def fetch(self, user_id: str, server_id: str):
return None
async def invalidate(self, user_id: str, server_id: str) -> None:
return None
class _LegacyCache:
def __init__(self) -> None:
self.deletes: list[tuple[str, str]] = []
async def delete(self, user_id: str, server_id: str) -> None:
self.deletes.append((user_id, server_id))
legacy_cache = _LegacyCache()
manager = MCPServerManager(per_user_oauth_token_store=_Store(), per_user_token_cache=legacy_cache)
await manager.invalidate_user_oauth_token_cache("alice", "srv-1")
assert legacy_cache.deletes == [("alice", "srv-1")]
@pytest.mark.asyncio
async def test_invalidate_user_oauth_token_cache_swallows_store_errors(self):
"""A cache-drop failure must not fail the credential write that triggered it."""
"""A cache-drop failure must not fail the credential write that triggered it, and the
legacy cache must still be evicted after the v2 store drop fails."""
class _Store:
async def fetch(self, user_id: str, server_id: str):
@ -3338,7 +3424,35 @@ class TestMCPServerManager:
async def invalidate(self, user_id: str, server_id: str) -> None:
raise RuntimeError("redis down")
manager = MCPServerManager(per_user_oauth_token_store=_Store())
class _LegacyCache:
def __init__(self) -> None:
self.deletes: list[tuple[str, str]] = []
async def delete(self, user_id: str, server_id: str) -> None:
self.deletes.append((user_id, server_id))
legacy_cache = _LegacyCache()
manager = MCPServerManager(per_user_oauth_token_store=_Store(), per_user_token_cache=legacy_cache)
await manager.invalidate_user_oauth_token_cache("alice", "srv-1")
assert legacy_cache.deletes == [("alice", "srv-1")]
@pytest.mark.asyncio
async def test_invalidate_user_oauth_token_cache_swallows_legacy_cache_errors(self):
"""The legacy cache drop is best-effort like the v2 drop: a failure must be logged, never
raised into the credential write that triggered the invalidation."""
class _Store:
async def fetch(self, user_id: str, server_id: str):
return None
async def invalidate(self, user_id: str, server_id: str) -> None:
return None
class _RaisingLegacyCache:
async def delete(self, user_id: str, server_id: str) -> None:
raise RuntimeError("redis down")
manager = MCPServerManager(per_user_oauth_token_store=_Store(), per_user_token_cache=_RaisingLegacyCache())
await manager.invalidate_user_oauth_token_cache("alice", "srv-1")
@pytest.mark.asyncio

View file

@ -92,6 +92,36 @@ async def test_token_cached_across_calls():
assert mock_client.post.call_count == 1
@pytest.mark.asyncio
async def test_m2m_token_not_shared_across_server_ids_with_identical_config():
"""Two servers with byte-identical client_credentials config but different server_ids must not
share a cached M2M token: the cache is keyed by server_id, so a new server entry (even one
recreated with the same URL and credentials) mints its own token instead of inheriting the
sibling's. Guards against the cache key ever collapsing to the URL or the client config."""
cache = MCPOAuth2TokenCache()
server_a = _server(server_id="srv-a")
server_b = _server(server_id="srv-b")
mock_client = AsyncMock()
mock_client.post.side_effect = [_token_response("tok-for-a"), _token_response("tok-for-b")]
with (
patch(
"litellm.proxy._experimental.mcp_server.oauth2_token_cache.get_async_httpx_client",
return_value=mock_client,
),
patch(
"litellm.proxy._experimental.mcp_server.oauth2_token_cache.mcp_oauth2_token_cache",
cache,
),
):
token_a = await resolve_mcp_auth(server_a)
token_b = await resolve_mcp_auth(server_b)
assert token_a == "tok-for-a"
assert token_b == "tok-for-b"
assert mock_client.post.call_count == 2
@pytest.mark.asyncio
async def test_per_request_header_beats_oauth2():
"""An explicit mcp_auth_header takes priority over the OAuth2 token."""

View file

@ -2595,6 +2595,135 @@ def test_org_admin_of_multiple_orgs_can_operate_on_both():
assert _user_is_org_admin({"organizations": ["org-A", "org-B"]}, user_obj) is True
# ── LIT-4221: /team/update org-context resolution from team_id ────────────────
from litellm.proxy.auth.auth_checks_organization import (
add_team_org_context_to_request_body,
)
@pytest.mark.asyncio
async def test_add_team_org_context_resolves_org_from_team():
"""For /team/update with only team_id, the target team's org is resolved and
injected so the org-admin route gate can see it. This is what lets an org
admin update a team budget from the Hub UI, which sends team_id, not
organization_id (LIT-4221)."""
async def fetch(team_id: str):
assert team_id == "team-1"
return "org-1"
out = await add_team_org_context_to_request_body(
route="/team/update",
request_body={"team_id": "team-1", "max_budget": 42},
fetch_team_org_id=fetch,
)
assert out == {"team_id": "team-1", "max_budget": 42, "organization_id": "org-1"}
@pytest.mark.asyncio
async def test_add_team_org_context_noop_when_org_id_already_present():
"""If the caller already passed organization_id, no lookup happens and the
body is returned unchanged."""
async def fetch(team_id: str):
raise AssertionError("must not resolve when organization_id is present")
body = {"team_id": "team-1", "organization_id": "org-explicit"}
out = await add_team_org_context_to_request_body(
route="/team/update", request_body=body, fetch_team_org_id=fetch
)
assert out == body
@pytest.mark.asyncio
async def test_add_team_org_context_noop_for_other_routes():
"""Only /team/update opts into org resolution; other routes are untouched."""
async def fetch(team_id: str):
raise AssertionError("must not resolve for a non-opted-in route")
body = {"team_id": "team-1"}
out = await add_team_org_context_to_request_body(
route="/team/delete", request_body=body, fetch_team_org_id=fetch
)
assert out == body
@pytest.mark.asyncio
async def test_add_team_org_context_noop_when_team_has_no_org():
"""A standalone team (no org) resolves to None, so nothing is injected and
the org-admin branch stays unreachable (no blanket access)."""
async def fetch(team_id: str):
return None
body = {"team_id": "team-1"}
out = await add_team_org_context_to_request_body(
route="/team/update", request_body=body, fetch_team_org_id=fetch
)
assert out == body
def test_team_update_gate_allows_org_admin_with_resolved_org():
"""Post-resolution (organization_id present), an org admin of that org clears
the gate for /team/update."""
user_obj = _make_org_admin_user("org-1")
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
request = MagicMock(spec=Request)
request.method = "POST"
request.query_params = {}
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/team/update",
request=request,
valid_token=valid_token,
request_data={"team_id": "team-1", "organization_id": "org-1"},
)
def test_team_update_gate_rejects_without_org_context():
"""Without organization_id (i.e. resolution found no org, or a non-org-admin),
the gate still rejects /team/update the fix adds no blanket allow. Guards
against re-widening the route (e.g. dropping it into self_managed_routes)."""
user_obj = _make_org_admin_user("org-1")
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
request = MagicMock(spec=Request)
request.method = "POST"
request.query_params = {}
with pytest.raises(Exception):
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/team/update",
request=request,
valid_token=valid_token,
request_data={"team_id": "team-1", "max_budget": 42},
)
def test_team_update_gate_rejects_cross_org_admin_with_resolved_org():
"""Even after the target team's org is resolved, an org admin of a DIFFERENT
org is rejected at the gate (no cross-org escalation)."""
user_obj = _make_org_admin_user("org-1")
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
request = MagicMock(spec=Request)
request.method = "POST"
request.query_params = {}
with pytest.raises(Exception):
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/team/update",
request=request,
valid_token=valid_token,
request_data={"team_id": "team-1", "organization_id": "org-2"},
)
@pytest.mark.asyncio
async def test_initialize_pass_through_registers_wildcard_for_auth_subpath():
"""

View file

@ -4900,6 +4900,158 @@ def test_oauth2_flow_defaults_to_none_when_omitted():
assert LiteLLM_MCPServerTable(server_id="srv-1", transport="http").oauth2_flow is None
def test_dcr_bridge_rejected_on_create_for_gateway_managed_auth_type():
from pydantic import ValidationError
from litellm.proxy._types import NewMCPServerRequest
with pytest.raises(ValidationError) as exc:
NewMCPServerRequest(
server_name="bridge-server",
url="https://example.com/mcp",
transport="http",
auth_type="oauth2",
oauth2_flow="authorization_code",
dcr_bridge=True,
)
assert "dcr_bridge is only supported" in str(exc.value)
def test_dcr_bridge_rejected_on_create_when_auth_type_omitted():
from pydantic import ValidationError
from litellm.proxy._types import NewMCPServerRequest
with pytest.raises(ValidationError) as exc:
NewMCPServerRequest(
server_name="bridge-server",
url="https://example.com/mcp",
transport="http",
dcr_bridge=True,
)
assert "dcr_bridge is only supported" in str(exc.value)
@pytest.mark.parametrize("auth_type", ["true_passthrough", "oauth_delegate"])
def test_dcr_bridge_accepted_on_create_for_client_forwarded_modes(auth_type):
from litellm.proxy._experimental.mcp_server.db import _prepare_mcp_server_data
from litellm.proxy._types import NewMCPServerRequest
payload = NewMCPServerRequest(
server_name="bridge-server",
url="https://example.com/mcp",
transport="http",
auth_type=auth_type,
dcr_bridge=True,
)
data_dict = _prepare_mcp_server_data(payload)
assert data_dict["dcr_bridge"] is True
def test_dcr_bridge_update_rejected_when_payload_auth_type_not_client_forwarded():
from pydantic import ValidationError
from litellm.proxy._types import UpdateMCPServerRequest
with pytest.raises(ValidationError) as exc:
UpdateMCPServerRequest(server_id="srv-1", auth_type="oauth2", dcr_bridge=True)
assert "dcr_bridge is only supported" in str(exc.value)
def test_dcr_bridge_update_without_auth_type_defers_to_endpoint():
from litellm.proxy._types import UpdateMCPServerRequest
assert UpdateMCPServerRequest(server_id="srv-1", dcr_bridge=True).dcr_bridge is True
def test_dcr_bridge_round_trips_on_response_model():
from litellm.proxy._types import LiteLLM_MCPServerTable
row = LiteLLM_MCPServerTable(server_id="srv-1", transport="http", dcr_bridge=True)
assert row.dcr_bridge is True
assert LiteLLM_MCPServerTable(server_id="srv-1", transport="http").dcr_bridge is None
def _edit_endpoint_patches(old_record, update_mock):
return (
patch("litellm.proxy.management_endpoints.mcp_management_endpoints.MCP_AVAILABLE", True),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=MagicMock(),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server",
AsyncMock(side_effect=old_record) if isinstance(old_record, Exception) else AsyncMock(return_value=old_record),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.update_mcp_server",
update_mock,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.validate_and_normalize_mcp_server_payload",
autospec=True,
),
)
@pytest.mark.asyncio
@pytest.mark.parametrize("stored_auth_type", ["oauth2", "api_key", "none"])
async def test_edit_mcp_server_rejects_dcr_bridge_when_stored_auth_type_not_client_forwarded(stored_auth_type):
from litellm.proxy._types import UpdateMCPServerRequest
from litellm.proxy.management_endpoints.mcp_management_endpoints import edit_mcp_server
old_record = MagicMock()
old_record.auth_type = stored_auth_type
update_mock = AsyncMock()
p1, p2, p3, p4, p5 = _edit_endpoint_patches(old_record, update_mock)
with p1, p2, p3, p4, p5:
payload = UpdateMCPServerRequest(server_id="srv-1", dcr_bridge=True)
user_auth = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
with pytest.raises(HTTPException) as exc:
await edit_mcp_server(payload=payload, user_api_key_dict=user_auth)
assert exc.value.status_code == 400
assert "dcr_bridge is only supported" in str(exc.value.detail)
update_mock.assert_not_called()
@pytest.mark.asyncio
async def test_edit_mcp_server_rejects_dcr_bridge_when_stored_record_unreadable():
from litellm.proxy._types import UpdateMCPServerRequest
from litellm.proxy.management_endpoints.mcp_management_endpoints import edit_mcp_server
update_mock = AsyncMock()
p1, p2, p3, p4, p5 = _edit_endpoint_patches(RuntimeError("db down"), update_mock)
with p1, p2, p3, p4, p5:
payload = UpdateMCPServerRequest(server_id="srv-1", dcr_bridge=True)
user_auth = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
with pytest.raises(HTTPException) as exc:
await edit_mcp_server(payload=payload, user_api_key_dict=user_auth)
assert exc.value.status_code == 400
update_mock.assert_not_called()
@pytest.mark.asyncio
async def test_edit_mcp_server_dcr_bridge_on_unknown_server_returns_404_not_400():
"""A dcr_bridge enablement targeting a server_id that does not exist must surface the accurate
404 from the update path, not a misleading 400 about the stored auth_type: get_mcp_server
returns None for a missing row without raising, which is distinct from a failed read."""
from litellm.proxy._types import UpdateMCPServerRequest
from litellm.proxy.management_endpoints.mcp_management_endpoints import edit_mcp_server
update_mock = AsyncMock(return_value=None)
p1, p2, p3, p4, p5 = _edit_endpoint_patches(None, update_mock)
with p1, p2, p3, p4, p5:
payload = UpdateMCPServerRequest(server_id="does-not-exist", dcr_bridge=True)
user_auth = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
with pytest.raises(HTTPException) as exc:
await edit_mcp_server(payload=payload, user_api_key_dict=user_auth)
assert exc.value.status_code == 404
update_mock.assert_called_once()
class TestPerUserCredentialConfigServerResolution:
"""Per-user credential and env-var endpoints must resolve config-defined MCP
servers, which live only in the in-memory registry and never get a DB row, so
@ -5134,3 +5286,93 @@ def test_stamp_oauth2_flow_ignores_non_oauth2():
payload = _oauth2_create_payload(auth_type="none")
mgmt_endpoints.stamp_omitted_oauth2_flow(payload)
assert payload.oauth2_flow is None
async def _run_edit(old_record, updated_record, purge_mock=None):
from litellm.proxy.management_endpoints.mcp_management_endpoints import edit_mcp_server
server_id = updated_record.server_id
with (
patch("litellm.proxy.management_endpoints.mcp_management_endpoints.MCP_AVAILABLE", True),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=MagicMock(),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server",
AsyncMock(side_effect=old_record)
if isinstance(old_record, Exception)
else AsyncMock(return_value=old_record),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.update_mcp_server",
AsyncMock(return_value=updated_record),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.validate_and_normalize_mcp_server_payload",
autospec=True,
),
patch("litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager") as mock_manager,
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.purge_user_oauth_credentials_for_server",
purge_mock if purge_mock is not None else AsyncMock(return_value=1),
) as mock_purge,
):
mock_manager.update_server = AsyncMock()
mock_manager.reload_servers_from_database = AsyncMock()
payload = UpdateMCPServerRequest(server_id=server_id, alias=updated_record.alias, url=updated_record.url)
user_auth = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
result = await edit_mcp_server(payload=payload, user_api_key_dict=user_auth)
return result, mock_purge
@pytest.mark.asyncio
async def test_edit_mcp_server_purges_user_tokens_on_mint_relevant_change():
server_id = str(uuid.uuid4())
old = generate_mock_mcp_server_db_record(server_id=server_id, url="https://old.example.com/mcp")
updated = generate_mock_mcp_server_db_record(server_id=server_id, url="https://new.example.com/mcp")
result, mock_purge = await _run_edit(old, updated)
assert result.server_id == server_id
mock_purge.assert_awaited_once()
assert mock_purge.await_args.args[1] == server_id
@pytest.mark.asyncio
async def test_edit_mcp_server_skips_purge_when_identity_unchanged():
server_id = str(uuid.uuid4())
old = generate_mock_mcp_server_db_record(server_id=server_id, alias="Before")
updated = generate_mock_mcp_server_db_record(server_id=server_id, alias="After")
result, mock_purge = await _run_edit(old, updated)
assert result.server_id == server_id
mock_purge.assert_not_awaited()
@pytest.mark.asyncio
async def test_edit_mcp_server_purge_failure_does_not_fail_the_edit():
"""The purge is best-effort: a purge exception after a successful update must be swallowed and
logged, never turned into an error response for an edit whose primary job already succeeded."""
server_id = str(uuid.uuid4())
old = generate_mock_mcp_server_db_record(server_id=server_id, url="https://old.example.com/mcp")
updated = generate_mock_mcp_server_db_record(server_id=server_id, url="https://new.example.com/mcp")
result, mock_purge = await _run_edit(old, updated, purge_mock=AsyncMock(side_effect=RuntimeError("db down")))
assert result.server_id == server_id
mock_purge.assert_awaited_once()
@pytest.mark.asyncio
async def test_edit_mcp_server_snapshot_failure_skips_purge_but_edit_succeeds():
"""The pre-update snapshot read is advisory (it only feeds the purge decision); a read failure
must skip the stale-token check with a warning, never fail the edit itself."""
server_id = str(uuid.uuid4())
updated = generate_mock_mcp_server_db_record(server_id=server_id, url="https://new.example.com/mcp")
result, mock_purge = await _run_edit(RuntimeError("db read failed"), updated)
assert result.server_id == server_id
mock_purge.assert_not_awaited()

View file

@ -3017,6 +3017,7 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts():
return_value=[
{
"session_id": session_id,
"session_total_spend": 15.0,
"mcp_tool_call_count": 1,
"mcp_tool_call_spend": 10.0,
}
@ -3044,6 +3045,10 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts():
assert rows[1]["mcp_tool_call_count"] == 1
assert rows[1]["mcp_tool_call_spend"] == 10.0
# Every row in the session carries the full session spend, not just its own
assert rows[0]["session_total_spend"] == 15.0
assert rows[1]["session_total_spend"] == 15.0
# Row without a session_id defaults to 1
assert rows[2]["session_total_count"] == 1
@ -3055,6 +3060,64 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts():
)
@pytest.mark.asyncio
async def test_build_ui_spend_logs_response_sums_multi_round_session_spend():
"""
Regression test for LIT-4342: for a multi-round session the UI must show the
summed cost of every round, not just the first call. _build_ui_spend_logs_response
enriches each row of a session with session_total_spend aggregated across the
whole session, scoped to the authorized api_keys of the page.
"""
from litellm.proxy.spend_tracking.spend_management_endpoints import (
_build_ui_spend_logs_response,
)
session_id = "sess-multi-round"
api_key = "hashed-key-xyz"
# Three rounds of the same chat session with different per-call spend.
dict_rows = [
{"request_id": "req-1", "session_id": session_id, "call_type": "completion", "api_key": api_key, "spend": 0.01},
{"request_id": "req-2", "session_id": session_id, "call_type": "completion", "api_key": api_key, "spend": 0.02},
{"request_id": "req-3", "session_id": session_id, "call_type": "completion", "api_key": api_key, "spend": 0.03},
]
mock_prisma = MagicMock()
mock_prisma.db.litellm_spendlogs.group_by = AsyncMock(
return_value=[{"session_id": session_id, "_count": {"session_id": 3}}]
)
# The raw aggregate query returns the full session spend (0.01 + 0.02 + 0.03).
mock_prisma.db.query_raw = AsyncMock(
return_value=[
{
"session_id": session_id,
"session_total_spend": 0.06,
"mcp_tool_call_count": 0,
"mcp_tool_call_spend": 0.0,
}
]
)
result = await _build_ui_spend_logs_response(
prisma_client=mock_prisma,
data=dict_rows,
total_records=3,
page=1,
page_size=50,
total_pages=1,
enrich_session_counts=True,
)
rows = result["data"]
assert [row["session_total_spend"] for row in rows] == [0.06, 0.06, 0.06]
# No MCP calls in this session, so MCP fields must not be attached.
assert all("mcp_tool_call_count" not in row for row in rows)
# The aggregate must be scoped to the authorized api_keys of the page.
_, call_args, _ = mock_prisma.db.query_raw.mock_calls[0]
assert call_args[1] == [session_id]
assert call_args[2] == [api_key]
# ---------------------------------------------------------------------------
# Tests for /spend/logs team-member permission
# ---------------------------------------------------------------------------

View file

@ -33,6 +33,7 @@ from litellm.proxy.spend_tracking.spend_tracking_utils import (
_is_master_key,
_redact_prompt_leaks_in_error_string,
_sanitize_error_information_for_spend_logs,
_sanitize_guardrail_information_for_spend_logs,
_sanitize_request_body_for_spend_logs_payload,
_should_store_prompts_and_responses_in_spend_logs,
get_logging_payload,
@ -1263,6 +1264,260 @@ def test_get_spend_logs_metadata_guardrail_info_fallback_from_metadata():
assert result["guardrail_information"] is None
@patch("litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs")
def test_sanitize_guardrail_information_redacts_all_prompt_carrying_fields_when_flag_false(
mock_should_store,
):
"""
match_details and classification are declared as structured metadata but
in-tree writers (litellm_content_filter, block_code_execution) inline
raw prompt content into them, so they leak the same way
guardrail_request/guardrail_response do. Redaction must cover all four.
"""
mock_should_store.return_value = False
guardrail_info = [
{
"guardrail_name": "demo-echo-guard",
"guardrail_status": "success",
"guardrail_request": {"messages": [{"role": "user", "content": "hi"}]},
"guardrail_response": {"evaluated_input": "hi"},
"match_details": [{"type": "pattern", "snippet": "hi", "action_taken": "log"}],
"classification": {"intent": "x", "evidence": [{"match": "hi"}]},
"guardrail_action": "NONE",
}
]
result = _sanitize_guardrail_information_for_spend_logs(guardrail_info)
assert result is not None
entry = result[0]
assert entry["guardrail_request"] == REDACTED_BY_LITELM_STRING
assert entry["guardrail_response"] == REDACTED_BY_LITELM_STRING
assert entry["match_details"] == REDACTED_BY_LITELM_STRING
assert entry["classification"] == REDACTED_BY_LITELM_STRING
assert entry["guardrail_name"] == "demo-echo-guard"
assert entry["guardrail_status"] == "success"
assert entry["guardrail_action"] == "NONE"
@patch("litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs")
def test_sanitize_guardrail_information_redacts_prompt_fields_when_flag_false(
mock_should_store,
):
"""
LIT-4314 Issue A regression: with store_prompts_in_spend_logs=False,
guardrail_request and guardrail_response must be redacted before they
land in LiteLLM_SpendLogs.metadata, while every other field on the
entry is preserved bit-for-bit.
"""
mock_should_store.return_value = False
guardrail_info = [
{
"guardrail_name": "demo-echo-guard",
"guardrail_provider": "custom",
"guardrail_mode": "pre_call",
"guardrail_status": "success",
"guardrail_request": {
"messages": [{"role": "user", "content": "Say hi in 3 words"}],
},
"guardrail_response": {
"evaluated_input": "Say hi in 3 words",
"verdict": "allow",
},
"start_time": 1_700_000_000.0,
"end_time": 1_700_000_000.5,
"duration": 0.5,
"guardrail_id": "gd-42",
"masked_entity_count": {"EMAIL": 1},
"violation_categories": ["prompt_injection"],
"risk_score": 3.5,
"guardrail_action": "NONE",
}
]
result = _sanitize_guardrail_information_for_spend_logs(guardrail_info)
assert result is not None
assert len(result) == 1
entry = result[0]
assert entry["guardrail_request"] == REDACTED_BY_LITELM_STRING
assert entry["guardrail_response"] == REDACTED_BY_LITELM_STRING
assert entry["guardrail_name"] == "demo-echo-guard"
assert entry["guardrail_provider"] == "custom"
assert entry["guardrail_mode"] == "pre_call"
assert entry["guardrail_status"] == "success"
assert entry["start_time"] == 1_700_000_000.0
assert entry["end_time"] == 1_700_000_000.5
assert entry["duration"] == 0.5
assert entry["guardrail_id"] == "gd-42"
assert entry["masked_entity_count"] == {"EMAIL": 1}
assert entry["violation_categories"] == ["prompt_injection"]
assert entry["risk_score"] == 3.5
assert entry["guardrail_action"] == "NONE"
assert guardrail_info[0]["guardrail_request"] == {
"messages": [{"role": "user", "content": "Say hi in 3 words"}],
}
assert guardrail_info[0]["guardrail_response"] == {
"evaluated_input": "Say hi in 3 words",
"verdict": "allow",
}
@patch("litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs")
def test_sanitize_guardrail_information_passthrough_when_flag_true(
mock_should_store,
):
"""
When store_prompts_in_spend_logs=True the sanitizer must be a no-op so
operators who explicitly opted in still see full guardrail payloads.
"""
mock_should_store.return_value = True
guardrail_info = [
{
"guardrail_name": "content_filter",
"guardrail_status": "success",
"guardrail_request": {"messages": [{"role": "user", "content": "hi"}]},
"guardrail_response": {"verdict": "allow"},
}
]
result = _sanitize_guardrail_information_for_spend_logs(guardrail_info)
assert result == guardrail_info
@patch("litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs")
def test_sanitize_guardrail_information_none_passthrough(mock_should_store):
mock_should_store.return_value = False
assert _sanitize_guardrail_information_for_spend_logs(None) is None
@patch("litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs")
def test_sanitize_guardrail_information_normalizes_bare_dict_input(mock_should_store):
"""
Regression: xecguard (xecguard.py:246) assigns a bare dict to
standard_logging_object["guardrail_information"] even though the typed
contract is Optional[List[...]]. Without defensive normalization here,
the for-loop would iterate the dict's string keys and _redact...
would TypeError on {**"guardrail_name"}, taking down the entire
spend-log write via update_database's broad except.
"""
mock_should_store.return_value = False
bare_dict_entry = {
"guardrail_name": "xecguard",
"guardrail_status": "success",
"guardrail_response": {"decision": "SAFE", "raw_prompt": "hi"},
"start_time": 1.0,
"end_time": 2.0,
"duration": 1.0,
}
result = _sanitize_guardrail_information_for_spend_logs(bare_dict_entry)
assert result is not None
assert isinstance(result, list)
assert len(result) == 1
entry = result[0]
assert entry["guardrail_response"] == REDACTED_BY_LITELM_STRING
assert entry["guardrail_name"] == "xecguard"
assert entry["guardrail_status"] == "success"
assert entry["start_time"] == 1.0
@patch("litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs")
def test_sanitize_guardrail_information_drops_non_dict_items_in_list(mock_should_store):
"""
A stray non-dict item in the list (e.g. from a buggy caller that
accidentally appends a string) should be silently skipped instead of
crashing the spend-log write.
"""
mock_should_store.return_value = False
mixed_input = [
{"guardrail_name": "x", "guardrail_response": {"leak": "hi"}},
"not-a-dict",
None,
]
result = _sanitize_guardrail_information_for_spend_logs(mixed_input)
assert result == [{"guardrail_name": "x", "guardrail_response": REDACTED_BY_LITELM_STRING}]
@patch("litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs")
def test_sanitize_guardrail_information_preserves_absent_prompt_fields(mock_should_store):
"""
Entries that never carried guardrail_request or guardrail_response must
not gain those keys after sanitization; consumers keying on presence
(`"guardrail_request" in entry`) would otherwise flip from absent to
the sentinel string.
"""
mock_should_store.return_value = False
guardrail_info = [
{
"guardrail_name": "demo-echo-guard",
"guardrail_status": "success",
"guardrail_response": {"verdict": "allow", "evaluated_input": "hi"},
}
]
result = _sanitize_guardrail_information_for_spend_logs(guardrail_info)
assert result is not None
entry = result[0]
assert "guardrail_request" not in entry
assert entry["guardrail_response"] == REDACTED_BY_LITELM_STRING
assert entry["guardrail_name"] == "demo-echo-guard"
assert entry["guardrail_status"] == "success"
@patch("litellm.proxy.proxy_server.master_key", "sk-master")
@patch(
"litellm.proxy.proxy_server.general_settings",
{"store_prompts_in_spend_logs": False},
)
def test_get_logging_payload_redacts_guardrail_prompt_fields_when_flag_false():
"""
End-to-end wire-in check: get_logging_payload -> _get_spend_logs_metadata
-> sanitizer. Without the wire-in at line 139, the raw guardrail_response
lands in payload["metadata"] verbatim.
"""
guardrail_info = [
{
"guardrail_name": "demo-echo-guard",
"guardrail_provider": "custom",
"guardrail_status": "success",
"guardrail_request": {"messages": [{"role": "user", "content": "secret"}]},
"guardrail_response": {"evaluated_input": "secret"},
}
]
kwargs = {
"model": "gpt-4o-mini",
"litellm_call_id": "test-call-id",
"litellm_params": {
"metadata": {
"user_api_key": "test-key",
"standard_logging_guardrail_information": guardrail_info,
},
"proxy_server_request": {},
},
}
payload = get_logging_payload(
kwargs=kwargs,
response_obj={},
start_time=datetime.datetime.now(tz=timezone.utc),
end_time=datetime.datetime.now(tz=timezone.utc),
)
metadata_result = json.loads(payload["metadata"])
stored = metadata_result["guardrail_information"][0]
assert stored["guardrail_request"] == REDACTED_BY_LITELM_STRING
assert stored["guardrail_response"] == REDACTED_BY_LITELM_STRING
assert stored["guardrail_name"] == "demo-echo-guard"
assert stored["guardrail_status"] == "success"
def test_get_logging_payload_guardrail_info_when_no_standard_logging_payload():
"""
When a guardrail blocks a request before the LLM call, the standard_logging_object
@ -1295,7 +1550,10 @@ def test_get_logging_payload_guardrail_info_when_no_standard_logging_payload():
}
with patch("litellm.proxy.proxy_server.master_key", "sk-master"):
with patch("litellm.proxy.proxy_server.general_settings", {}):
with patch(
"litellm.proxy.proxy_server.general_settings",
{"store_prompts_in_spend_logs": True},
):
payload = get_logging_payload(
kwargs=kwargs,
response_obj={},

View file

@ -62,6 +62,77 @@ def test_openai_gpt_5_6_model_info(model):
assert provider == "openai"
AZURE_GLOBAL_MODELS = (
"azure/gpt-5.6",
"azure/gpt-5.6-sol",
"azure/gpt-5.6-terra",
"azure/gpt-5.6-luna",
)
AZURE_REGIONAL_MODELS = tuple(
f"azure/{region}/{tier}"
for region in ("us", "eu")
for tier in ("gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna")
)
def _tier_key(azure_model):
return azure_model.split("/")[-1]
def _load_main():
json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json"
with open(json_path) as f:
return json.load(f)
@pytest.mark.parametrize("model", AZURE_GLOBAL_MODELS)
def test_azure_gpt_5_6_global_model_info(model):
model_cost = _load_main()
info = model_cost.get(model)
assert info is not None, f"{model} not found in model_prices_and_context_window.json"
assert info["litellm_provider"] == "azure"
assert info["mode"] == "chat"
input_cost, output_cost, cache_read_cost, _ = STANDARD_PRICING[_tier_key(model)]
assert info["input_cost_per_token"] == input_cost
assert info["output_cost_per_token"] == output_cost
assert info["cache_read_input_token_cost"] == cache_read_cost
assert info["input_cost_per_token_above_272k_tokens"] == pytest.approx(input_cost * 2)
assert info["output_cost_per_token_above_272k_tokens"] == pytest.approx(output_cost * 1.5)
assert info["input_cost_per_token_priority"] == pytest.approx(input_cost * 2)
assert info["output_cost_per_token_priority"] == pytest.approx(output_cost * 2)
assert info["input_cost_per_token_above_272k_tokens_priority"] == pytest.approx(input_cost * 4)
assert info["output_cost_per_token_above_272k_tokens_priority"] == pytest.approx(output_cost * 3)
assert info["max_input_tokens"] == 1050000
assert info["max_output_tokens"] == 128000
assert info["supports_reasoning"] is True
routed_model, provider, _, _ = get_llm_provider(model=model)
assert provider == "azure"
@pytest.mark.parametrize("model", AZURE_REGIONAL_MODELS)
def test_azure_gpt_5_6_regional_model_info(model):
model_cost = _load_main()
info = model_cost.get(model)
assert info is not None, f"{model} not found in model_prices_and_context_window.json"
assert info["litellm_provider"] == "azure"
input_cost, output_cost, cache_read_cost, _ = STANDARD_PRICING[_tier_key(model)]
assert info["input_cost_per_token"] == pytest.approx(input_cost * 1.1)
assert info["output_cost_per_token"] == pytest.approx(output_cost * 1.1)
assert info["cache_read_input_token_cost"] == pytest.approx(cache_read_cost * 1.1)
assert info["input_cost_per_token_above_272k_tokens"] == pytest.approx(input_cost * 2.2)
assert info["output_cost_per_token_above_272k_tokens"] == pytest.approx(output_cost * 1.65)
assert info["input_cost_per_token_priority"] == pytest.approx(input_cost * 2.75)
assert info["output_cost_per_token_priority"] == pytest.approx(output_cost * 2.75)
def test_gpt_5_6_backup_matches_main():
"""Ensure the bundled model cost map stays in sync with the canonical file."""
repo_root = Path(__file__).parents[2]
@ -73,7 +144,7 @@ def test_gpt_5_6_backup_matches_main():
with open(backup_path) as f:
backup_cost = json.load(f)
for model in GPT_5_6_MODELS:
for model in GPT_5_6_MODELS + AZURE_GLOBAL_MODELS + AZURE_REGIONAL_MODELS:
assert backup_cost.get(model) == main_cost.get(model), (
f"{model} differs between main and backup model cost maps"
)

View file

@ -0,0 +1,63 @@
import json
from pathlib import Path
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
MUSE_SPARK_MODEL = "meta/muse-spark-1.1"
def test_muse_spark_1_1_model_info():
json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json"
with open(json_path) as f:
model_cost = json.load(f)
info = model_cost.get(MUSE_SPARK_MODEL)
assert info is not None, f"{MUSE_SPARK_MODEL} not found in model_prices_and_context_window.json"
assert info["litellm_provider"] == "meta"
assert info["mode"] == "chat"
assert info["input_cost_per_token"] == 1.25e-06
assert info["output_cost_per_token"] == 4.25e-06
assert info["cache_read_input_token_cost"] == 1.5e-07
assert info["max_input_tokens"] == 1048576
assert info["max_output_tokens"] == 131072
assert info["max_tokens"] == 131072
assert info["supports_function_calling"] is True
assert info["supports_parallel_function_calling"] is True
assert info["supports_prompt_caching"] is True
assert info["supports_reasoning"] is True
assert info["supports_response_schema"] is True
assert info["supports_tool_choice"] is True
assert info["supports_vision"] is True
assert info["supports_pdf_input"] is True
assert info["supports_web_search"] is True
assert info["supports_minimal_reasoning_effort"] is True
assert info["supports_xhigh_reasoning_effort"] is True
assert info["supported_endpoints"] == ["/v1/chat/completions", "/v1/responses", "/v1/messages"]
assert info["supported_modalities"] == ["text", "image", "video"]
assert info["supported_output_modalities"] == ["text"]
routed_model, provider, _, api_base = get_llm_provider(model=MUSE_SPARK_MODEL, api_key="sk-test")
assert routed_model == "muse-spark-1.1"
assert provider == "meta"
assert api_base == "https://api.meta.ai/v1"
def test_muse_spark_1_1_backup_matches_main():
"""Ensure the bundled model cost map stays in sync with the canonical file."""
repo_root = Path(__file__).parents[2]
main_path = repo_root / "model_prices_and_context_window.json"
backup_path = repo_root / "litellm" / "model_prices_and_context_window_backup.json"
with open(main_path) as f:
main_cost = json.load(f)
with open(backup_path) as f:
backup_cost = json.load(f)
assert backup_cost.get(MUSE_SPARK_MODEL) == main_cost.get(MUSE_SPARK_MODEL), (
f"{MUSE_SPARK_MODEL} differs between main and backup model cost maps"
)

View file

@ -874,6 +874,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"/v1/embeddings",
"/v1/chat/completions",
"/v1/completions",
"/v1/messages",
"/v1/images/generations",
"/v1/realtime",
"/v1/realtime/transcription_sessions",
@ -884,7 +885,6 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"/v1/audio/speech",
"/v1/ocr",
"/vertex_ai/live",
"/v1/realtime/transcription_sessions",
],
},
},

View file

@ -1,6 +1,6 @@
{
"@typescript-eslint/no-explicit-any": 1980,
"complexity": 128,
"@typescript-eslint/no-explicit-any": 1977,
"complexity": 129,
"local/no-large-inline-object-arg": 509,
"local/no-long-condition-chain": 233,
"max-depth": 59,

View file

@ -4,32 +4,40 @@
"count": 1
}
},
"src/app/(dashboard)/api-reference/APIReferenceView.tsx": {
"src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/api-reference/_components/APIReferenceView.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/budgets/components/budget_modal.tsx": {
"src/app/(dashboard)/budgets/_components/budget_modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/budgets/components/budget_panel.test.tsx": {
"src/app/(dashboard)/budgets/_components/budget_panel.test.tsx": {
"unused-imports/no-unused-imports": {
"count": 2
}
},
"src/app/(dashboard)/budgets/components/budget_panel.tsx": {
"src/app/(dashboard)/budgets/_components/budget_panel.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/budgets/components/edit_budget_modal.tsx": {
"src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/caching/components/cache_dashboard.tsx": {
"src/app/(dashboard)/caching/_components/cache_dashboard.tsx": {
"no-restricted-imports": {
"count": 1
},
@ -40,17 +48,17 @@
"count": 2
}
},
"src/app/(dashboard)/caching/components/cache_health.tsx": {
"src/app/(dashboard)/caching/_components/cache_health.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/caching/components/cache_settings/RedisTypeSelector.tsx": {
"src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/caching/components/cache_settings/index.tsx": {
"src/app/(dashboard)/caching/_components/cache_settings/index.tsx": {
"no-restricted-imports": {
"count": 1
},
@ -58,17 +66,17 @@
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/components/add_margin_form.tsx": {
"src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/components/add_provider_form.tsx": {
"src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/components/cost_tracking_settings.tsx": {
"src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx": {
"no-nested-ternary": {
"count": 2
},
@ -76,96 +84,86 @@
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/components/how_it_works.tsx": {
"src/app/(dashboard)/cost-tracking/_components/how_it_works.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_cost_results.test.tsx": {
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx": {
"unused-imports/no-unused-imports": {
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_cost_results.tsx": {
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_export_dropdown.test.tsx": {
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.test.tsx": {
"unused-imports/no-unused-imports": {
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_export_dropdown.tsx": {
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/components/pricing_calculator/use_multi_cost_estimate.ts": {
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/use_multi_cost_estimate.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/components/provider_discount_table.test.tsx": {
"src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx": {
"unused-imports/no-unused-imports": {
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/components/provider_discount_table.tsx": {
"src/app/(dashboard)/cost-tracking/_components/provider_discount_table.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/components/provider_display_helpers.test.ts": {
"src/app/(dashboard)/cost-tracking/_components/provider_display_helpers.test.ts": {
"unused-imports/no-unused-imports": {
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/components/provider_margin_table.tsx": {
"src/app/(dashboard)/cost-tracking/_components/provider_margin_table.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/components/use_discount_config.ts": {
"src/app/(dashboard)/cost-tracking/_components/use_discount_config.ts": {
"no-restricted-syntax": {
"count": 2
}
},
"src/app/(dashboard)/cost-tracking/components/use_margin_config.ts": {
"src/app/(dashboard)/cost-tracking/_components/use_margin_config.ts": {
"no-restricted-syntax": {
"count": 2
}
},
"src/app/(dashboard)/guardrails-monitor/components/EvaluationSettingsModal.tsx": {
"src/app/(dashboard)/guardrails-monitor/_components/EvaluationSettingsModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/guardrails-monitor/components/GuardrailDetail.tsx": {
"src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx": {
"no-nested-ternary": {
"count": 3
}
},
"src/app/(dashboard)/guardrails-monitor/components/GuardrailsMonitorView.tsx": {
"src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/guardrails-monitor/components/GuardrailsOverview.tsx": {
"src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx": {
"no-nested-ternary": {
"count": 8
}
},
"src/app/(dashboard)/guardrails-monitor/components/ScoreChart.test.tsx": {
"react/display-name": {
"count": 1
}
},
"src/app/(dashboard)/guardrails-monitor/components/ScoreChart.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails.ts": {
"no-restricted-syntax": {
"count": 1
@ -326,7 +324,7 @@
"count": 2
}
},
"src/app/(dashboard)/memory/components/MemoryView.tsx": {
"src/app/(dashboard)/memory/_components/MemoryView.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -373,6 +371,22 @@
"count": 1
}
},
"src/app/(dashboard)/old-usage/_components/usage.tsx": {
"no-restricted-imports": {
"count": 2
},
"react-hooks/immutability": {
"count": 1
},
"react-hooks/purity": {
"count": 1
}
},
"src/app/(dashboard)/organizations/_components/organizations.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx": {
"no-restricted-imports": {
"count": 1
@ -522,7 +536,7 @@
"count": 1
}
},
"src/app/(dashboard)/projects/components/ProjectDetailsPage.tsx": {
"src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx": {
"no-nested-ternary": {
"count": 3
},
@ -530,27 +544,27 @@
"count": 1
}
},
"src/app/(dashboard)/projects/components/ProjectKeysSection.tsx": {
"src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/projects/components/ProjectModals/ProjectBaseForm.tsx": {
"src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/projects/components/ProjectsPage.tsx": {
"src/app/(dashboard)/projects/_components/ProjectsPage.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/prompts/components/add_prompt_form.tsx": {
"src/app/(dashboard)/prompts/_components/add_prompt_form.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/prompts/components/index.tsx": {
"src/app/(dashboard)/prompts/_components/index.tsx": {
"no-nested-ternary": {
"count": 1
},
@ -561,17 +575,17 @@
"count": 1
}
},
"src/app/(dashboard)/prompts/components/prompt_editor_view/DeveloperMessageCard.tsx": {
"src/app/(dashboard)/prompts/_components/prompt_editor_view/DeveloperMessageCard.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/prompts/components/prompt_editor_view/ModelConfigCard.tsx": {
"src/app/(dashboard)/prompts/_components/prompt_editor_view/ModelConfigCard.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/prompts/components/prompt_editor_view/PromptCodeSnippets.tsx": {
"src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptCodeSnippets.tsx": {
"no-nested-ternary": {
"count": 1
},
@ -582,32 +596,32 @@
"count": 1
}
},
"src/app/(dashboard)/prompts/components/prompt_editor_view/PromptEditorHeader.tsx": {
"src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptEditorHeader.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/prompts/components/prompt_editor_view/PromptMessagesCard.tsx": {
"src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptMessagesCard.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/prompts/components/prompt_editor_view/PublishModal.tsx": {
"src/app/(dashboard)/prompts/_components/prompt_editor_view/PublishModal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/prompts/components/prompt_editor_view/ToolsCard.tsx": {
"src/app/(dashboard)/prompts/_components/prompt_editor_view/ToolsCard.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/prompts/components/prompt_editor_view/VersionHistorySidePanel.test.tsx": {
"src/app/(dashboard)/prompts/_components/prompt_editor_view/VersionHistorySidePanel.test.tsx": {
"max-nested-callbacks": {
"count": 1
}
},
"src/app/(dashboard)/prompts/components/prompt_editor_view/VersionHistorySidePanel.tsx": {
"src/app/(dashboard)/prompts/_components/prompt_editor_view/VersionHistorySidePanel.tsx": {
"no-nested-ternary": {
"count": 1
},
@ -615,22 +629,22 @@
"count": 1
}
},
"src/app/(dashboard)/prompts/components/prompt_editor_view/conversation_panel/MessageInput.tsx": {
"src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/MessageInput.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/prompts/components/prompt_editor_view/conversation_panel/index.tsx": {
"src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/prompts/components/prompt_editor_view/conversation_panel/useConversation.ts": {
"src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/useConversation.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"src/app/(dashboard)/prompts/components/prompt_info.tsx": {
"src/app/(dashboard)/prompts/_components/prompt_info.tsx": {
"no-nested-ternary": {
"count": 3
},
@ -641,7 +655,7 @@
"count": 2
}
},
"src/app/(dashboard)/prompts/components/prompt_table.tsx": {
"src/app/(dashboard)/prompts/_components/prompt_table.tsx": {
"no-nested-ternary": {
"count": 1
},
@ -649,6 +663,14 @@
"count": 1
}
},
"src/app/(dashboard)/router-settings/_components/general_settings.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx": {
"no-restricted-imports": {
"count": 1
@ -851,14 +873,6 @@
"count": 1
}
},
"src/components/AdminPanel.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/CreateUserButton.tsx": {
"no-restricted-imports": {
"count": 1
@ -1247,7 +1261,7 @@
"count": 1
}
},
"src/components/agents.tsx": {
"src/app/(dashboard)/agents/_components/index.tsx": {
"no-restricted-imports": {
"count": 1
},
@ -1255,7 +1269,7 @@
"count": 1
}
},
"src/components/agents/add_agent_form.tsx": {
"src/app/(dashboard)/agents/_components/add_agent_form.tsx": {
"no-nested-ternary": {
"count": 3
},
@ -1269,7 +1283,7 @@
"count": 1
}
},
"src/components/agents/agent_card_discovery.tsx": {
"src/app/(dashboard)/agents/_components/agent_card_discovery.tsx": {
"react-hooks/refs": {
"count": 3
},
@ -1277,17 +1291,17 @@
"count": 1
}
},
"src/components/agents/agent_cost_view.tsx": {
"src/app/(dashboard)/agents/_components/agent_cost_view.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/agents/agent_form_fields.tsx": {
"src/app/(dashboard)/agents/_components/agent_form_fields.tsx": {
"no-nested-ternary": {
"count": 1
}
},
"src/components/agents/agent_info.tsx": {
"src/app/(dashboard)/agents/_components/agent_info.tsx": {
"no-nested-ternary": {
"count": 1
},
@ -1298,12 +1312,12 @@
"count": 1
}
},
"src/components/agents/agent_virtual_keys.tsx": {
"src/app/(dashboard)/agents/_components/agent_virtual_keys.tsx": {
"no-nested-ternary": {
"count": 1
}
},
"src/components/agents/dynamic_agent_form_fields.tsx": {
"src/app/(dashboard)/agents/_components/dynamic_agent_form_fields.tsx": {
"no-nested-ternary": {
"count": 2
}
@ -1520,35 +1534,27 @@
"count": 1
}
},
"src/components/general_settings.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 2
}
},
"src/components/guardrails.tsx": {
"src/app/(dashboard)/guardrails/_components/index.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/guardrails/GuardrailTestPanel.tsx": {
"src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/guardrails/GuardrailTestPlayground.tsx": {
"src/app/(dashboard)/guardrails/_components/GuardrailTestPlayground.tsx": {
"no-nested-ternary": {
"count": 1
}
},
"src/components/guardrails/GuardrailTestResults.tsx": {
"src/app/(dashboard)/guardrails/_components/GuardrailTestResults.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/guardrails/TeamGuardrailsTab.tsx": {
"src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx": {
"no-nested-ternary": {
"count": 2
},
@ -1556,7 +1562,7 @@
"count": 1
}
},
"src/components/guardrails/add_guardrail_form.tsx": {
"src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx": {
"no-nested-ternary": {
"count": 4
},
@ -1567,7 +1573,7 @@
"count": 2
}
},
"src/components/guardrails/content_filter/CompetitorIntentConfiguration.tsx": {
"src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx": {
"no-nested-ternary": {
"count": 1
},
@ -1575,7 +1581,7 @@
"count": 1
}
},
"src/components/guardrails/content_filter/ContentCategoryConfiguration.tsx": {
"src/app/(dashboard)/guardrails/_components/content_filter/ContentCategoryConfiguration.tsx": {
"no-nested-ternary": {
"count": 3
},
@ -1583,12 +1589,12 @@
"count": 1
}
},
"src/components/guardrails/content_filter/ContentFilterDisplay.tsx": {
"src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/guardrails/content_filter/ContentFilterManager.tsx": {
"src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.tsx": {
"max-params": {
"count": 2
},
@ -1596,7 +1602,7 @@
"count": 1
}
},
"src/components/guardrails/custom_code/CustomCodeModal.tsx": {
"src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx": {
"no-nested-ternary": {
"count": 6
},
@ -1607,7 +1613,7 @@
"count": 1
}
},
"src/components/guardrails/edit_guardrail_form.tsx": {
"src/app/(dashboard)/guardrails/_components/edit_guardrail_form.tsx": {
"no-restricted-imports": {
"count": 1
},
@ -1618,7 +1624,7 @@
"count": 1
}
},
"src/components/guardrails/guardrail_info.tsx": {
"src/app/(dashboard)/guardrails/_components/guardrail_info.tsx": {
"max-params": {
"count": 1
},
@ -1629,7 +1635,7 @@
"count": 3
}
},
"src/components/guardrails/guardrail_optional_params.tsx": {
"src/app/(dashboard)/guardrails/_components/guardrail_optional_params.tsx": {
"no-nested-ternary": {
"count": 5
},
@ -1637,7 +1643,7 @@
"count": 1
}
},
"src/components/guardrails/guardrail_provider_fields.tsx": {
"src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx": {
"no-nested-ternary": {
"count": 5
},
@ -1645,7 +1651,7 @@
"count": 1
}
},
"src/components/guardrails/guardrail_table.tsx": {
"src/app/(dashboard)/guardrails/_components/guardrail_table.tsx": {
"no-nested-ternary": {
"count": 1
},
@ -1653,7 +1659,7 @@
"count": 1
}
},
"src/components/guardrails/tool_permission/ToolPermissionRulesEditor.tsx": {
"src/app/(dashboard)/guardrails/_components/tool_permission/ToolPermissionRulesEditor.tsx": {
"no-restricted-imports": {
"count": 1
},
@ -2028,11 +2034,6 @@
"count": 1
}
},
"src/components/organizations.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/page_utils.test.ts": {
"max-nested-callbacks": {
"count": 3
@ -2079,7 +2080,7 @@
"count": 1
}
},
"src/components/policies/add_attachment_form.tsx": {
"src/app/(dashboard)/policies/_components/add_attachment_form.tsx": {
"no-restricted-imports": {
"count": 1
},
@ -2087,7 +2088,7 @@
"count": 1
}
},
"src/components/policies/add_policy_form.tsx": {
"src/app/(dashboard)/policies/_components/add_policy_form.tsx": {
"no-restricted-imports": {
"count": 1
},
@ -2098,7 +2099,7 @@
"count": 1
}
},
"src/components/policies/ai_suggestion_modal.tsx": {
"src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx": {
"no-nested-ternary": {
"count": 10
},
@ -2109,12 +2110,12 @@
"count": 1
}
},
"src/components/policies/attachment_table.test.tsx": {
"src/app/(dashboard)/policies/_components/attachment_table.test.tsx": {
"react/display-name": {
"count": 1
}
},
"src/components/policies/attachment_table.tsx": {
"src/app/(dashboard)/policies/_components/attachment_table.tsx": {
"no-nested-ternary": {
"count": 1
},
@ -2122,7 +2123,7 @@
"count": 1
}
},
"src/components/policies/guardrail_selection_modal.tsx": {
"src/app/(dashboard)/policies/_components/guardrail_selection_modal.tsx": {
"no-nested-ternary": {
"count": 1
},
@ -2130,12 +2131,12 @@
"count": 1
}
},
"src/components/policies/impact_popover.test.tsx": {
"src/app/(dashboard)/policies/_components/impact_popover.test.tsx": {
"react/display-name": {
"count": 1
}
},
"src/components/policies/impact_popover.tsx": {
"src/app/(dashboard)/policies/_components/impact_popover.tsx": {
"no-nested-ternary": {
"count": 1
},
@ -2143,12 +2144,12 @@
"count": 1
}
},
"src/components/policies/index.test.tsx": {
"src/app/(dashboard)/policies/_components/index.test.tsx": {
"react/display-name": {
"count": 1
}
},
"src/components/policies/index.tsx": {
"src/app/(dashboard)/policies/_components/index.tsx": {
"no-restricted-imports": {
"count": 1
},
@ -2156,7 +2157,7 @@
"count": 1
}
},
"src/components/policies/pipeline_flow_builder.tsx": {
"src/app/(dashboard)/policies/_components/pipeline_flow_builder.tsx": {
"no-nested-ternary": {
"count": 1
},
@ -2167,7 +2168,7 @@
"count": 2
}
},
"src/components/policies/policy_info.tsx": {
"src/app/(dashboard)/policies/_components/policy_info.tsx": {
"no-restricted-imports": {
"count": 1
},
@ -2175,12 +2176,12 @@
"count": 1
}
},
"src/components/policies/policy_table.test.tsx": {
"src/app/(dashboard)/policies/_components/policy_table.test.tsx": {
"react/display-name": {
"count": 1
}
},
"src/components/policies/policy_table.tsx": {
"src/app/(dashboard)/policies/_components/policy_table.tsx": {
"no-nested-ternary": {
"count": 2
},
@ -2188,7 +2189,7 @@
"count": 1
}
},
"src/components/policies/policy_test_panel.tsx": {
"src/app/(dashboard)/policies/_components/policy_test_panel.tsx": {
"no-restricted-imports": {
"count": 1
},
@ -2196,7 +2197,7 @@
"count": 1
}
},
"src/components/policies/template_parameter_modal.tsx": {
"src/app/(dashboard)/policies/_components/template_parameter_modal.tsx": {
"no-restricted-imports": {
"count": 1
},
@ -2271,17 +2272,17 @@
"count": 1
}
},
"src/components/tag_management/TagTable.tsx": {
"src/app/(dashboard)/tag-management/_components/TagTable.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/tag_management/components/CreateTagModal.tsx": {
"src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/tag_management/index.tsx": {
"src/app/(dashboard)/tag-management/_components/index.tsx": {
"no-restricted-imports": {
"count": 1
},
@ -2289,7 +2290,7 @@
"count": 1
}
},
"src/components/tag_management/tag_info.tsx": {
"src/app/(dashboard)/tag-management/_components/tag_info.tsx": {
"no-restricted-imports": {
"count": 1
},
@ -2371,17 +2372,6 @@
"count": 1
}
},
"src/components/usage.tsx": {
"no-restricted-imports": {
"count": 2
},
"react-hooks/immutability": {
"count": 1
},
"react-hooks/purity": {
"count": 1
}
},
"src/components/user_agent_activity.tsx": {
"no-restricted-imports": {
"count": 2
@ -2398,12 +2388,12 @@
"count": 2
}
},
"src/components/vector_store_management/CreateVectorStore.tsx": {
"src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/vector_store_management/VectorStoreForm.tsx": {
"src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx": {
"no-nested-ternary": {
"count": 2
},
@ -2414,12 +2404,12 @@
"count": 1
}
},
"src/components/vector_store_management/VectorStoreTable.tsx": {
"src/app/(dashboard)/vector-stores/_components/VectorStoreTable.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/vector_store_management/index.tsx": {
"src/app/(dashboard)/vector-stores/_components/index.tsx": {
"no-restricted-imports": {
"count": 1
},
@ -2427,7 +2417,7 @@
"count": 1
}
},
"src/components/vector_store_management/vector_store_info.tsx": {
"src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx": {
"no-restricted-imports": {
"count": 1
},

View file

@ -34,6 +34,7 @@
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-syntax-highlighter": "15.6.6",
"recharts": "3.9.2",
"remark-gfm": "4.0.1",
"tailwind-merge": "3.4.0",
"uuid": "14.0.0"
@ -2927,6 +2928,32 @@
"npm": ">=9.5.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
@ -3284,6 +3311,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -3804,6 +3843,42 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/@tremor/react/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/@tremor/react/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/@tremor/react/node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tremor/react/node_modules/tailwind-merge": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
@ -3814,6 +3889,28 @@
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/@tremor/react/node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@ -4069,6 +4166,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz",
@ -6309,6 +6412,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.49.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@ -6872,9 +6985,9 @@
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/expect-type": {
@ -6901,9 +7014,9 @@
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz",
"integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@ -7688,6 +7801,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "11.1.11",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz",
"integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -11589,7 +11712,6 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/react-json-view-lite": {
@ -11631,6 +11753,29 @@
"react": ">=18"
}
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
@ -11707,26 +11852,33 @@
}
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz",
"integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=14"
"node": ">=18"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
@ -11738,12 +11890,6 @@
"decimal.js-light": "^2.4.1"
}
},
"node_modules/recharts/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@ -11758,6 +11904,21 @@
"node": ">=8"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -13429,9 +13590,9 @@
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",

View file

@ -50,6 +50,7 @@
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-syntax-highlighter": "15.6.6",
"recharts": "3.9.2",
"remark-gfm": "4.0.1",
"tailwind-merge": "3.4.0",
"uuid": "14.0.0"

View file

@ -1,6 +1,6 @@
"use client";
import { AccessGroupsPage } from "./components/AccessGroupsPage";
import { AccessGroupsPage } from "./_components/AccessGroupsPage";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
export default function AccessGroups() {

View file

@ -8,34 +8,34 @@ const mockGetAllowedIPs = vi.fn();
const mockAddAllowedIP = vi.fn();
const mockDeleteAllowedIP = vi.fn();
vi.mock("./networking", () => ({
vi.mock("@/components/networking", () => ({
getSSOSettings: (...args: unknown[]) => mockGetSSOSettings(...args),
getAllowedIPs: (...args: unknown[]) => mockGetAllowedIPs(...args),
addAllowedIP: (...args: unknown[]) => mockAddAllowedIP(...args),
deleteAllowedIP: (...args: unknown[]) => mockDeleteAllowedIP(...args),
}));
vi.mock("./constants", () => ({
vi.mock("@/components/constants", () => ({
useBaseUrl: () => "http://localhost:4000",
}));
vi.mock("./Settings/AdminSettings/SSOSettings/SSOSettings", () => ({
vi.mock("@/components/Settings/AdminSettings/SSOSettings/SSOSettings", () => ({
default: () => <div>SSO Settings</div>,
}));
vi.mock("./Settings/AdminSettings/UISettings/UISettings", () => ({
vi.mock("@/components/Settings/AdminSettings/UISettings/UISettings", () => ({
default: () => <div>UI Settings</div>,
}));
vi.mock("./SCIM", () => ({
vi.mock("@/components/SCIM", () => ({
default: () => <div>SCIM Config</div>,
}));
vi.mock("./SSOModals", () => ({
vi.mock("@/components/SSOModals", () => ({
default: () => <div>SSO Modals</div>,
}));
vi.mock("./UIAccessControlForm", () => ({
vi.mock("@/components/UIAccessControlForm", () => ({
default: () => <div>UI Access Control Form</div>,
}));

View file

@ -16,18 +16,18 @@ import {
} from "@tremor/react";
import { Alert, Button as Button2, Form, Input, Modal, Space, Tabs, Typography } from "antd";
import React, { useEffect, useState } from "react";
import NewBadge from "./common_components/NewBadge";
import { useBaseUrl } from "./constants";
import NotificationsManager from "./molecules/notifications_manager";
import { addAllowedIP, deleteAllowedIP, getAllowedIPs, getSSOSettings } from "./networking";
import SCIMConfig from "./SCIM";
import LoggingSettings from "./Settings/AdminSettings/LoggingSettings/LoggingSettings";
import SSOSettings from "./Settings/AdminSettings/SSOSettings/SSOSettings";
import UISettings from "./Settings/AdminSettings/UISettings/UISettings";
import HashicorpVault from "./Settings/AdminSettings/HashicorpVault/HashicorpVault";
import PluginSettings from "./Settings/AdminSettings/PluginSettings/PluginSettings";
import SSOModals from "./SSOModals";
import UIAccessControlForm from "./UIAccessControlForm";
import NewBadge from "@/components/common_components/NewBadge";
import { useBaseUrl } from "@/components/constants";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { addAllowedIP, deleteAllowedIP, getAllowedIPs, getSSOSettings } from "@/components/networking";
import SCIMConfig from "@/components/SCIM";
import LoggingSettings from "@/components/Settings/AdminSettings/LoggingSettings/LoggingSettings";
import SSOSettings from "@/components/Settings/AdminSettings/SSOSettings/SSOSettings";
import UISettings from "@/components/Settings/AdminSettings/UISettings/UISettings";
import HashicorpVault from "@/components/Settings/AdminSettings/HashicorpVault/HashicorpVault";
import PluginSettings from "@/components/Settings/AdminSettings/PluginSettings/PluginSettings";
import SSOModals from "@/components/SSOModals";
import UIAccessControlForm from "@/components/UIAccessControlForm";
const { Title, Paragraph, Text } = Typography;

View file

@ -1,6 +1,6 @@
"use client";
import AdminPanel from "@/components/AdminPanel";
import AdminPanel from "./_components/AdminPanel";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";

View file

@ -4,7 +4,7 @@ import MessageManager from "@/components/molecules/message_manager";
import { resolveLogoSrc } from "@/lib/assetPaths";
import { Button } from "@tremor/react";
import { CheckCircleFilled, KeyOutlined, RobotOutlined, AppstoreOutlined, InfoCircleOutlined } from "@ant-design/icons";
import CreatedKeyDisplay from "../shared/CreatedKeyDisplay";
import CreatedKeyDisplay from "@/components/shared/CreatedKeyDisplay";
import {
createAgentCall,
getAgentCreateMetadata,
@ -14,19 +14,19 @@ import {
keyUpdateCall,
modelAvailableCall,
AgentCreateInfo,
} from "../networking";
} from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
import { Team } from "../key_team_helpers/key_list";
import TeamDropdown from "../common_components/team_dropdown";
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
import { Team } from "@/components/key_team_helpers/key_list";
import TeamDropdown from "@/components/common_components/team_dropdown";
import AgentFormFields from "./agent_form_fields";
import AgentCardDiscovery, { DiscoveredAgentCardSelection } from "./agent_card_discovery";
import { buildDiscoveryRequest, overlayDiscoveredCardParams } from "./agent_discovery_utils";
import DynamicAgentFormFields, { buildDynamicAgentData } from "./dynamic_agent_form_fields";
import { getDefaultFormValues, buildAgentDataFromForm } from "./agent_config";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
import GuardrailSelector from "../guardrails/GuardrailSelector";
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions";
import GuardrailSelector from "@/components/guardrails/GuardrailSelector";
const { Step } = Steps;

View file

@ -2,18 +2,18 @@ import React from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../../tests/test-utils";
import { renderWithProviders } from "@/../tests/test-utils";
import AgentCardDiscovery from "./agent_card_discovery";
vi.mock("../networking", async () => {
const actual = await vi.importActual<any>("../networking");
vi.mock("@/components/networking", async () => {
const actual = await vi.importActual<any>("@/components/networking");
return {
...actual,
discoverAgentCardCall: vi.fn(),
};
});
import { discoverAgentCardCall } from "../networking";
import { discoverAgentCardCall } from "@/components/networking";
const mockDiscover = discoverAgentCardCall as unknown as ReturnType<typeof vi.fn>;

View file

@ -11,7 +11,7 @@ import {
SearchOutlined,
} from "@ant-design/icons";
import { DiscoveredAgentCard, discoverAgentCardCall } from "../networking";
import { DiscoveredAgentCard, discoverAgentCardCall } from "@/components/networking";
import {
ALLOWED_CAPABILITY_KEYS,
selectionsFromSavedAgentCard,

View file

@ -1,7 +1,7 @@
import React from "react";
import { Title } from "@tremor/react";
import { Descriptions } from "antd";
import { Agent } from "./types";
import { Agent } from "@/components/agents/types";
interface AgentCostViewProps {
agent: Agent;

View file

@ -1,4 +1,4 @@
import { AgentCreateInfo, DiscoveredAgentCard, DiscoveryMode } from "../networking";
import { AgentCreateInfo, DiscoveredAgentCard, DiscoveryMode } from "@/components/networking";
export interface DiscoveryRequestPlan {
url: string;

View file

@ -3,11 +3,11 @@ import { Card, Title, Text, Button as TremorButton, Tab, TabGroup, TabList, TabP
import { Form, Input, InputNumber, Button as AntButton, Spin, Descriptions, Divider } from "antd";
import MessageManager from "@/components/molecules/message_manager";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { getAgentInfo, patchAgentCall, getAgentCreateMetadata, AgentCreateInfo } from "../networking";
import { Agent } from "./types";
import { KeyResponse } from "../key_team_helpers/key_list";
import { getAgentInfo, patchAgentCall, getAgentCreateMetadata, AgentCreateInfo } from "@/components/networking";
import { Agent } from "@/components/agents/types";
import { KeyResponse } from "@/components/key_team_helpers/key_list";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import KeyInfoView from "../templates/key_info_view";
import KeyInfoView from "@/components/templates/key_info_view";
import AgentVirtualKeys from "./agent_virtual_keys";
import AgentFormFields from "./agent_form_fields";
import DynamicAgentFormFields, { buildDynamicAgentData } from "./dynamic_agent_form_fields";

View file

@ -1,5 +1,5 @@
import { Agent } from "./types";
import { AgentCreateInfo } from "../networking";
import { Agent } from "@/components/agents/types";
import { AgentCreateInfo } from "@/components/networking";
/**
* Detects the agent type from an agent's litellm_params.

View file

@ -2,9 +2,9 @@ import React from "react";
import { describe, it, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../../tests/test-utils";
import { renderWithProviders } from "@/../tests/test-utils";
import AgentVirtualKeys from "./agent_virtual_keys";
import type { KeyResponse } from "../key_team_helpers/key_list";
import type { KeyResponse } from "@/components/key_team_helpers/key_list";
const makeKey = (overrides: Partial<KeyResponse>): KeyResponse =>
({

View file

@ -1,7 +1,7 @@
import React from "react";
import { Button, Tooltip, Typography } from "antd";
import { KeyOutlined } from "@ant-design/icons";
import { KeyResponse } from "../key_team_helpers/key_list";
import { KeyResponse } from "@/components/key_team_helpers/key_list";
const { Title, Text } = Typography;

View file

@ -1,6 +1,6 @@
import React from "react";
import { Form, Input, Select, Collapse } from "antd";
import { AgentCreateInfo, AgentCredentialFieldMetadata } from "../networking";
import { AgentCreateInfo, AgentCredentialFieldMetadata } from "@/components/networking";
import { AGENT_FORM_CONFIG } from "./agent_config";
import CostConfigFields from "./cost_config_fields";

View file

@ -1,19 +1,19 @@
import React from "react";
import { render, screen, waitFor, act, fireEvent, within } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import AgentsPanel from "./agents";
import * as networking from "./networking";
import AgentsPanel from "./index";
import * as networking from "@/components/networking";
vi.mock("./networking", () => ({
vi.mock("@/components/networking", () => ({
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
deleteAgentCall: vi.fn(),
}));
vi.mock("./agents/add_agent_form", () => ({
vi.mock("./add_agent_form", () => ({
default: () => <div data-testid="add-agent-form" />,
}));
vi.mock("./agents/agent_info", () => ({
vi.mock("./agent_info", () => ({
default: () => <div data-testid="agent-info" />,
}));

View file

@ -13,15 +13,15 @@ import {
} from "@tremor/react";
import { Modal, Alert, Tooltip, Skeleton, Switch } from "antd";
import { CheckCircleOutlined } from "@ant-design/icons";
import { getAgentsList, deleteAgentCall } from "./networking";
import AddAgentForm from "./agents/add_agent_form";
import { getAgentsList, deleteAgentCall } from "@/components/networking";
import AddAgentForm from "./add_agent_form";
import { isAdminRole } from "@/utils/roles";
import AgentInfoView from "./agents/agent_info";
import NotificationsManager from "./molecules/notifications_manager";
import { Agent } from "./agents/types";
import { Team } from "./key_team_helpers/key_list";
import AgentInfoView from "./agent_info";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { Agent } from "@/components/agents/types";
import { Team } from "@/components/key_team_helpers/key_list";
import { DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells";
import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
interface AgentsPanelProps {
accessToken: string | null;

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