mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/elegant-edison-bf44a2
This commit is contained in:
commit
94f27810fb
361 changed files with 7001 additions and 920 deletions
48
.github/actions/detect-backend-changes/action.yml
vendored
Normal file
48
.github/actions/detect-backend-changes/action.yml
vendored
Normal 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}"
|
||||
13
.github/workflows/_test-unit-base.yml
vendored
13
.github/workflows/_test-unit-base.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
7
.github/workflows/test-unit-proxy-legacy.yml
vendored
7
.github/workflows/test-unit-proxy-legacy.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "dcr_bridge" BOOLEAN;
|
||||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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``.
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) :])
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
228
tests/e2e/budgets/test_budget_reset_advances_e2e.py
Normal file
228
tests/e2e/budgets/test_budget_reset_advances_e2e.py
Normal 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"
|
||||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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), (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
163
tests/e2e/llm_translation/test_cache_control.py
Normal file
163
tests/e2e/llm_translation/test_cache_control.py
Normal 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)
|
||||
|
|
@ -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})"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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:])
|
||||
|
|
|
|||
224
tests/test_litellm/llms/openai_like/test_meta_provider.py
Normal file
224
tests/test_litellm/llms/openai_like/test_meta_provider.py
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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={},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
63
tests/test_litellm/test_muse_spark_1_1_model_metadata.py
Normal file
63
tests/test_litellm/test_muse_spark_1_1_model_metadata.py
Normal 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"
|
||||
)
|
||||
|
|
@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
221
ui/litellm-dashboard/package-lock.json
generated
221
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}));
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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>;
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { AgentCreateInfo, DiscoveredAgentCard, DiscoveryMode } from "../networking";
|
||||
import { AgentCreateInfo, DiscoveredAgentCard, DiscoveryMode } from "@/components/networking";
|
||||
|
||||
export interface DiscoveryRequestPlan {
|
||||
url: string;
|
||||
|
|
@ -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";
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 =>
|
||||
({
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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" />,
|
||||
}));
|
||||
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue