mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
Merge branch 'litellm_internal_staging' into feature/improve-gigachat-provider
This commit is contained in:
commit
b1a778cb86
137 changed files with 11429 additions and 2960 deletions
18
.github/workflows/image-scan.yml
vendored
18
.github/workflows/image-scan.yml
vendored
|
|
@ -9,6 +9,7 @@ on:
|
|||
- "litellm_**"
|
||||
paths:
|
||||
- docker/Dockerfile.non_root
|
||||
- tests/proxy_migration_tests/test_offline_image_migration.py
|
||||
- uv.lock
|
||||
- ui/litellm-dashboard/package-lock.json
|
||||
- .github/workflows/image-scan.yml
|
||||
|
|
@ -51,6 +52,23 @@ jobs:
|
|||
- name: Build runtime image
|
||||
run: docker build -f docker/Dockerfile.non_root -t litellm-image-scan:${{ github.sha }} .
|
||||
|
||||
# The prisma bake must migrate a fresh DB with no egress as an arbitrary
|
||||
# non-root uid (OpenShift restricted-v2 / air-gapped / readOnlyRootFilesystem).
|
||||
# `docker run` as the default uid with network hides a broken bake because
|
||||
# the migration entrypoint exits 0 even when it applied nothing; asserting
|
||||
# the schema was created is what catches it.
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Verify offline migration as a non-root uid
|
||||
env:
|
||||
LITELLM_IMAGE: litellm-image-scan:${{ github.sha }}
|
||||
run: |
|
||||
python -m pip install "pytest==9.0.3"
|
||||
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py -v
|
||||
|
||||
# Scans the whole shipped artifact: OS/apk plus every language package
|
||||
# baked into the image, including ones no lockfile declares (e.g. prisma's
|
||||
# vendored node engine) that osv-scan cannot see. osv-scan stays the fast
|
||||
|
|
|
|||
6
.github/workflows/test-litellm-ui-unit.yml
vendored
6
.github/workflows/test-litellm-ui-unit.yml
vendored
|
|
@ -19,7 +19,7 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
ui-unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-latest-16-cores
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
|
|
@ -50,8 +50,8 @@ jobs:
|
|||
if [ -n "$BASE_SHA" ]; then
|
||||
echo "Pull request: running only tests related to changes since $BASE_SHA"
|
||||
npm run test -- --run --changed "$BASE_SHA" --passWithNoTests \
|
||||
--pool forks --poolOptions.forks.maxForks=4
|
||||
--pool forks --poolOptions.forks.maxForks=14
|
||||
else
|
||||
echo "Push to $GITHUB_REF_NAME: running the full suite"
|
||||
npm run test -- --run --pool forks --poolOptions.forks.maxForks=4
|
||||
npm run test -- --run --pool forks --poolOptions.forks.maxForks=14
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ jobs:
|
|||
tests/test_litellm/proxy/rag_endpoints
|
||||
tests/test_litellm/proxy/realtime_endpoints
|
||||
tests/test_litellm/proxy/ui_crud_endpoints
|
||||
tests/test_litellm/proxy/config_resolvers
|
||||
tests/test_litellm/proxy/utils
|
||||
workers: 2
|
||||
reruns: 2
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
|
|||
"/team/",
|
||||
"/v2/team/",
|
||||
"/organization/",
|
||||
"/v2/organization/",
|
||||
"/customer/",
|
||||
"/end_user/",
|
||||
"/sso/",
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
|
|||
UV_LINK_MODE=copy \
|
||||
PATH="/app/.venv/bin:${PATH}" \
|
||||
LITELLM_NON_ROOT=true \
|
||||
PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \
|
||||
XDG_CACHE_HOME=/app/.cache
|
||||
|
||||
# Copy dependency metadata first for layer caching
|
||||
|
|
@ -106,7 +105,9 @@ RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \
|
|||
--python python3; \
|
||||
fi
|
||||
|
||||
RUN prisma generate --schema=./schema.prisma
|
||||
RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
|
||||
npm_config_cache=/root/.npm \
|
||||
prisma generate --schema=./schema.prisma
|
||||
|
||||
RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh && \
|
||||
sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh
|
||||
|
|
@ -127,8 +128,6 @@ RUN for i in 1 2 3; do \
|
|||
# the rest of the builder's /app is source and build metadata that must not
|
||||
# ship (manifest-scanning tools attribute everything in it to this image).
|
||||
# entrypoint.sh invokes litellm/proxy/prisma_migration.py by source path.
|
||||
# Prisma caches live under /app/.cache here (XDG_CACHE_HOME /
|
||||
# PRISMA_BINARY_CACHE_DIR) so the runtime prisma generate finds them.
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
COPY --from=builder /app/docker /app/docker
|
||||
COPY --from=builder /app/schema.prisma /app/schema.prisma
|
||||
|
|
@ -138,21 +137,35 @@ COPY --from=builder /app/litellm/proxy/prisma_migration.py /app/litellm/proxy/pr
|
|||
# enterprise.enterprise_hooks from it)
|
||||
COPY --from=builder /app/enterprise /app/enterprise
|
||||
COPY --from=builder /app/litellm-proxy-extras /app/litellm-proxy-extras
|
||||
COPY --from=builder /app/.cache /app/.cache
|
||||
# Prisma CLI + engines are baked under /opt/prisma, a fixed path every runtime
|
||||
# uid can read and that no cache volume mount shadows (unlike /app/.cache or
|
||||
# $HOME/.cache under readOnlyRootFilesystem + emptyDir or arbitrary-uid setups).
|
||||
# PRISMA_CLI_QUERY_ENGINE_TYPE=binary makes the CLI use the baked binary query
|
||||
# engine directly, so `prisma migrate deploy` on a fresh database needs no npm
|
||||
# and no network access; without it the CLI looks for the library engine, which
|
||||
# prisma stopped baking, and falls back to a download that fails offline or as a
|
||||
# non-writable uid (#33650, #24554).
|
||||
COPY --from=builder /opt/prisma /opt/prisma
|
||||
COPY --from=builder /var/lib/litellm/ui /var/lib/litellm/ui
|
||||
COPY --from=builder /var/lib/litellm/assets /var/lib/litellm/assets
|
||||
|
||||
# XDG_CACHE_HOME is intentionally left unset so it falls back to $HOME/.cache
|
||||
# (/app/.cache, writable by the runtime uid). The prisma bake at the read-only
|
||||
# /opt/prisma is anchored by PRISMA_BINARY_CACHE_DIR / PRISMA_CLI_PATH, so
|
||||
# nothing needs XDG to point there; pointing it at the read-only bake would
|
||||
# deny any XDG-aware library that writes a cache at runtime.
|
||||
ENV PATH="/app/.venv/bin:${PATH}" \
|
||||
PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \
|
||||
PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
|
||||
PRISMA_CLI_PATH=/opt/prisma/binaries/node_modules/.bin/prisma \
|
||||
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
|
||||
HOME=/app \
|
||||
LITELLM_NON_ROOT=true \
|
||||
XDG_CACHE_HOME=/app/.cache \
|
||||
PRISMA_SKIP_POSTINSTALL_GENERATE=1 \
|
||||
PRISMA_HIDE_UPDATE_MESSAGE=1 \
|
||||
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1 \
|
||||
PRISMA_OFFLINE_MODE=true
|
||||
|
||||
RUN mkdir -p /nonexistent /var/lib/litellm/assets /var/lib/litellm/ui && \
|
||||
RUN mkdir -p /nonexistent /app/.cache /var/lib/litellm/assets /var/lib/litellm/ui && \
|
||||
chown -R nobody:nogroup /app /var/lib/litellm/ui /var/lib/litellm/assets /nonexistent && \
|
||||
PRISMA_PATH=$(python -c "import os, prisma; print(os.path.dirname(prisma.__file__))") && \
|
||||
chown -R nobody:nogroup "$PRISMA_PATH" && \
|
||||
|
|
@ -165,12 +178,14 @@ RUN mkdir -p /nonexistent /var/lib/litellm/assets /var/lib/litellm/ui && \
|
|||
[ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chmod -R g=u "$LITELLM_PROXY_EXTRAS_PATH" || true && \
|
||||
chmod -R g+w "$PRISMA_PATH" /var/lib/litellm/ui /var/lib/litellm/assets && \
|
||||
[ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chmod -R g+w "$LITELLM_PROXY_EXTRAS_PATH" || true && \
|
||||
chmod -R g+rX "$PRISMA_PATH" /var/lib/litellm/ui /var/lib/litellm/assets /app/.cache
|
||||
chmod -R g+rX "$PRISMA_PATH" /var/lib/litellm/ui /var/lib/litellm/assets && \
|
||||
chmod -R a+rX /opt/prisma && \
|
||||
test -x /opt/prisma/binaries/node_modules/.bin/prisma && \
|
||||
test -f /opt/prisma/binaries/node_modules/prisma/build/index.js && \
|
||||
ls /opt/prisma/binaries/node_modules/@prisma/engines/query-engine-* >/dev/null 2>&1
|
||||
|
||||
USER 65534
|
||||
|
||||
RUN prisma generate --schema=./schema.prisma
|
||||
|
||||
EXPOSE 4000/tcp
|
||||
|
||||
ENTRYPOINT ["/app/docker/prod_entrypoint.sh"]
|
||||
|
|
|
|||
|
|
@ -9,9 +9,22 @@ duration_in_seconds is used in diff parts of the code base, example
|
|||
import re
|
||||
import time as time_module
|
||||
from datetime import datetime, time, timedelta, timezone, tzinfo
|
||||
from typing import Optional, Tuple
|
||||
from typing import Final, Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
||||
_BUDGET_DURATION_WORD_ALIASES: Final[dict[str, str]] = {
|
||||
"hourly": "1h",
|
||||
"daily": "24h",
|
||||
"weekly": "7d",
|
||||
"monthly": "30d",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_duration(duration: str) -> str:
|
||||
return _BUDGET_DURATION_WORD_ALIASES.get(duration.strip().lower(), duration)
|
||||
|
||||
|
||||
def _extract_from_regex(duration: str) -> Tuple[int, str]:
|
||||
match = re.match(r"(\d+)(mo|[smhdw]?)", duration)
|
||||
|
|
@ -48,7 +61,7 @@ def duration_in_seconds(duration: str) -> int:
|
|||
|
||||
Returns time in seconds till when budget needs to be reset
|
||||
"""
|
||||
value, unit = _extract_from_regex(duration=duration)
|
||||
value, unit = _extract_from_regex(duration=_normalize_duration(duration))
|
||||
|
||||
if unit == "s":
|
||||
return value
|
||||
|
|
@ -124,9 +137,13 @@ def get_next_standardized_reset_time(
|
|||
current_time, _ = _setup_timezone(current_time, timezone_str)
|
||||
|
||||
# Parse duration
|
||||
value, unit = _parse_duration(duration)
|
||||
value, unit = _parse_duration(_normalize_duration(duration))
|
||||
if value is None:
|
||||
# Fall back to default if format is invalid
|
||||
verbose_logger.warning(
|
||||
"Unrecognized budget_duration %r; falling back to a next-midnight reset. "
|
||||
"Use the <int><unit> format (e.g. '1h', '7d', '30d', '1mo').",
|
||||
duration,
|
||||
)
|
||||
return current_time.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
||||
|
||||
# Midnight of the current day in the specified timezone
|
||||
|
|
|
|||
|
|
@ -480,10 +480,21 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
"""
|
||||
Filter out unsupported fields from JSON schema for Anthropic's output_format API.
|
||||
|
||||
Anthropic's output_format doesn't support certain JSON schema properties:
|
||||
- maxItems/minItems: Not supported for array types
|
||||
- minimum/maximum: Not supported for numeric types
|
||||
- minLength/maxLength: Not supported for string types
|
||||
Anthropic's output_format doesn't support certain JSON schema properties.
|
||||
These are constraints that cannot be enforced by the constrained-decoding
|
||||
grammar Anthropic compiles the schema into, so the API rejects them with a
|
||||
400 ``invalid_request_error`` (e.g. "output_format.schema: For 'array' type,
|
||||
property 'uniqueItems' is not supported"):
|
||||
- maxItems/minItems/uniqueItems/contains/minContains/maxContains/prefixItems: array constraints
|
||||
- minimum/maximum/exclusiveMinimum/exclusiveMaximum/multipleOf: numeric constraints
|
||||
- minLength/maxLength: string constraints
|
||||
- minProperties/maxProperties/patternProperties/propertyNames: object constraints
|
||||
- dependentRequired/dependentSchemas/unevaluatedProperties: object constraints
|
||||
- if/then/else/not: conditional and negation keywords
|
||||
|
||||
``oneOf`` is also rejected ("Schema type 'oneOf' is not supported") and is
|
||||
rewritten to ``anyOf``, matching the Anthropic SDK. Unknown keywords are
|
||||
ignored by the API, so anything not listed here passes through untouched.
|
||||
|
||||
This mirrors the transformation done by the Anthropic Python SDK.
|
||||
See: https://platform.claude.com/docs/en/build-with-claude/structured-outputs#how-sdk-transformation-works
|
||||
|
|
@ -504,33 +515,53 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
|
||||
# All numeric/string/array constraints not supported by Anthropic
|
||||
unsupported_fields = {
|
||||
"maxItems",
|
||||
"minItems", # array constraints
|
||||
"minimum",
|
||||
"maximum", # numeric constraints
|
||||
"exclusiveMinimum",
|
||||
"exclusiveMaximum", # numeric constraints
|
||||
"minLength",
|
||||
"maxLength", # string constraints
|
||||
}
|
||||
|
||||
# Build description additions from removed constraints
|
||||
constraint_descriptions: list = []
|
||||
constraint_labels = {
|
||||
"minItems": "minimum number of items: {}",
|
||||
"maxItems": "maximum number of items: {}",
|
||||
"uniqueItems": "all array items must be unique",
|
||||
"contains": "array must contain an item matching: {}",
|
||||
"minContains": "minimum number of matching items: {}",
|
||||
"maxContains": "maximum number of matching items: {}",
|
||||
"prefixItems": "leading items must match, in order: {}",
|
||||
"minimum": "minimum value: {}",
|
||||
"maximum": "maximum value: {}",
|
||||
"exclusiveMinimum": "exclusive minimum value: {}",
|
||||
"exclusiveMaximum": "exclusive maximum value: {}",
|
||||
"multipleOf": "must be a multiple of {}",
|
||||
"minLength": "minimum length: {}",
|
||||
"maxLength": "maximum length: {}",
|
||||
"minProperties": "minimum number of properties: {}",
|
||||
"maxProperties": "maximum number of properties: {}",
|
||||
"patternProperties": "properties whose names match each pattern must satisfy: {}",
|
||||
"propertyNames": "property names must satisfy: {}",
|
||||
"dependentRequired": "dependent required properties: {}",
|
||||
"dependentSchemas": "dependent schemas: {}",
|
||||
"unevaluatedProperties": "unevaluated properties must satisfy: {}",
|
||||
"if": "conditional (if): {}",
|
||||
"then": "conditional (then): {}",
|
||||
"else": "conditional (else): {}",
|
||||
"not": "must not match: {}",
|
||||
}
|
||||
for field in unsupported_fields:
|
||||
if field in schema:
|
||||
constraint_descriptions.append(constraint_labels[field].format(schema[field]))
|
||||
unsupported_fields = set(constraint_labels)
|
||||
|
||||
# Build description additions from removed constraints. Iterating
|
||||
# constraint_labels (not the set) keeps the note order deterministic across
|
||||
# processes, so identical requests serialize identically regardless of
|
||||
# PYTHONHASHSEED and stay cache-friendly.
|
||||
constraint_descriptions: list = []
|
||||
for field, label in constraint_labels.items():
|
||||
if field not in schema:
|
||||
continue
|
||||
value = schema[field]
|
||||
# A falsy boolean constraint (e.g. ``uniqueItems: false``) imposes no
|
||||
# real requirement, so don't add a misleading advisory note for it.
|
||||
if isinstance(value, bool) and not value:
|
||||
continue
|
||||
# Sub-schema constraints (e.g. ``contains``) are serialized as JSON so
|
||||
# the advisory note preserves what the constraint actually required,
|
||||
# instead of just noting that it existed.
|
||||
note_value = json.dumps(value) if isinstance(value, (dict, list)) else value
|
||||
constraint_descriptions.append(label.format(note_value))
|
||||
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
|
|
@ -557,11 +588,17 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
elif key == "$defs" and isinstance(value, dict):
|
||||
result[key] = {k: AnthropicConfig.filter_anthropic_output_schema(v) for k, v in value.items()}
|
||||
elif key == "anyOf" and isinstance(value, list):
|
||||
result[key] = [AnthropicConfig.filter_anthropic_output_schema(item) for item in value]
|
||||
result["anyOf"] = result.get("anyOf", []) + [
|
||||
AnthropicConfig.filter_anthropic_output_schema(item) for item in value
|
||||
]
|
||||
elif key == "allOf" and isinstance(value, list):
|
||||
result[key] = [AnthropicConfig.filter_anthropic_output_schema(item) for item in value]
|
||||
elif key == "oneOf" and isinstance(value, list):
|
||||
result[key] = [AnthropicConfig.filter_anthropic_output_schema(item) for item in value]
|
||||
# Anthropic rejects oneOf ("Schema type 'oneOf' is not supported");
|
||||
# the Anthropic SDK rewrites it to anyOf, so do the same.
|
||||
result["anyOf"] = result.get("anyOf", []) + [
|
||||
AnthropicConfig.filter_anthropic_output_schema(item) for item in value
|
||||
]
|
||||
else:
|
||||
result[key] = value
|
||||
|
||||
|
|
|
|||
|
|
@ -895,9 +895,8 @@ class AmazonConverseConfig(BaseConfig):
|
|||
if _tool_choice_value is not None:
|
||||
optional_params["tool_choice"] = _tool_choice_value
|
||||
if param == "parallel_tool_calls":
|
||||
disable_parallel = not value
|
||||
optional_params["_parallel_tool_use_config"] = {
|
||||
"tool_choice": {"disable_parallel_tool_use": disable_parallel}
|
||||
"tool_choice": {"type": "auto", "disable_parallel_tool_use": not value}
|
||||
}
|
||||
if param == "thinking":
|
||||
if (
|
||||
|
|
@ -1208,6 +1207,22 @@ class AmazonConverseConfig(BaseConfig):
|
|||
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _merge_parallel_tool_use_config(additional_request_params: dict, parallel_tool_use_config: dict) -> dict:
|
||||
merged_entries = {
|
||||
key: (
|
||||
{
|
||||
**value,
|
||||
**additional_request_params[key],
|
||||
**{k: v for k, v in value.items() if k != "type"},
|
||||
}
|
||||
if isinstance(additional_request_params.get(key), dict) and isinstance(value, dict)
|
||||
else value
|
||||
)
|
||||
for key, value in parallel_tool_use_config.items()
|
||||
}
|
||||
return {**additional_request_params, **merged_entries}
|
||||
|
||||
def _prepare_request_params(
|
||||
self, optional_params: dict, model: str, drop_params: bool = False
|
||||
) -> Tuple[dict, dict, dict, Optional[OutputConfigBlock]]:
|
||||
|
|
@ -1276,15 +1291,9 @@ class AmazonConverseConfig(BaseConfig):
|
|||
# Handle parallel_tool_calls configuration
|
||||
parallel_tool_use_config = additional_request_params.pop("_parallel_tool_use_config", None)
|
||||
if parallel_tool_use_config is not None and bedrock_converse_supports_parallel_tool_use_config(model):
|
||||
for key, value in parallel_tool_use_config.items():
|
||||
if (
|
||||
key in additional_request_params
|
||||
and isinstance(additional_request_params[key], dict)
|
||||
and isinstance(value, dict)
|
||||
):
|
||||
additional_request_params[key].update(value)
|
||||
else:
|
||||
additional_request_params[key] = value
|
||||
additional_request_params = self._merge_parallel_tool_use_config(
|
||||
additional_request_params, parallel_tool_use_config
|
||||
)
|
||||
|
||||
additional_request_params.pop("parallel_tool_calls", None)
|
||||
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ class SagemakerChatConfig(OpenAIGPTConfig, BaseAWSLLM):
|
|||
raise SagemakerError(status_code=response.status_code, message=response.text)
|
||||
|
||||
custom_stream_decoder = AWSEventStreamDecoder(model="", is_messages_api=True)
|
||||
completion_stream = custom_stream_decoder.iter_bytes(response.iter_bytes(chunk_size=1024))
|
||||
completion_stream = custom_stream_decoder.iter_bytes(response.iter_bytes())
|
||||
|
||||
streaming_response = CustomStreamWrapper(
|
||||
completion_stream=completion_stream,
|
||||
|
|
@ -189,7 +189,7 @@ class SagemakerChatConfig(OpenAIGPTConfig, BaseAWSLLM):
|
|||
raise SagemakerError(status_code=response.status_code, message=response.text)
|
||||
|
||||
custom_stream_decoder = AWSEventStreamDecoder(model="", is_messages_api=True)
|
||||
completion_stream = custom_stream_decoder.aiter_bytes(response.aiter_bytes(chunk_size=1024))
|
||||
completion_stream = custom_stream_decoder.aiter_bytes(response.aiter_bytes())
|
||||
|
||||
streaming_response = CustomStreamWrapper(
|
||||
completion_stream=completion_stream,
|
||||
|
|
|
|||
|
|
@ -200,23 +200,12 @@ class SagemakerLLM(BaseAWSLLM):
|
|||
# Add model_id as InferenceComponentName header
|
||||
# boto3 doc: https://docs.aws.amazon.com/sagemaker/latest/APIReference/API_runtime_InvokeEndpoint.html
|
||||
prepared_request.headers.update({"X-Amzn-SageMaker-Inference-Component": model_id})
|
||||
sync_handler = _get_httpx_client()
|
||||
sync_response = sync_handler.post(
|
||||
url=prepared_request.url,
|
||||
completion_stream = self.make_sync_call(
|
||||
api_base=prepared_request.url,
|
||||
headers=prepared_request.headers, # type: ignore
|
||||
data=prepared_request.body,
|
||||
stream=stream,
|
||||
data=cast(str, prepared_request.body), # cast-ok: signed body is a JSON str, mirrors async path
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
if sync_response.status_code != 200:
|
||||
raise SagemakerError(
|
||||
status_code=sync_response.status_code,
|
||||
message=str(sync_response.read()),
|
||||
)
|
||||
|
||||
decoder = AWSEventStreamDecoder(model="")
|
||||
|
||||
completion_stream = decoder.iter_bytes(sync_response.iter_bytes(chunk_size=1024))
|
||||
streaming_response = CustomStreamWrapper(
|
||||
completion_stream=completion_stream,
|
||||
model=model,
|
||||
|
|
@ -334,6 +323,29 @@ class SagemakerLLM(BaseAWSLLM):
|
|||
litellm_params=litellm_params,
|
||||
)
|
||||
|
||||
def make_sync_call(
|
||||
self,
|
||||
api_base: str,
|
||||
headers: dict,
|
||||
data: str,
|
||||
logging_obj,
|
||||
client=None,
|
||||
):
|
||||
if client is None:
|
||||
client = _get_httpx_client()
|
||||
sync_response = client.post(
|
||||
api_base,
|
||||
headers=headers,
|
||||
data=data,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
if sync_response.status_code != 200:
|
||||
raise SagemakerError(status_code=sync_response.status_code, message=str(sync_response.read()))
|
||||
|
||||
decoder = AWSEventStreamDecoder(model="")
|
||||
return decoder.iter_bytes(sync_response.iter_bytes())
|
||||
|
||||
async def make_async_call(
|
||||
self,
|
||||
api_base: str,
|
||||
|
|
@ -358,7 +370,7 @@ class SagemakerLLM(BaseAWSLLM):
|
|||
raise SagemakerError(status_code=response.status_code, message=response.text)
|
||||
|
||||
decoder = AWSEventStreamDecoder(model="")
|
||||
completion_stream = decoder.aiter_bytes(response.aiter_bytes(chunk_size=1024))
|
||||
completion_stream = decoder.aiter_bytes(response.aiter_bytes())
|
||||
|
||||
return completion_stream
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import html as _html
|
|||
import json
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple
|
||||
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
||||
|
|
@ -13,6 +14,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Resp
|
|||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
get_async_httpx_client,
|
||||
httpxSpecialProvider,
|
||||
|
|
@ -20,7 +22,9 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import (
|
||||
TokenEndpointAuthConfigError,
|
||||
build_token_endpoint_client_auth,
|
||||
normalize_token_endpoint_auth_method,
|
||||
)
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPTokenEndpointAuthMethod
|
||||
from litellm.proxy._experimental.mcp_server.bridge_token_flow import (
|
||||
_bridge_mint_error_response,
|
||||
_BridgeMintReady,
|
||||
|
|
@ -111,6 +115,9 @@ def encode_state_with_base_url(
|
|||
client_redirect_uri: Optional[str] = None,
|
||||
litellm_user_id: str | None = None,
|
||||
mcp_server_id: str | None = None,
|
||||
dcr_client_id: str | None = None,
|
||||
dcr_client_secret: str | None = None,
|
||||
dcr_token_endpoint_auth_method: MCPTokenEndpointAuthMethod | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Encode the base_url, original state, and PKCE parameters using encryption.
|
||||
|
|
@ -124,8 +131,18 @@ def encode_state_with_base_url(
|
|||
litellm_user_id: The SSO-authenticated litellm user captured at the bridge authorize
|
||||
(interactive dcr_bridge oauth_delegate only); the callback seals it into the gateway
|
||||
authorization code so the token mint can bind the envelope to this user
|
||||
mcp_server_id: The bridge server the interactive flow targets, sealed alongside
|
||||
litellm_user_id so the gateway code cannot be replayed against another server
|
||||
mcp_server_id: The server the flow targets, sealed alongside litellm_user_id (bridge) or
|
||||
dcr_client_id (ephemeral mint) so the gateway code cannot be replayed against another
|
||||
server
|
||||
dcr_client_id: The ephemeral DCR client the gateway minted at authorize for a
|
||||
client-forwarded-token server with no caller-supplied client; the callback seals it
|
||||
into the forwarded authorization code so the token exchange can authenticate with it
|
||||
while the gateway stores nothing
|
||||
dcr_client_secret: The minted client's secret, when the upstream issued one
|
||||
dcr_token_endpoint_auth_method: The token-endpoint auth method the upstream's registration
|
||||
response granted the minted client, sealed alongside the credentials so the exchange
|
||||
authenticates the way the upstream expects instead of falling back to the server row's
|
||||
configured method
|
||||
|
||||
Returns:
|
||||
An encrypted string that encodes all values
|
||||
|
|
@ -138,6 +155,9 @@ def encode_state_with_base_url(
|
|||
"client_redirect_uri": client_redirect_uri,
|
||||
"litellm_user_id": litellm_user_id,
|
||||
"mcp_server_id": mcp_server_id,
|
||||
"dcr_client_id": dcr_client_id,
|
||||
"dcr_client_secret": dcr_client_secret,
|
||||
"dcr_token_endpoint_auth_method": dcr_token_endpoint_auth_method,
|
||||
}
|
||||
state_json = json.dumps(state_data, sort_keys=True)
|
||||
encrypted_state = encrypt_value_helper(state_json)
|
||||
|
|
@ -217,6 +237,93 @@ def open_bridge_authorization_code(code: str) -> _BridgeAuthorizationCode | None
|
|||
return None
|
||||
|
||||
|
||||
_PASSTHROUGH_AUTH_CODE_PREFIX = "llm_ptcode_"
|
||||
|
||||
|
||||
class PassthroughAuthorizationCode(BaseModel):
|
||||
"""The ephemeral DCR client and upstream code the gateway seals into the authorization code it
|
||||
forwards for a client-forwarded-token server (``true_passthrough`` / ``oauth_delegate``) whose
|
||||
authorize fell through to gateway-side registration. These modes forbid the gateway from storing
|
||||
an OAuth client identity, so the minted client survives only inside this sealed value: the
|
||||
client echoes it back at the token endpoint, where the gateway recovers the client to
|
||||
authenticate the upstream exchange. ``mcp_server_id`` binds the code to the server it was minted
|
||||
for so it cannot be spent at another server's token endpoint."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
upstream_code: str = Field(min_length=1)
|
||||
client_id: str = Field(min_length=1)
|
||||
client_secret: str | None = None
|
||||
token_endpoint_auth_method: MCPTokenEndpointAuthMethod | None = None
|
||||
mcp_server_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
def seal_passthrough_authorization_code(
|
||||
upstream_code: str,
|
||||
client_id: str,
|
||||
client_secret: str | None,
|
||||
mcp_server_id: str,
|
||||
token_endpoint_auth_method: MCPTokenEndpointAuthMethod | None = None,
|
||||
) -> str:
|
||||
"""Seal the upstream authorization code together with the ephemeral DCR client that authorized
|
||||
it. Encrypted with the same authenticated symmetric helper as the OAuth state and bridge codes,
|
||||
so the client can neither read the (possibly confidential) client credentials nor forge a
|
||||
code."""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"upstream_code": upstream_code,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"token_endpoint_auth_method": token_endpoint_auth_method,
|
||||
"mcp_server_id": mcp_server_id,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
return _PASSTHROUGH_AUTH_CODE_PREFIX + encrypt_value_helper(payload)
|
||||
|
||||
|
||||
def open_passthrough_authorization_code(code: str) -> PassthroughAuthorizationCode | None:
|
||||
"""Recover the sealed ephemeral client and upstream code, or ``None`` when ``code`` is not a
|
||||
gateway passthrough code or does not decrypt / validate, so a raw upstream code falls through to
|
||||
the existing caller-supplied-client behavior."""
|
||||
if not code.startswith(_PASSTHROUGH_AUTH_CODE_PREFIX):
|
||||
return None
|
||||
decrypted = decrypt_value_helper(
|
||||
code[len(_PASSTHROUGH_AUTH_CODE_PREFIX) :], "passthrough_authorization_code", return_original_value=False
|
||||
)
|
||||
if not isinstance(decrypted, str):
|
||||
return None
|
||||
try:
|
||||
return PassthroughAuthorizationCode.model_validate_json(decrypted)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def redeem_passthrough_authorization_code(
|
||||
code: str | None, mcp_server: MCPServer, code_verifier: str | None
|
||||
) -> PassthroughAuthorizationCode | None:
|
||||
"""The single redemption gate for sealed passthrough codes: a raw or foreign code returns
|
||||
``None`` so the caller keeps its existing behavior, while a genuine sealed code must be spent
|
||||
at the server it was minted for and must carry the PKCE verifier of the S256 flow that minted
|
||||
it (the mint refuses downgraded flows, so a verifier-less redemption is an interception
|
||||
attempt, not a legitimate client)."""
|
||||
if not code:
|
||||
return None
|
||||
sealed = open_passthrough_authorization_code(code)
|
||||
if sealed is None:
|
||||
return None
|
||||
if sealed.mcp_server_id != mcp_server.server_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Authorization code was issued for a different MCP server",
|
||||
)
|
||||
if not code_verifier:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="code_verifier is required to redeem this authorization code",
|
||||
)
|
||||
return sealed
|
||||
|
||||
|
||||
def _redirect_to_litellm_login(request: Request) -> RedirectResponse:
|
||||
"""Send an unauthenticated browser through litellm login before the interactive bridge authorize
|
||||
can capture its identity. The bridge oauth_delegate flow seals the SSO user into the gateway code,
|
||||
|
|
@ -594,6 +701,7 @@ async def authorize_with_server(
|
|||
code_challenge_method: Optional[str] = None,
|
||||
response_type: Optional[str] = None,
|
||||
scope: Optional[str] = None,
|
||||
ephemeral_dcr_client: "EphemeralDcrClient | None" = None,
|
||||
):
|
||||
_raise_if_not_oauth2(mcp_server)
|
||||
if mcp_server.authorization_url is None:
|
||||
|
|
@ -612,7 +720,10 @@ async def authorize_with_server(
|
|||
# calling this for its enforcement side effect, then falls through to the gateway
|
||||
# /callback flow below, which reads the original code_challenge names.
|
||||
bridge_challenge, bridge_method = _require_s256_pkce(code_challenge, code_challenge_method)
|
||||
if _dcr_bridge_relays_client_registration(mcp_server):
|
||||
# A gateway-minted ephemeral client is registered against {base}/callback, so its
|
||||
# flow must run the short-circuit arm; the relay arm is only for clients that
|
||||
# registered themselves through the front door and hold their own redirect binding.
|
||||
if _dcr_bridge_relays_client_registration(mcp_server) and ephemeral_dcr_client is None:
|
||||
return _redirect_to_upstream_authorize(
|
||||
mcp_server=mcp_server,
|
||||
client_id=client_id,
|
||||
|
|
@ -656,7 +767,12 @@ async def authorize_with_server(
|
|||
code_challenge_method=code_challenge_method,
|
||||
client_redirect_uri=redirect_uri,
|
||||
litellm_user_id=litellm_user_id,
|
||||
mcp_server_id=mcp_server.server_id if litellm_user_id else None,
|
||||
mcp_server_id=mcp_server.server_id if (litellm_user_id or ephemeral_dcr_client) else None,
|
||||
dcr_client_id=ephemeral_dcr_client.client_id if ephemeral_dcr_client else None,
|
||||
dcr_client_secret=ephemeral_dcr_client.client_secret if ephemeral_dcr_client else None,
|
||||
dcr_token_endpoint_auth_method=ephemeral_dcr_client.token_endpoint_auth_method
|
||||
if ephemeral_dcr_client
|
||||
else None,
|
||||
)
|
||||
relay_state = secrets.token_urlsafe(_OAUTH_STATE_HANDLE_BYTES)
|
||||
|
||||
|
|
@ -703,6 +819,7 @@ async def exchange_token_with_server(
|
|||
code_verifier: Optional[str],
|
||||
refresh_token: Optional[str] = None,
|
||||
scope: Optional[str] = None,
|
||||
client_token_endpoint_auth_method: MCPTokenEndpointAuthMethod | None = None,
|
||||
):
|
||||
_raise_if_not_oauth2(mcp_server)
|
||||
if grant_type not in ("authorization_code", "refresh_token"):
|
||||
|
|
@ -718,15 +835,24 @@ async def exchange_token_with_server(
|
|||
),
|
||||
)
|
||||
|
||||
# The id and secret must come from the same source. When the server-side client_id wins,
|
||||
# falling back to the caller's secret pairs the persisted client with a foreign secret; the
|
||||
# register short-circuit hands clients a placeholder secret ("dummy"), so a re-auth against a
|
||||
# persisted public PKCE client (no stored secret) would send that placeholder and the IdP 401s.
|
||||
# The id, secret, and token-endpoint auth method must come from the same source. When the
|
||||
# server-side client_id wins, falling back to the caller's secret pairs the persisted client
|
||||
# with a foreign secret; the register short-circuit hands clients a placeholder secret
|
||||
# ("dummy"), so a re-auth against a persisted public PKCE client (no stored secret) would send
|
||||
# that placeholder and the IdP 401s. Symmetrically, a caller-side client (an ephemeral mint
|
||||
# recovered from a sealed code) must authenticate the way its own registration was granted,
|
||||
# not the way the server row is configured; callers that carry no method keep the row's method
|
||||
# as before.
|
||||
resolved_client_id = mcp_server.client_id if mcp_server.client_id else client_id
|
||||
resolved_client_secret = mcp_server.client_secret if mcp_server.client_id else client_secret
|
||||
resolved_auth_method = (
|
||||
mcp_server.token_endpoint_auth_method
|
||||
if mcp_server.client_id
|
||||
else (client_token_endpoint_auth_method or mcp_server.token_endpoint_auth_method)
|
||||
)
|
||||
try:
|
||||
client_auth = build_token_endpoint_client_auth(
|
||||
auth_method=mcp_server.token_endpoint_auth_method,
|
||||
auth_method=resolved_auth_method,
|
||||
client_id=resolved_client_id,
|
||||
client_secret=resolved_client_secret,
|
||||
)
|
||||
|
|
@ -1229,7 +1355,7 @@ async def _persist_dcr_client_registration(
|
|||
return "failed"
|
||||
|
||||
|
||||
def _client_supplied_redirect_uris(value: object) -> list[str] | None:
|
||||
def client_supplied_redirect_uris(value: object) -> list[str] | None:
|
||||
"""RFC 7591 redirect_uris must be a non-empty array of URI strings. Any other shape (not a list,
|
||||
an empty list, or a list holding a non-string or empty-string element) yields None so every
|
||||
register arm falls back to the gateway callback instead of echoing a malformed value back to the
|
||||
|
|
@ -1241,6 +1367,142 @@ def _client_supplied_redirect_uris(value: object) -> list[str] | None:
|
|||
return uris if len(uris) == len(value) else None
|
||||
|
||||
|
||||
async def _post_dcr_registration(
|
||||
registration_url: str,
|
||||
register_data: Mapping[str, object],
|
||||
server_id: str,
|
||||
) -> httpx.Response:
|
||||
"""POST an RFC 7591 registration to the upstream and return its response, relaying a classified
|
||||
upstream rejection instead of a generic 500 and failing loud on an absent response."""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Register)
|
||||
try:
|
||||
response = await async_client.post(
|
||||
registration_url,
|
||||
headers=headers,
|
||||
json=register_data,
|
||||
)
|
||||
if response is not None:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status_code, detail = dcr_fault_detail(classify_upstream_dcr_rejection(exc.response, log_context=server_id))
|
||||
raise HTTPException(status_code=status_code, detail=detail) from exc
|
||||
if response is None:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="MCP upstream registration endpoint returned no response",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class EphemeralDcrClient(BaseModel):
|
||||
"""A DCR client minted for a single authorize round trip and never stored by the gateway."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
client_id: str = Field(min_length=1)
|
||||
client_secret: str | None = None
|
||||
token_endpoint_auth_method: MCPTokenEndpointAuthMethod | None = None
|
||||
|
||||
|
||||
_EPHEMERAL_DCR_CLIENT_CACHE = InMemoryCache(default_ttl=_OAUTH_STATE_COOKIE_TTL_SECONDS)
|
||||
_EPHEMERAL_DCR_MINT_LOCKS: dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
async def mint_ephemeral_dcr_client(request: Request, mcp_server: MCPServer) -> EphemeralDcrClient | None:
|
||||
"""Mint a throwaway OAuth client via the upstream's RFC 7591 registration endpoint for a
|
||||
client-forwarded-token server whose authorize arrived with no client_id. Returns ``None`` when
|
||||
the upstream exposes no registration endpoint, so the caller keeps its existing failure path.
|
||||
The minted client is deliberately not persisted anywhere: ``true_passthrough`` /
|
||||
``oauth_delegate`` require the gateway to hold no OAuth client identity, so it survives only in
|
||||
the encrypted OAuth state and the sealed authorization code the callback forwards.
|
||||
|
||||
Reloading the authorize page or retrying a flow must not register a fresh upstream client every
|
||||
time (an OAuth client identifies the application, not the user, so reuse is semantically
|
||||
correct). A per-process TTL cache bounded to the OAuth state cookie's lifetime dedupes the mint
|
||||
per (server, gateway origin), and a per-server lock single-flights concurrent mints (the
|
||||
``_OAUTH_METADATA_FETCH_LOCKS`` pattern; keyed by server_id alone so the lock registry stays
|
||||
bounded by the server count even when the request origin varies) so parallel authorize requests
|
||||
cannot each register an upstream client; the cache stamps nothing onto the server record and
|
||||
correctness never depends on it because the sealed state carries the client through the flow."""
|
||||
if mcp_server.registration_url is None:
|
||||
return None
|
||||
request_base_url = get_request_base_url(request)
|
||||
cache_key = f"mcp_ephemeral_dcr_client:{mcp_server.server_id}:{request_base_url}"
|
||||
cached = _EPHEMERAL_DCR_CLIENT_CACHE.get_cache(cache_key)
|
||||
if isinstance(cached, EphemeralDcrClient):
|
||||
return cached
|
||||
lock = _EPHEMERAL_DCR_MINT_LOCKS.setdefault(mcp_server.server_id, asyncio.Lock())
|
||||
async with lock:
|
||||
cached_after_wait = _EPHEMERAL_DCR_CLIENT_CACHE.get_cache(cache_key)
|
||||
if isinstance(cached_after_wait, EphemeralDcrClient):
|
||||
return cached_after_wait
|
||||
register_data: dict[str, object] = {
|
||||
"client_name": mcp_server.server_name or mcp_server.server_id,
|
||||
"redirect_uris": [f"{request_base_url}/callback"],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
}
|
||||
response = await _post_dcr_registration(
|
||||
registration_url=mcp_server.registration_url,
|
||||
register_data=register_data,
|
||||
server_id=mcp_server.server_id,
|
||||
)
|
||||
try:
|
||||
registration = _DcrClientRegistration.model_validate_json(response.text)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="MCP upstream registration endpoint returned no usable client_id",
|
||||
) from exc
|
||||
if not registration.client_id:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="MCP upstream registration endpoint returned no usable client_id",
|
||||
)
|
||||
minted = EphemeralDcrClient(
|
||||
client_id=registration.client_id,
|
||||
client_secret=registration.client_secret,
|
||||
token_endpoint_auth_method=normalize_token_endpoint_auth_method(registration.token_endpoint_auth_method),
|
||||
)
|
||||
_EPHEMERAL_DCR_CLIENT_CACHE.set_cache(cache_key, minted)
|
||||
return minted
|
||||
|
||||
|
||||
async def resolve_ephemeral_dcr_client(
|
||||
request: Request,
|
||||
mcp_server: MCPServer,
|
||||
code_challenge: str | None,
|
||||
code_challenge_method: str | None,
|
||||
redirect_uri: str,
|
||||
) -> EphemeralDcrClient | None:
|
||||
"""The single owner of the gateway-side mint policy for a clientless authorize. Returns
|
||||
``None`` for servers whose mode does not permit gateway minting and for upstreams without a
|
||||
registration endpoint, so those callers keep their existing failure paths: plain ``oauth2``
|
||||
keeps its persisted-client contract, and the interactive ``oauth_delegate`` dcr_bridge
|
||||
sign-in has its own sealed-identity flow. ``true_passthrough`` mints regardless of the
|
||||
``dcr_bridge`` flag (the UI creates passthrough servers with the flag on by default): a
|
||||
minted flow runs the bridge short-circuit arm, while the relay front door remains for
|
||||
external clients that registered themselves. Flows that could never succeed fail loud
|
||||
before any upstream registration: a missing ``authorization_url``, a downgraded PKCE pair
|
||||
(without S256 the sealed code would be bearer-redeemable by any authenticated caller who
|
||||
intercepts the redirect), or an untrusted ``redirect_uri`` (a rejected redirect must not be
|
||||
usable to generate orphan IdP clients)."""
|
||||
if not (mcp_server.is_true_passthrough or (mcp_server.is_oauth_delegate and not mcp_server.is_dcr_bridge)):
|
||||
return None
|
||||
if mcp_server.authorization_url is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="MCP server authorization url is not set",
|
||||
)
|
||||
_require_s256_pkce(code_challenge, code_challenge_method)
|
||||
validate_trusted_redirect_uri(request, redirect_uri)
|
||||
return await mint_ephemeral_dcr_client(request, mcp_server)
|
||||
|
||||
|
||||
async def register_client_with_server(
|
||||
request: Request,
|
||||
mcp_server: MCPServer,
|
||||
|
|
@ -1302,30 +1564,11 @@ async def register_client_with_server(
|
|||
"response_types": response_types or (["code"] if bridge_relay else []),
|
||||
"token_endpoint_auth_method": token_endpoint_auth_method or ("none" if bridge_relay else ""),
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Register)
|
||||
try:
|
||||
response = await async_client.post(
|
||||
mcp_server.registration_url,
|
||||
headers=headers,
|
||||
json=register_data,
|
||||
)
|
||||
if response is not None:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status_code, detail = dcr_fault_detail(
|
||||
classify_upstream_dcr_rejection(exc.response, log_context=mcp_server.server_id)
|
||||
)
|
||||
raise HTTPException(status_code=status_code, detail=detail) from exc
|
||||
if response is None:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="MCP upstream registration endpoint returned no response",
|
||||
)
|
||||
response = await _post_dcr_registration(
|
||||
registration_url=mcp_server.registration_url,
|
||||
register_data=register_data,
|
||||
server_id=mcp_server.server_id,
|
||||
)
|
||||
|
||||
token_response = response.json()
|
||||
|
||||
|
|
@ -1563,11 +1806,23 @@ async def callback(
|
|||
# envelope to this user. Every other flow forwards the raw code unchanged.
|
||||
litellm_user_id = state_data.get("litellm_user_id")
|
||||
mcp_server_id = state_data.get("mcp_server_id")
|
||||
dcr_client_id = state_data.get("dcr_client_id")
|
||||
dcr_client_secret = state_data.get("dcr_client_secret")
|
||||
forwarded_code = code
|
||||
if isinstance(litellm_user_id, str) and litellm_user_id and isinstance(mcp_server_id, str) and mcp_server_id:
|
||||
forwarded_code = seal_bridge_authorization_code(
|
||||
upstream_code=code, litellm_user_id=litellm_user_id, mcp_server_id=mcp_server_id
|
||||
)
|
||||
elif isinstance(dcr_client_id, str) and dcr_client_id and isinstance(mcp_server_id, str) and mcp_server_id:
|
||||
forwarded_code = seal_passthrough_authorization_code(
|
||||
upstream_code=code,
|
||||
client_id=dcr_client_id,
|
||||
client_secret=dcr_client_secret if isinstance(dcr_client_secret, str) and dcr_client_secret else None,
|
||||
mcp_server_id=mcp_server_id,
|
||||
token_endpoint_auth_method=normalize_token_endpoint_auth_method(
|
||||
state_data.get("dcr_token_endpoint_auth_method")
|
||||
),
|
||||
)
|
||||
|
||||
params = {"code": forwarded_code, "state": original_state}
|
||||
complete_returned_url = _append_query_params(redirect_uri, params)
|
||||
|
|
@ -2158,7 +2413,7 @@ async def register_client(request: Request, mcp_server_name: Optional[str] = Non
|
|||
|
||||
request_data = await _read_request_body(request=request)
|
||||
data: dict = {**request_data}
|
||||
client_redirect_uris = _client_supplied_redirect_uris(data.get("redirect_uris"))
|
||||
client_redirect_uris = client_supplied_redirect_uris(data.get("redirect_uris"))
|
||||
|
||||
dummy_return = {
|
||||
"client_id": mcp_server_name or "dummy_client",
|
||||
|
|
|
|||
|
|
@ -1857,6 +1857,17 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase):
|
|||
default_team_member_models: Optional[List[str]] = None # default allowed_models seeded onto new team members
|
||||
|
||||
|
||||
class PatchTeamRequest(UpdateTeamRequest):
|
||||
"""
|
||||
Body of PATCH /team/{team_id}.
|
||||
|
||||
Identical to UpdateTeamRequest except team_id is optional, because PATCH takes it
|
||||
from the path. A team_id in the body is still accepted when it matches the path.
|
||||
"""
|
||||
|
||||
team_id: str | None = None
|
||||
|
||||
|
||||
class ResetTeamBudgetRequest(LiteLLMPydanticObjectBase):
|
||||
"""
|
||||
internal type used to reset the budget on a team
|
||||
|
|
@ -2768,6 +2779,30 @@ class LiteLLM_OrganizationTableUpdate(LiteLLM_BudgetTable):
|
|||
return values
|
||||
|
||||
|
||||
class OrganizationUpdateRequestV2(LiteLLMPydanticObjectBase):
|
||||
"""
|
||||
Typed PATCH body for ``/v2/organization/{organization_id}`` (RFC 7396 merge-patch).
|
||||
|
||||
Presence is read from ``model_fields_set``, so a sent field is written and an omitted one is
|
||||
left untouched. ``extra="forbid"`` makes an unknown key a 422 rather than a silent no-op, since
|
||||
the contract hinges on which keys are present. See the endpoint for the per-field clear tokens.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
organization_alias: str | None = None
|
||||
models: list[str] | None = None
|
||||
metadata: dict | None = None
|
||||
tpm_limit: int | None = None
|
||||
rpm_limit: int | None = None
|
||||
max_budget: float | None = None
|
||||
soft_budget: float | None = None
|
||||
max_parallel_requests: int | None = None
|
||||
model_max_budget: dict | None = None
|
||||
budget_duration: str | None = None
|
||||
object_permission: LiteLLM_ObjectPermissionBase | None = None
|
||||
|
||||
|
||||
from litellm.models.organization import ( # noqa: E402
|
||||
LiteLLM_OrganizationTable as LiteLLM_OrganizationTable,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -523,7 +523,7 @@ export LITELLM_PROXY_API_KEY=sk-...
|
|||
lite model-groups list [--format table|json]
|
||||
```
|
||||
|
||||
Lists the model groups your key can reach on the proxy, via `/model_group/info`, along with each group's mode (`chat`, `embedding`, etc.) and per-token pricing. This is also what `lite autoroute configure` uses internally to discover what it can offer you.
|
||||
Lists the model groups your key can reach on the proxy, via `/model_group/info`, along with each group's mode (`chat`, `embedding`, etc.) and per-token pricing. Note this route needs management access; `lite autoroute configure` instead discovers models through `/v1/models`, so it works with a key scoped to just the AI API routes
|
||||
|
||||
#### Configure the Auto-Router
|
||||
|
||||
|
|
|
|||
|
|
@ -15,41 +15,25 @@ class DiscoveredModel(BaseModel):
|
|||
|
||||
name: str
|
||||
mode: str = "chat"
|
||||
input_cost_per_token: float | None = None
|
||||
output_cost_per_token: float | None = None
|
||||
|
||||
|
||||
class _RawModelGroup(BaseModel):
|
||||
class _RawModelListing(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
model_group: str
|
||||
# Optional: some real deployments return an explicit `"mode": null` for models that
|
||||
# were registered without a mode (seen for embedding models like voyage-4-large).
|
||||
# ModelGroupInfo's own "chat" default (litellm/types/router.py) only applies when the
|
||||
# key is missing entirely, not when it's present as null, so this must tolerate None.
|
||||
mode: str | None = "chat"
|
||||
input_cost_per_token: float | None = None
|
||||
output_cost_per_token: float | None = None
|
||||
id: str
|
||||
# /v1/models attaches "mode" (sourced from the cost map) only for models it can resolve;
|
||||
# a model whose mode is unknown arrives without the field, so default it to chat rather
|
||||
# than dropping it, which keeps it selectable as a routing target in the wizard.
|
||||
mode: str = "chat"
|
||||
|
||||
|
||||
_RAW_MODEL_GROUPS_ADAPTER = TypeAdapter(list[_RawModelGroup])
|
||||
_RAW_MODEL_LISTING_ADAPTER = TypeAdapter(list[_RawModelListing])
|
||||
|
||||
|
||||
def parse_discovered_models(raw: list[JsonValue]) -> tuple[DiscoveredModel, ...]:
|
||||
"""Validate a raw `/model_group/info` response into typed models."""
|
||||
parsed = _RAW_MODEL_GROUPS_ADAPTER.validate_python(raw)
|
||||
return tuple(
|
||||
DiscoveredModel(
|
||||
name=group.model_group,
|
||||
# A null mode means the server genuinely doesn't know what this model does;
|
||||
# "unknown" (rather than guessing "chat") keeps it out of both chat_models()
|
||||
# and embedding_models() instead of risking a wrong-mode deployment.
|
||||
mode=group.mode or "unknown",
|
||||
input_cost_per_token=group.input_cost_per_token,
|
||||
output_cost_per_token=group.output_cost_per_token,
|
||||
)
|
||||
for group in parsed
|
||||
)
|
||||
"""Validate a raw `/v1/models` response into typed models."""
|
||||
parsed = _RAW_MODEL_LISTING_ADAPTER.validate_python(raw)
|
||||
return tuple(DiscoveredModel(name=item.id, mode=item.mode) for item in parsed)
|
||||
|
||||
|
||||
def chat_models(models: tuple[DiscoveredModel, ...]) -> tuple[DiscoveredModel, ...]:
|
||||
|
|
|
|||
|
|
@ -111,12 +111,12 @@ def run_configure_wizard(ctx: click.Context) -> Path:
|
|||
api_key = ctx.obj["api_key"]
|
||||
client = Client(base_url=base_url, api_key=api_key)
|
||||
|
||||
raw_groups = client.model_groups.info()
|
||||
if not isinstance(raw_groups, list):
|
||||
raw_models = client.models.list()
|
||||
if not isinstance(raw_models, list):
|
||||
raise click.ClickException(
|
||||
f"Unexpected response from /model_group/info: expected a list, got {type(raw_groups).__name__}"
|
||||
f"Unexpected response from /v1/models: expected a list, got {type(raw_models).__name__}"
|
||||
)
|
||||
discovered = parse_discovered_models(raw_groups)
|
||||
discovered = parse_discovered_models(raw_models)
|
||||
chat_pool = chat_models(discovered)
|
||||
embedding_pool = embedding_models(discovered)
|
||||
|
||||
|
|
|
|||
9
litellm/proxy/config_resolvers/__init__.py
Normal file
9
litellm/proxy/config_resolvers/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"""Typed, provenance-aware resolution of proxy settings from DB then env."""
|
||||
|
||||
from litellm.proxy.config_resolvers._descriptors import (
|
||||
FieldDescriptor,
|
||||
FieldSource,
|
||||
resolve_fields,
|
||||
)
|
||||
|
||||
__all__ = ["FieldDescriptor", "FieldSource", "resolve_fields"]
|
||||
73
litellm/proxy/config_resolvers/_descriptors.py
Normal file
73
litellm/proxy/config_resolvers/_descriptors.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"""Shared primitive for resolving a settings value from its sources.
|
||||
|
||||
A ``FieldDescriptor`` names, for one setting, where it lives in the stored DB
|
||||
row (``db_key``), which process env var carries it (``env_var``), whether it is
|
||||
a secret, and its effective default. ``resolve_fields`` reconciles a set of
|
||||
descriptors against a decrypted DB row and the process environment with a fixed
|
||||
precedence, returning the resolved values plus per-field provenance so a caller
|
||||
can tell whether a value came from the database, the environment, a default, or
|
||||
is unset.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
FieldSource = Literal["db", "env", "default", "unset"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FieldDescriptor:
|
||||
field_name: str
|
||||
db_key: str
|
||||
env_var: str
|
||||
is_secret: bool = False
|
||||
default: str | None = None
|
||||
|
||||
|
||||
def _db_is_set(db_value: object, empty_db_is_set: bool) -> bool:
|
||||
if empty_db_is_set:
|
||||
# A stored key that is present, even as "", is an explicit admin choice
|
||||
# (e.g. clearing an alerting webhook) and must win over a stale env var.
|
||||
return db_value is not None
|
||||
# A blank stored value is treated as absent, so it falls through to env. This
|
||||
# fits settings whose clear path also unsets the env var (e.g. SSO).
|
||||
return isinstance(db_value, str) and bool(db_value.strip())
|
||||
|
||||
|
||||
def _resolve_one(
|
||||
descriptor: FieldDescriptor,
|
||||
db_values: Mapping[str, object],
|
||||
env: Mapping[str, str],
|
||||
empty_db_is_set: bool,
|
||||
) -> tuple[str, str | None, FieldSource]:
|
||||
db_value = db_values.get(descriptor.db_key)
|
||||
if _db_is_set(db_value, empty_db_is_set):
|
||||
return descriptor.field_name, db_value if isinstance(db_value, str) else str(db_value), "db"
|
||||
env_value = env.get(descriptor.env_var)
|
||||
if isinstance(env_value, str) and env_value.strip():
|
||||
return descriptor.field_name, env_value, "env"
|
||||
if descriptor.default is not None:
|
||||
return descriptor.field_name, descriptor.default, "default"
|
||||
return descriptor.field_name, None, "unset"
|
||||
|
||||
|
||||
def resolve_fields(
|
||||
descriptors: Sequence[FieldDescriptor],
|
||||
db_values: Mapping[str, object],
|
||||
env: Mapping[str, str],
|
||||
empty_db_is_set: bool = False,
|
||||
) -> tuple[dict[str, str | None], dict[str, FieldSource]]:
|
||||
"""Resolve every descriptor to (values, provenance).
|
||||
|
||||
Precedence per field: a set stored value wins, else a non-blank process env
|
||||
var, else the descriptor default, else unset. ``empty_db_is_set`` selects
|
||||
how a present-but-empty stored value is read: ``False`` treats it as absent
|
||||
so it falls back to env (SSO, whose clear path also unsets the env var);
|
||||
``True`` treats it as an explicit clear that wins over env (alerting, whose
|
||||
clear path stores "" without unsetting the env var).
|
||||
"""
|
||||
resolved = tuple(_resolve_one(descriptor, db_values, env, empty_db_is_set) for descriptor in descriptors)
|
||||
values = {field_name: value for field_name, value, _ in resolved}
|
||||
provenance = {field_name: source for field_name, _, source in resolved}
|
||||
return values, provenance
|
||||
25
litellm/proxy/config_resolvers/alerting.py
Normal file
25
litellm/proxy/config_resolvers/alerting.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Descriptor tables for the alerting settings surfaced by /get/config/callbacks.
|
||||
|
||||
These reconcile the stored ``environment_variables`` blob (keyed by the
|
||||
uppercase env-var names) with the process environment. SMTP_PORT and SMTP_TLS
|
||||
carry the same effective defaults the mail-send path applies, so the settings
|
||||
page shows the config that mail would actually use rather than a blank.
|
||||
"""
|
||||
|
||||
from litellm.proxy.config_resolvers._descriptors import FieldDescriptor
|
||||
|
||||
EMAIL_DESCRIPTORS: tuple[FieldDescriptor, ...] = (
|
||||
FieldDescriptor("SMTP_HOST", "SMTP_HOST", "SMTP_HOST"),
|
||||
FieldDescriptor("SMTP_PORT", "SMTP_PORT", "SMTP_PORT", default="587"),
|
||||
FieldDescriptor("SMTP_TLS", "SMTP_TLS", "SMTP_TLS", default="True"),
|
||||
FieldDescriptor("SMTP_USERNAME", "SMTP_USERNAME", "SMTP_USERNAME", is_secret=True),
|
||||
FieldDescriptor("SMTP_PASSWORD", "SMTP_PASSWORD", "SMTP_PASSWORD", is_secret=True),
|
||||
FieldDescriptor("SMTP_SENDER_EMAIL", "SMTP_SENDER_EMAIL", "SMTP_SENDER_EMAIL"),
|
||||
FieldDescriptor("TEST_EMAIL_ADDRESS", "TEST_EMAIL_ADDRESS", "TEST_EMAIL_ADDRESS"),
|
||||
FieldDescriptor("EMAIL_LOGO_URL", "EMAIL_LOGO_URL", "EMAIL_LOGO_URL"),
|
||||
FieldDescriptor("EMAIL_SUPPORT_CONTACT", "EMAIL_SUPPORT_CONTACT", "EMAIL_SUPPORT_CONTACT"),
|
||||
)
|
||||
|
||||
SLACK_DESCRIPTORS: tuple[FieldDescriptor, ...] = (
|
||||
FieldDescriptor("SLACK_WEBHOOK_URL", "SLACK_WEBHOOK_URL", "SLACK_WEBHOOK_URL", is_secret=True),
|
||||
)
|
||||
94
litellm/proxy/config_resolvers/sso.py
Normal file
94
litellm/proxy/config_resolvers/sso.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Resolved SSO config object.
|
||||
|
||||
Reconciles the dedicated ``sso_config`` DB row (lowercase, per-value encrypted
|
||||
keys) with the process environment (uppercase env vars) into a typed
|
||||
``SSOConfig`` plus per-field provenance. This is the single source of truth for
|
||||
the SSO field -> env-var mapping, used by both the read-back endpoint and the
|
||||
save endpoint so the two can never drift.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
|
||||
from litellm.proxy.config_resolvers._descriptors import (
|
||||
FieldDescriptor,
|
||||
FieldSource,
|
||||
resolve_fields,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.ui_sso import (
|
||||
RoleMappings,
|
||||
SSOConfig,
|
||||
TeamMappings,
|
||||
)
|
||||
|
||||
SSO_DESCRIPTORS: tuple[FieldDescriptor, ...] = (
|
||||
FieldDescriptor("google_client_id", "google_client_id", "GOOGLE_CLIENT_ID"),
|
||||
FieldDescriptor("google_client_secret", "google_client_secret", "GOOGLE_CLIENT_SECRET", is_secret=True),
|
||||
FieldDescriptor("microsoft_client_id", "microsoft_client_id", "MICROSOFT_CLIENT_ID"),
|
||||
FieldDescriptor("microsoft_client_secret", "microsoft_client_secret", "MICROSOFT_CLIENT_SECRET", is_secret=True),
|
||||
FieldDescriptor("microsoft_tenant", "microsoft_tenant", "MICROSOFT_TENANT"),
|
||||
FieldDescriptor("generic_client_id", "generic_client_id", "GENERIC_CLIENT_ID"),
|
||||
FieldDescriptor("generic_client_secret", "generic_client_secret", "GENERIC_CLIENT_SECRET", is_secret=True),
|
||||
FieldDescriptor(
|
||||
"generic_authorization_endpoint", "generic_authorization_endpoint", "GENERIC_AUTHORIZATION_ENDPOINT"
|
||||
),
|
||||
FieldDescriptor("generic_token_endpoint", "generic_token_endpoint", "GENERIC_TOKEN_ENDPOINT"),
|
||||
FieldDescriptor("generic_userinfo_endpoint", "generic_userinfo_endpoint", "GENERIC_USERINFO_ENDPOINT"),
|
||||
FieldDescriptor("generic_scope", "generic_scope", "GENERIC_SCOPE", default="openid email profile"),
|
||||
FieldDescriptor("proxy_base_url", "proxy_base_url", "PROXY_BASE_URL"),
|
||||
)
|
||||
|
||||
# Derived from the descriptor table so read (masking) and the field->env mapping
|
||||
# never diverge from the resolver.
|
||||
SSO_SECRET_FIELDS: frozenset[str] = frozenset(d.field_name for d in SSO_DESCRIPTORS if d.is_secret)
|
||||
SSO_FIELD_ENV_VARS: dict[str, str] = {d.field_name: d.env_var for d in SSO_DESCRIPTORS}
|
||||
|
||||
# Structured sub-objects stored on the SSO row that are not simple env-backed
|
||||
# scalars; handled outside the descriptor resolution.
|
||||
_STRUCTURED_KEYS = ("role_mappings", "team_mappings")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedSSOConfig:
|
||||
config: SSOConfig
|
||||
provenance: dict[str, FieldSource]
|
||||
|
||||
|
||||
def _decrypt(raw: Mapping[str, object]) -> dict[str, object]:
|
||||
return {
|
||||
key: (
|
||||
decrypt_value_helper(value=value, key=key, return_original_value=True) if isinstance(value, str) else value
|
||||
)
|
||||
for key, value in raw.items()
|
||||
}
|
||||
|
||||
|
||||
def _parse_role_mappings(data: object) -> RoleMappings | None:
|
||||
# The stored row is JSON, so mappings arrive as a dict (or are absent).
|
||||
return RoleMappings(**data) if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _parse_team_mappings(data: object) -> TeamMappings | None:
|
||||
return TeamMappings(**data) if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def resolve_sso_config(sso_db_settings: Mapping[str, object] | None, env: Mapping[str, str]) -> ResolvedSSOConfig:
|
||||
"""Resolve the effective SSO config: stored row first, then process env.
|
||||
|
||||
Decryption happens here, once, via the pure ``decrypt_value_helper``; this
|
||||
function never writes ``os.environ`` (unlike the legacy read path). Values
|
||||
are returned unmasked so the login path could consume them; the read-back
|
||||
endpoint is responsible for masking secrets before responding to the UI.
|
||||
"""
|
||||
raw = dict(sso_db_settings) if sso_db_settings else {}
|
||||
decrypted = _decrypt({key: value for key, value in raw.items() if key not in _STRUCTURED_KEYS})
|
||||
values, provenance = resolve_fields(SSO_DESCRIPTORS, decrypted, env)
|
||||
structured = {
|
||||
"user_email": decrypted.get("user_email"),
|
||||
"ui_access_mode": decrypted.get("ui_access_mode"),
|
||||
"role_mappings": _parse_role_mappings(raw.get("role_mappings")),
|
||||
"team_mappings": _parse_team_mappings(raw.get("team_mappings")),
|
||||
}
|
||||
config = SSOConfig(**{**values, **structured})
|
||||
return ResolvedSSOConfig(config=config, provenance=provenance)
|
||||
|
|
@ -136,9 +136,12 @@ if MCP_AVAILABLE:
|
|||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
_raise_if_not_oauth2,
|
||||
authorize_with_server,
|
||||
client_supplied_redirect_uris,
|
||||
exchange_token_with_server,
|
||||
get_request_base_url,
|
||||
redeem_passthrough_authorization_code,
|
||||
register_client_with_server,
|
||||
resolve_ephemeral_dcr_client,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
global_mcp_server_manager,
|
||||
|
|
@ -1661,7 +1664,21 @@ if MCP_AVAILABLE:
|
|||
mcp_server = await _get_cached_temporary_mcp_server_or_404(server_id, user_api_key_dict, request=request)
|
||||
_raise_if_not_oauth2(mcp_server)
|
||||
# Use the server's stored client_id when the caller doesn't supply one
|
||||
resolved_client_id = mcp_server.client_id or client_id or ""
|
||||
stored_or_supplied_client_id = mcp_server.client_id or client_id or ""
|
||||
ephemeral_dcr_client = (
|
||||
await resolve_ephemeral_dcr_client(
|
||||
request=request,
|
||||
mcp_server=mcp_server,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method=code_challenge_method,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
if not stored_or_supplied_client_id
|
||||
else None
|
||||
)
|
||||
resolved_client_id = stored_or_supplied_client_id or (
|
||||
ephemeral_dcr_client.client_id if ephemeral_dcr_client else ""
|
||||
)
|
||||
if not resolved_client_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
|
@ -1683,6 +1700,7 @@ if MCP_AVAILABLE:
|
|||
code_challenge_method=code_challenge_method,
|
||||
response_type=response_type,
|
||||
scope=scope,
|
||||
ephemeral_dcr_client=ephemeral_dcr_client,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
|
|
@ -1705,7 +1723,21 @@ if MCP_AVAILABLE:
|
|||
):
|
||||
mcp_server = await _get_cached_temporary_mcp_server_or_404(server_id, user_api_key_dict, request=request)
|
||||
_raise_if_not_oauth2(mcp_server)
|
||||
resolved_client_id = mcp_server.client_id or client_id or ""
|
||||
# Sealed passthrough codes exist only for the authorization_code grant. A refresh_token
|
||||
# grant must never open one: the minted client is unrecoverable after the single flow by
|
||||
# contract, so an expired browser-held token re-runs authorize instead.
|
||||
sealed_code = (
|
||||
redeem_passthrough_authorization_code(code=code, mcp_server=mcp_server, code_verifier=code_verifier)
|
||||
if grant_type == "authorization_code"
|
||||
else None
|
||||
)
|
||||
resolved_code = sealed_code.upstream_code if sealed_code else code
|
||||
# A sealed flow ran the gateway /callback as its upstream redirect (bridge short-circuit
|
||||
# or plain flow alike), so the exchange must present that binding, not the browser page.
|
||||
resolved_redirect_uri = f"{get_request_base_url(request)}/callback" if sealed_code else redirect_uri
|
||||
caller_client_id = sealed_code.client_id if sealed_code else client_id
|
||||
caller_client_secret = sealed_code.client_secret if sealed_code else client_secret
|
||||
resolved_client_id = mcp_server.client_id or caller_client_id or ""
|
||||
if not resolved_client_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
|
@ -1721,13 +1753,14 @@ if MCP_AVAILABLE:
|
|||
request=request,
|
||||
mcp_server=mcp_server,
|
||||
grant_type=grant_type,
|
||||
code=code,
|
||||
redirect_uri=redirect_uri,
|
||||
code=resolved_code,
|
||||
redirect_uri=resolved_redirect_uri,
|
||||
client_id=resolved_client_id,
|
||||
client_secret=client_secret,
|
||||
client_secret=caller_client_secret,
|
||||
code_verifier=code_verifier,
|
||||
refresh_token=refresh_token,
|
||||
scope=scope,
|
||||
client_token_endpoint_auth_method=sealed_code.token_endpoint_auth_method if sealed_code else None,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
|
|
@ -1743,6 +1776,7 @@ if MCP_AVAILABLE:
|
|||
mcp_server = await _get_cached_temporary_mcp_server_or_404(server_id, user_api_key_dict, request=request)
|
||||
request_data = await _read_request_body(request=request)
|
||||
data: dict = {**request_data}
|
||||
client_redirect_uris = client_supplied_redirect_uris(data.get("redirect_uris"))
|
||||
|
||||
return await register_client_with_server(
|
||||
request=request,
|
||||
|
|
@ -1753,6 +1787,7 @@ if MCP_AVAILABLE:
|
|||
token_endpoint_auth_method=data.get("token_endpoint_auth_method", ""),
|
||||
fallback_client_id=server_id,
|
||||
persist_credentials=_user_is_full_admin(user_api_key_dict),
|
||||
client_redirect_uris=client_redirect_uris,
|
||||
)
|
||||
|
||||
@router.delete(
|
||||
|
|
|
|||
|
|
@ -13,16 +13,18 @@ Endpoints for /organization operations
|
|||
|
||||
#### ORGANIZATION MANAGEMENT ####
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Annotated, Any, Dict, List, Mapping, Optional, Tuple
|
||||
|
||||
import fastapi
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm._uuid import uuid
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy.auth.auth_checks import can_user_call_model, get_user_object
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
|
||||
from litellm.proxy.management_endpoints.budget_management_endpoints import (
|
||||
new_budget,
|
||||
update_budget,
|
||||
|
|
@ -34,6 +36,7 @@ from litellm.proxy.management_endpoints.common_utils import (
|
|||
)
|
||||
from litellm.proxy.management_helpers.object_permission_utils import (
|
||||
handle_update_object_permission_common,
|
||||
prepare_object_permission_upsert,
|
||||
)
|
||||
from litellm.proxy.management_helpers.utils import (
|
||||
get_new_internal_user_defaults,
|
||||
|
|
@ -101,6 +104,30 @@ async def _verify_org_access(
|
|||
)
|
||||
|
||||
|
||||
_STR_OBJECT_DICT_ADAPTER = TypeAdapter(dict[str, object])
|
||||
_BUDGET_SETTABLE_FIELDS = frozenset(LiteLLM_BudgetTable.model_fields.keys()) - {"budget_id"}
|
||||
_ORG_COLUMN_FIELDS = frozenset({"organization_alias", "models"})
|
||||
|
||||
|
||||
def build_budget_write_data(budget_updates: Mapping[str, object], updated_by: str) -> Mapping[str, object]:
|
||||
"""
|
||||
Budget-row columns to write. ``budget_reset_at`` tracks any sent ``budget_duration``:
|
||||
recomputed for a new duration, cleared alongside a ``None`` duration so no stale reset
|
||||
timestamp survives. Other sent fields (including a ``None`` clear) are written as-is.
|
||||
"""
|
||||
budget_duration = budget_updates.get("budget_duration")
|
||||
recomputed_reset_at: Mapping[str, object] = (
|
||||
{
|
||||
"budget_reset_at": (
|
||||
get_budget_reset_time(budget_duration=budget_duration) if isinstance(budget_duration, str) else None
|
||||
)
|
||||
}
|
||||
if "budget_duration" in budget_updates
|
||||
else {}
|
||||
)
|
||||
return {**budget_updates, **recomputed_reset_at, "updated_by": updated_by}
|
||||
|
||||
|
||||
def handle_nested_budget_structure_in_organization_update_request(
|
||||
raw_data: dict,
|
||||
) -> dict:
|
||||
|
|
@ -556,6 +583,154 @@ async def handle_update_object_permission(
|
|||
return data_json
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/v2/organization/{organization_id}",
|
||||
tags=["organization management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=LiteLLM_OrganizationTableWithMembers,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def update_organization_v2(
|
||||
organization_id: str,
|
||||
data: OrganizationUpdateRequestV2,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
):
|
||||
"""
|
||||
Partial update of an organization (RESTful PATCH, RFC 7396 merge-patch semantics).
|
||||
|
||||
A sent field is written and an omitted one is left untouched (presence is read from
|
||||
``model_fields_set``). Clear tokens are per field: budget limits and ``metadata`` clear with
|
||||
``null``, ``models`` with ``[]``, and ``object_permission`` with ``null`` (it merges when sent,
|
||||
so an empty ``{}`` is rejected). ``organization_alias`` is required and cannot be cleared.
|
||||
Validation failures return 422; the object-permission upsert, budget-row write, and
|
||||
org-row write are one transaction.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": CommonProxyErrors.db_not_connected_error.value},
|
||||
)
|
||||
|
||||
if user_api_key_dict.user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "Cannot associate a user_id to this action. Check `/key/info` to validate if 'user_id' is set."
|
||||
},
|
||||
)
|
||||
|
||||
if data.max_budget is not None and (not math.isfinite(data.max_budget) or data.max_budget < 0):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"error": f"max_budget must be a non-negative finite number. Received: {data.max_budget}"},
|
||||
)
|
||||
if data.soft_budget is not None and (not math.isfinite(data.soft_budget) or data.soft_budget < 0):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}"},
|
||||
)
|
||||
if data.model_max_budget:
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
validate_model_max_budget,
|
||||
)
|
||||
|
||||
try:
|
||||
validate_model_max_budget(data.model_max_budget)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail={"error": str(e)})
|
||||
|
||||
if "organization_alias" in data.model_fields_set and data.organization_alias is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"error": "organization_alias cannot be cleared; it is required"},
|
||||
)
|
||||
if "models" in data.model_fields_set and data.models is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"error": "models cannot be set to null; send [] to clear it"},
|
||||
)
|
||||
if data.object_permission is not None and not data.object_permission.model_dump(exclude_none=True):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"error": "object_permission cannot be an empty object; send null to clear it, or a non-empty object to set grants"
|
||||
},
|
||||
)
|
||||
|
||||
await _verify_org_access(
|
||||
organization_id=organization_id,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
|
||||
existing_organization_row = await OrganizationRepository(prisma_client).table.find_unique(
|
||||
where={"organization_id": organization_id},
|
||||
)
|
||||
if existing_organization_row is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"Organization not found for organization_id={organization_id}"},
|
||||
)
|
||||
|
||||
field_values = _STR_OBJECT_DICT_ADAPTER.validate_python(data.model_dump())
|
||||
present_fields = data.model_fields_set
|
||||
budget_updates = {field: field_values[field] for field in present_fields if field in _BUDGET_SETTABLE_FIELDS}
|
||||
org_column_updates: Mapping[str, object] = {
|
||||
**{field: field_values[field] for field in present_fields if field in _ORG_COLUMN_FIELDS},
|
||||
**({"metadata": data.metadata or {}} if "metadata" in present_fields else {}),
|
||||
}
|
||||
|
||||
object_permission_cleared = "object_permission" in present_fields and data.object_permission is None
|
||||
object_permission_upsert = (
|
||||
await prepare_object_permission_upsert(
|
||||
new_object_permission=data.object_permission.model_dump(exclude_none=True),
|
||||
existing_object_permission_id=existing_organization_row.object_permission_id,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
if data.object_permission is not None
|
||||
else None
|
||||
)
|
||||
object_permission_write: Mapping[str, object] = (
|
||||
{"object_permission_id": object_permission_upsert.object_permission_id}
|
||||
if object_permission_upsert is not None
|
||||
else ({"object_permission_id": None} if object_permission_cleared else {})
|
||||
)
|
||||
|
||||
organization_write_data = prisma_client.jsonify_object(
|
||||
{
|
||||
**org_column_updates,
|
||||
**object_permission_write,
|
||||
"updated_by": user_api_key_dict.user_id,
|
||||
}
|
||||
)
|
||||
|
||||
async with prisma_client.db.tx() as tx:
|
||||
if object_permission_upsert is not None:
|
||||
await tx.litellm_objectpermissiontable.upsert(
|
||||
where={"object_permission_id": object_permission_upsert.object_permission_id},
|
||||
data={
|
||||
"create": object_permission_upsert.record,
|
||||
"update": object_permission_upsert.record,
|
||||
},
|
||||
)
|
||||
if budget_updates:
|
||||
await tx.litellm_budgettable.update(
|
||||
where={"budget_id": existing_organization_row.budget_id},
|
||||
data=prisma_client.jsonify_object(
|
||||
dict(build_budget_write_data(budget_updates, user_api_key_dict.user_id))
|
||||
),
|
||||
)
|
||||
response = await tx.litellm_organizationtable.update(
|
||||
where={"organization_id": organization_id},
|
||||
data=organization_write_data,
|
||||
include={"members": True, "teams": True, "litellm_budget_table": True},
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/organization/delete",
|
||||
tags=["organization management"],
|
||||
|
|
|
|||
|
|
@ -1317,6 +1317,13 @@ async def delete_user(
|
|||
where={"team_id": team.team_id}, data={"members": new_members}
|
||||
)
|
||||
|
||||
team_row = LiteLLM_TeamTable(**team.model_dump())
|
||||
if any(member.user_id == user_id for member in team_row.members_with_roles or []):
|
||||
await team_member_delete(
|
||||
data=TeamMemberDeleteRequest(team_id=team_row.team_id, user_id=user_id),
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
||||
)
|
||||
|
||||
await _set_user_keys_blocked(user_id=user_id, blocked=True)
|
||||
|
||||
await _delete_rows_referencing_user(prisma_client, user_id=user_id)
|
||||
|
|
@ -1346,6 +1353,31 @@ def _extract_group_values(value: Any) -> List[str]:
|
|||
return group_values
|
||||
|
||||
|
||||
def _extract_ids_from_path_filter(path: str | None, attribute: str) -> List[str]:
|
||||
"""Return ids from a SCIM filtered path like ``members[value eq "id"]``.
|
||||
|
||||
Okta commonly sends membership removals as a filtered path and omits the
|
||||
request body ``value``, so the id lives only inside the ``[value eq "..."]``
|
||||
filter. The ``eq`` operator is matched case-insensitively per the SCIM
|
||||
spec; the id keeps its original case. Per the SCIM filter grammar the
|
||||
compared value must be quoted (single or double), so malformed unquoted
|
||||
filters yield no id. A quoted id may contain escaped quotes and
|
||||
backslashes (``\\"`` and ``\\\\``), which are unescaped before use.
|
||||
``path`` must be the raw, case-preserving path from the patch op.
|
||||
"""
|
||||
if not path:
|
||||
return []
|
||||
match = re.match(
|
||||
rf"""\s*{re.escape(attribute)}\s*\[\s*value\s+eq\s+(['"])((?:\\.|[^\\])*?)\1\s*\]\s*$""",
|
||||
path,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if not match:
|
||||
return []
|
||||
extracted = re.sub(r"\\(.)", r"\1", match.group(2))
|
||||
return [extracted] if extracted else []
|
||||
|
||||
|
||||
def _handle_displayname_update(op_type: str, value: Any, update_data: Dict[str, Any]) -> None:
|
||||
"""Handle displayname updates."""
|
||||
if op_type == "remove":
|
||||
|
|
@ -1389,9 +1421,11 @@ def _handle_name_update(path: str, op_type: str, value: Any, scim_metadata: Dict
|
|||
scim_metadata["familyName"] = str(value)
|
||||
|
||||
|
||||
def _handle_group_operations(op_type: str, value: Any, teams_set: Set[str]) -> Optional[Set[str]]:
|
||||
def _handle_group_operations(op_type: str, value: Any, teams_set: Set[str], path: str | None) -> Set[str] | None:
|
||||
"""Handle group/team membership operations."""
|
||||
group_values = _extract_group_values(value)
|
||||
if not group_values and value is None:
|
||||
group_values = _extract_ids_from_path_filter(path, "groups")
|
||||
if op_type == "replace":
|
||||
return set(group_values)
|
||||
elif op_type == "add":
|
||||
|
|
@ -1504,7 +1538,7 @@ def _apply_patch_ops(
|
|||
elif _multi_valued_attribute_base(path) in SCIM_MULTI_VALUED_ATTRIBUTE_METADATA_KEYS:
|
||||
_handle_multi_valued_attribute_update(path, op_type, value, metadata)
|
||||
elif path.startswith("groups"):
|
||||
new_replace_set = _handle_group_operations(op_type, value, teams_set)
|
||||
new_replace_set = _handle_group_operations(op_type, value, teams_set, op.path)
|
||||
if new_replace_set is not None:
|
||||
replace_team_set = new_replace_set
|
||||
else:
|
||||
|
|
@ -1925,8 +1959,16 @@ async def delete_group(
|
|||
|
||||
async def _process_group_patch_operations(
|
||||
patch_ops: SCIMPatchOp, existing_team, prisma_client
|
||||
) -> Tuple[Dict[str, Any], Set[str]]:
|
||||
"""Process patch operations for a group and return update data and final members."""
|
||||
) -> Tuple[Dict[str, Any], Set[str], Set[str] | None]:
|
||||
"""Process patch operations for a group and return update data, final members
|
||||
and, when the request contained a member ``replace`` op, the absolute target
|
||||
roster it declared (``None`` otherwise).
|
||||
|
||||
``add``/``remove`` are deltas relative to the current roster, but ``replace``
|
||||
is absolute: it declares the roster is exactly this set, so the caller must
|
||||
reconcile against it as a set-to-target rather than rebasing it onto a
|
||||
concurrently-mutated roster.
|
||||
"""
|
||||
update_data: Dict[str, Any] = {}
|
||||
|
||||
# Create a fresh copy of existing metadata to avoid Prisma issues
|
||||
|
|
@ -1960,6 +2002,8 @@ async def _process_group_patch_operations(
|
|||
elif path.startswith("members"):
|
||||
# Handle member operations
|
||||
member_values = _extract_group_values(value)
|
||||
if not member_values and value is None:
|
||||
member_values = _extract_ids_from_path_filter(op.path, "members")
|
||||
# Check the feature flag
|
||||
scim_upsert_user = await _get_scim_upsert_user_setting()
|
||||
# Validate all users exist or create them based on feature flag
|
||||
|
|
@ -2012,7 +2056,12 @@ async def _process_group_patch_operations(
|
|||
if metadata:
|
||||
update_data["metadata"] = metadata
|
||||
|
||||
return update_data, final_members
|
||||
member_replace_present = any(
|
||||
op.op == "replace" and (op.path or "").lower().startswith("members") for op in patch_ops.Operations
|
||||
)
|
||||
replace_target = set(final_members) if member_replace_present else None
|
||||
|
||||
return update_data, final_members, replace_target
|
||||
|
||||
|
||||
async def _apply_group_patch_updates(group_id: str, update_data: Dict[str, Any], prisma_client):
|
||||
|
|
@ -2083,27 +2132,29 @@ async def patch_group(
|
|||
existing_team = await _check_team_exists(group_id)
|
||||
|
||||
# Process patch operations
|
||||
update_data, final_members = await _process_group_patch_operations(patch_ops, existing_team, prisma_client)
|
||||
update_data, final_members, replace_target = await _process_group_patch_operations(
|
||||
patch_ops, existing_team, prisma_client
|
||||
)
|
||||
|
||||
# Track current members BEFORE update for comparison
|
||||
current_members = set(await _get_team_member_user_ids_from_team(existing_team))
|
||||
snapshot_members = set(await _get_team_member_user_ids_from_team(existing_team))
|
||||
intended_add = final_members - snapshot_members
|
||||
intended_remove = snapshot_members - final_members
|
||||
|
||||
# Apply the metadata/displayName updates to the database
|
||||
updated_team = await _apply_group_patch_updates(group_id, update_data, prisma_client)
|
||||
|
||||
# Refresh team data from database to get the latest state after concurrent updates
|
||||
# This prevents race conditions when multiple PATCH requests come in simultaneously
|
||||
refreshed_team = await TeamRepository(prisma_client).table.find_unique(where={"team_id": group_id})
|
||||
if refreshed_team:
|
||||
# Re-read current members from refreshed team to account for concurrent updates
|
||||
refreshed_current_members = set(
|
||||
await _get_team_member_user_ids_from_team(LiteLLM_TeamTable(**refreshed_team.model_dump()))
|
||||
)
|
||||
# Use the refreshed members for comparison
|
||||
current_members = refreshed_current_members
|
||||
refreshed_current = (
|
||||
set(await _get_team_member_user_ids_from_team(LiteLLM_TeamTable(**refreshed_team.model_dump())))
|
||||
if refreshed_team
|
||||
else snapshot_members
|
||||
)
|
||||
|
||||
# Handle user-team relationship changes
|
||||
await _handle_group_membership_changes(group_id, current_members, final_members)
|
||||
effective_final = (
|
||||
replace_target if replace_target is not None else (refreshed_current | intended_add) - intended_remove
|
||||
)
|
||||
|
||||
await _handle_group_membership_changes(group_id, refreshed_current, effective_final)
|
||||
|
||||
# A rename can flip whether this group matches scim_admin_group by display
|
||||
# name, so retained members must be re-resolved too, not just the ones whose
|
||||
|
|
@ -2112,7 +2163,7 @@ async def patch_group(
|
|||
alias_changed = new_alias != existing_team.team_alias
|
||||
await _recompute_scim_member_roles(
|
||||
prisma_client,
|
||||
(current_members | final_members if alias_changed else current_members ^ final_members),
|
||||
(refreshed_current | effective_final if alias_changed else refreshed_current ^ effective_final),
|
||||
)
|
||||
|
||||
# Refresh team one more time to get final state after membership changes
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ from litellm.proxy._types import (
|
|||
Member,
|
||||
NewTeamRequest,
|
||||
OrgMember,
|
||||
PatchTeamRequest,
|
||||
ProxyErrorTypes,
|
||||
ProxyException,
|
||||
SpecialManagementEndpointEnums,
|
||||
|
|
@ -1956,6 +1957,7 @@ async def update_team(
|
|||
)
|
||||
async def patch_team(
|
||||
team_id: str,
|
||||
data: PatchTeamRequest,
|
||||
http_request: Request,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
litellm_changed_by: Annotated[
|
||||
|
|
@ -1968,11 +1970,12 @@ async def patch_team(
|
|||
"""
|
||||
Partially update a team using RFC 7386 JSON Merge Patch semantics.
|
||||
|
||||
`team_id` is taken from the path. `metadata` is merged with the team's stored
|
||||
metadata rather than replacing it: an omitted key is preserved, `key: null`
|
||||
deletes it, and any other value overwrites (recursing into nested objects).
|
||||
Every other field behaves exactly like `POST /team/update` (omitted preserves,
|
||||
a value overwrites). Returns the full updated team.
|
||||
`team_id` is taken from the path; a `team_id` in the body is accepted only when it
|
||||
matches. `metadata` is merged with the team's stored metadata rather than replacing
|
||||
it: an omitted key is preserved, `key: null` deletes it, and any other value
|
||||
overwrites (recursing into nested objects). Every other field behaves exactly like
|
||||
`POST /team/update` (omitted preserves, a value overwrites). Returns the full
|
||||
updated team.
|
||||
|
||||
```
|
||||
curl --location --request PATCH 'http://0.0.0.0:4000/team/8d916b1c-510d-4894-a334-1c16a93344f5' \
|
||||
|
|
@ -1992,21 +1995,15 @@ async def patch_team(
|
|||
detail={"error": CommonProxyErrors.db_not_connected_error.value},
|
||||
)
|
||||
|
||||
try:
|
||||
body = await http_request.json()
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail={"error": "Request body must be a JSON object"})
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(status_code=400, detail={"error": "Request body must be a JSON object"})
|
||||
|
||||
body_team_id = body.pop("team_id", None)
|
||||
if body_team_id is not None and body_team_id != team_id:
|
||||
if data.team_id is not None and data.team_id != team_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": f"team_id in body ({body_team_id}) does not match team_id in path ({team_id})"},
|
||||
detail={"error": f"team_id in body ({data.team_id}) does not match team_id in path ({team_id})"},
|
||||
)
|
||||
|
||||
if "metadata" in body:
|
||||
patch_fields = data.model_dump(exclude_unset=True, exclude={"team_id"})
|
||||
|
||||
if "metadata" in patch_fields:
|
||||
existing_team_row = await TeamRepository(prisma_client).table.find_unique(where={"team_id": team_id})
|
||||
if existing_team_row is None:
|
||||
raise HTTPException(
|
||||
|
|
@ -2014,9 +2011,9 @@ async def patch_team(
|
|||
detail={"error": f"Team not found, passed team_id={team_id}"},
|
||||
)
|
||||
existing_metadata = existing_team_row.metadata if isinstance(existing_team_row.metadata, dict) else {}
|
||||
body["metadata"] = apply_json_merge_patch(existing_metadata, body["metadata"])
|
||||
patch_fields["metadata"] = apply_json_merge_patch(existing_metadata, patch_fields["metadata"])
|
||||
|
||||
update_request = UpdateTeamRequest(team_id=team_id, **body)
|
||||
update_request = UpdateTeamRequest(team_id=team_id, **patch_fields)
|
||||
|
||||
result = await update_team(
|
||||
data=update_request,
|
||||
|
|
@ -2375,7 +2372,15 @@ async def _add_team_members_to_team(
|
|||
user_api_key_dict: UserAPIKeyAuth,
|
||||
litellm_proxy_admin_name: str,
|
||||
) -> Tuple[LiteLLM_TeamTable, List[LiteLLM_UserTable], List[LiteLLM_TeamMembership]]:
|
||||
"""Add team members to the team."""
|
||||
"""Add team members to the team.
|
||||
|
||||
The members_with_roles reconciliation runs inside a transaction that locks
|
||||
the team row with ``SELECT ... FOR UPDATE`` before reading the current
|
||||
membership. Concurrent /team/member_add calls for the same team therefore
|
||||
serialize on the row lock and each appends onto the other's committed
|
||||
result, instead of both rewriting the whole JSON array from a stale
|
||||
snapshot (which silently drops one member on the losing write).
|
||||
"""
|
||||
# Process and add new members
|
||||
updated_users, updated_team_memberships = await _process_team_members(
|
||||
data=data,
|
||||
|
|
@ -2385,19 +2390,22 @@ async def _add_team_members_to_team(
|
|||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
)
|
||||
|
||||
# Update team members list
|
||||
await _update_team_members_list(
|
||||
data=data,
|
||||
complete_team_data=complete_team_data,
|
||||
updated_users=updated_users,
|
||||
)
|
||||
async with prisma_client.tx() as tx:
|
||||
complete_team_data.members_with_roles = await TeamRepository(prisma_client).get_members_with_roles_locked(
|
||||
tx, data.team_id
|
||||
)
|
||||
|
||||
# ADD MEMBER TO TEAM
|
||||
_db_team_members = [m.model_dump() for m in complete_team_data.members_with_roles]
|
||||
updated_team = await TeamRepository(prisma_client).table.update(
|
||||
where={"team_id": data.team_id},
|
||||
data={"members_with_roles": json.dumps(_db_team_members)}, # type: ignore
|
||||
)
|
||||
await _update_team_members_list(
|
||||
data=data,
|
||||
complete_team_data=complete_team_data,
|
||||
updated_users=updated_users,
|
||||
)
|
||||
|
||||
_db_team_members = [m.model_dump() for m in complete_team_data.members_with_roles]
|
||||
updated_team = await tx.litellm_teamtable.update(
|
||||
where={"team_id": data.team_id},
|
||||
data={"members_with_roles": json.dumps(_db_team_members)},
|
||||
)
|
||||
|
||||
return updated_team, updated_users, updated_team_memberships
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ organizations, teams, and keys.
|
|||
"""
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Set, Union
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
|
@ -64,6 +65,57 @@ async def attach_object_permission_to_dict(
|
|||
return data_dict
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ObjectPermissionUpsert:
|
||||
object_permission_id: str
|
||||
record: dict[str, object]
|
||||
|
||||
|
||||
async def prepare_object_permission_upsert(
|
||||
new_object_permission: Mapping[str, object],
|
||||
existing_object_permission_id: str | None,
|
||||
prisma_client: PrismaClient,
|
||||
) -> ObjectPermissionUpsert:
|
||||
"""
|
||||
Read-and-merge half of an object permission upsert; performs no writes.
|
||||
|
||||
Merges the sent grants over the existing row (looked up by
|
||||
``existing_object_permission_id``, or a fresh uuid when the entity has none) and
|
||||
returns the id plus the full record to upsert. The id is pinned inside the record
|
||||
because the column has ``@default(uuid())``, so a create without it would mint a
|
||||
different id than the one the caller links. ``mcp_tool_permissions`` is serialized
|
||||
to a JSON string to avoid GraphQL parsing issues (e.g. server IDs starting with
|
||||
"3e64" being interpreted as floats).
|
||||
|
||||
Keeping this separate from the write lets callers run the upsert inside the same
|
||||
transaction as the row that links ``object_permission_id``, so a rolled-back
|
||||
update cannot leave permission changes live.
|
||||
"""
|
||||
object_permission_id = existing_object_permission_id or str(uuid.uuid4())
|
||||
existing_object_permission = await ObjectPermissionRepository(prisma_client).table.find_unique(
|
||||
where={"object_permission_id": object_permission_id},
|
||||
)
|
||||
existing_fields: dict[str, object] = (
|
||||
existing_object_permission.model_dump(exclude_unset=True, exclude_none=True)
|
||||
if existing_object_permission is not None
|
||||
else {}
|
||||
)
|
||||
merged: dict[str, object] = {
|
||||
**existing_fields,
|
||||
**new_object_permission,
|
||||
"object_permission_id": object_permission_id,
|
||||
}
|
||||
record: dict[str, object] = {
|
||||
**merged,
|
||||
**(
|
||||
{"mcp_tool_permissions": safe_dumps(merged["mcp_tool_permissions"])}
|
||||
if "mcp_tool_permissions" in merged
|
||||
else {}
|
||||
),
|
||||
}
|
||||
return ObjectPermissionUpsert(object_permission_id=object_permission_id, record=record)
|
||||
|
||||
|
||||
async def handle_update_object_permission_common(
|
||||
data_json: Dict,
|
||||
existing_object_permission_id: Optional[str],
|
||||
|
|
@ -93,50 +145,23 @@ async def handle_update_object_permission_common(
|
|||
if prisma_client is None:
|
||||
raise ValueError("Prisma client not found")
|
||||
|
||||
#########################################################
|
||||
# Ensure `object_permission` is not added to the data_json
|
||||
# We need to update the entity at the object_permission_id level in the LiteLLM_ObjectPermissionTable
|
||||
#########################################################
|
||||
new_object_permission: Union[dict, str] = data_json.pop("object_permission", None)
|
||||
new_object_permission: Union[dict, str, None] = data_json.pop("object_permission", None)
|
||||
if new_object_permission is None:
|
||||
return None
|
||||
|
||||
# Lookup existing object permission ID and update that entry
|
||||
object_permission_id_to_use: str = existing_object_permission_id or str(uuid.uuid4())
|
||||
existing_object_permissions_dict: Dict = {}
|
||||
|
||||
existing_object_permission = await ObjectPermissionRepository(prisma_client).table.find_unique(
|
||||
where={"object_permission_id": object_permission_id_to_use},
|
||||
)
|
||||
|
||||
# Update the object permission
|
||||
if existing_object_permission is not None:
|
||||
existing_object_permissions_dict = existing_object_permission.model_dump(exclude_unset=True, exclude_none=True)
|
||||
|
||||
# Handle string JSON object permission
|
||||
if isinstance(new_object_permission, str):
|
||||
new_object_permission = json.loads(new_object_permission)
|
||||
|
||||
if isinstance(new_object_permission, dict):
|
||||
existing_object_permissions_dict.update(new_object_permission)
|
||||
|
||||
#########################################################
|
||||
# Serialize mcp_tool_permissions JSON field to avoid GraphQL parsing issues
|
||||
# (e.g., server IDs starting with "3e64" being interpreted as floats)
|
||||
#########################################################
|
||||
if "mcp_tool_permissions" in existing_object_permissions_dict:
|
||||
existing_object_permissions_dict["mcp_tool_permissions"] = safe_dumps(
|
||||
existing_object_permissions_dict["mcp_tool_permissions"]
|
||||
)
|
||||
|
||||
#########################################################
|
||||
# Commit the update to the LiteLLM_ObjectPermissionTable
|
||||
#########################################################
|
||||
upsert = await prepare_object_permission_upsert(
|
||||
new_object_permission=new_object_permission if isinstance(new_object_permission, dict) else {},
|
||||
existing_object_permission_id=existing_object_permission_id,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
created_object_permission_row = await ObjectPermissionRepository(prisma_client).table.upsert(
|
||||
where={"object_permission_id": object_permission_id_to_use},
|
||||
where={"object_permission_id": upsert.object_permission_id},
|
||||
data={
|
||||
"create": existing_object_permissions_dict,
|
||||
"update": existing_object_permissions_dict,
|
||||
"create": upsert.record,
|
||||
"update": upsert.record,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -198,13 +198,9 @@
|
|||
"icon_url": "https://cdn.simpleicons.org/googledrive",
|
||||
"category": "Productivity",
|
||||
"registry_url": null,
|
||||
"transport": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-gdrive"],
|
||||
"env_vars": [
|
||||
{"name": "GOOGLE_CLIENT_ID", "description": "Google OAuth Client ID", "secret": false},
|
||||
{"name": "GOOGLE_CLIENT_SECRET", "description": "Google OAuth Client Secret", "secret": true}
|
||||
]
|
||||
"transport": "http",
|
||||
"url": "https://drivemcp.googleapis.com/mcp/v1",
|
||||
"env_vars": []
|
||||
},
|
||||
{
|
||||
"name": "google_calendar",
|
||||
|
|
|
|||
|
|
@ -304,6 +304,11 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
|
|||
encrypt_value_helper,
|
||||
)
|
||||
from litellm.proxy.common_utils.html_forms.ui_login import build_ui_login_form
|
||||
from litellm.proxy.config_resolvers import resolve_fields
|
||||
from litellm.proxy.config_resolvers.alerting import (
|
||||
EMAIL_DESCRIPTORS,
|
||||
SLACK_DESCRIPTORS,
|
||||
)
|
||||
from litellm.proxy.common_utils.http_parsing_utils import (
|
||||
_read_request_body,
|
||||
_safe_get_request_headers,
|
||||
|
|
@ -1159,9 +1164,9 @@ _OPENAPI_HTTP_METHODS = {
|
|||
# Credentials surfaced by `/get/config/callbacks` in the alerting block: the
|
||||
# full Slack incoming-webhook URL is itself a credential, and the SMTP
|
||||
# password is a service password. Masked on read so plaintext never reaches
|
||||
# the UI. Kept here at module scope to match the analogous
|
||||
# `_SSO_SENSITIVE_FIELDS` / `_CACHE_SENSITIVE_FIELDS` constants in the SSO
|
||||
# and cache endpoint files.
|
||||
# the UI. Kept here at module scope to match the analogous descriptor
|
||||
# `is_secret` flags in litellm.proxy.config_resolvers and the
|
||||
# `_CACHE_SENSITIVE_FIELDS` constant in the cache endpoint file.
|
||||
_ALERTING_SENSITIVE_VARS: Set[str] = {"SLACK_WEBHOOK_URL", "SMTP_PASSWORD"}
|
||||
|
||||
|
||||
|
|
@ -15491,14 +15496,10 @@ async def get_config(
|
|||
_alerting = _general_settings.get("alerting", [])
|
||||
alerting_data = []
|
||||
if "slack" in _alerting:
|
||||
_slack_vars = [
|
||||
"SLACK_WEBHOOK_URL",
|
||||
]
|
||||
_slack_env_vars = {
|
||||
_var: (value if (value := environment_variables.get(_var)) is not None else os.getenv(_var))
|
||||
for _var in _slack_vars
|
||||
}
|
||||
_slack_env_vars = _apply_alerting_env_role_gate(_slack_env_vars, is_full_admin)
|
||||
_slack_values, _ = resolve_fields(
|
||||
SLACK_DESCRIPTORS, environment_variables, os.environ, empty_db_is_set=True
|
||||
)
|
||||
_slack_env_vars = _apply_alerting_env_role_gate(_slack_values, is_full_admin)
|
||||
|
||||
_alerting_types = proxy_logging_obj.slack_alerting_instance.alert_types
|
||||
_all_alert_types = proxy_logging_obj.slack_alerting_instance._all_possible_alert_types()
|
||||
|
|
@ -15514,19 +15515,8 @@ async def get_config(
|
|||
}
|
||||
)
|
||||
# pass email alerting vars
|
||||
_email_vars = [
|
||||
"SMTP_HOST",
|
||||
"SMTP_PORT",
|
||||
"SMTP_USERNAME",
|
||||
"SMTP_PASSWORD",
|
||||
"SMTP_SENDER_EMAIL",
|
||||
"TEST_EMAIL_ADDRESS",
|
||||
"EMAIL_LOGO_URL",
|
||||
"EMAIL_SUPPORT_CONTACT",
|
||||
]
|
||||
_email_env_vars = _apply_alerting_env_role_gate(
|
||||
{_var: environment_variables.get(_var) for _var in _email_vars}, is_full_admin
|
||||
)
|
||||
_email_values, _ = resolve_fields(EMAIL_DESCRIPTORS, environment_variables, os.environ, empty_db_is_set=True)
|
||||
_email_env_vars = _apply_alerting_env_role_gate(_email_values, is_full_admin)
|
||||
|
||||
alerting_data.append(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ from litellm._logging import verbose_proxy_logger
|
|||
from litellm.litellm_core_utils.sensitive_data_masker import mask_sensitive_keys
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.config_resolvers.sso import (
|
||||
SSO_FIELD_ENV_VARS,
|
||||
SSO_SECRET_FIELDS,
|
||||
resolve_sso_config,
|
||||
)
|
||||
from litellm.repositories.config_repository import ConfigRepository
|
||||
from litellm.repositories.table_repositories import (
|
||||
SSOConfigRepository,
|
||||
|
|
@ -27,16 +32,6 @@ from litellm.types.proxy.management_endpoints.ui_sso import (
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
# SSO secret fields returned by /get/sso_settings. These are masked on read so
|
||||
# the UI can show "(set)" without ever transporting the plaintext OAuth secret
|
||||
# off the server, matching the write-once + masked-on-read contract used for
|
||||
# the HashiCorp Vault config override.
|
||||
_SSO_SENSITIVE_FIELDS: Set[str] = {
|
||||
"google_client_secret",
|
||||
"microsoft_client_secret",
|
||||
"generic_client_secret",
|
||||
}
|
||||
|
||||
# Maps each UIThemeConfig field to the env var the UI branding path reads it
|
||||
# from. /update/ui_theme_settings writes both the stored ui_theme_config and
|
||||
# these env vars, so /get/ui_theme_settings resolves the same env vars to
|
||||
|
|
@ -109,7 +104,8 @@ class SettingsResponse(BaseModel):
|
|||
class SSOSettingsResponse(SettingsResponse):
|
||||
"""Response model for SSO settings"""
|
||||
|
||||
pass
|
||||
provenance: Dict[str, str] = Field(default_factory=dict)
|
||||
"""Per-field source of each value: 'db', 'env', 'default', or 'unset'."""
|
||||
|
||||
|
||||
class InternalUserSettingsResponse(SettingsResponse):
|
||||
|
|
@ -757,7 +753,7 @@ async def get_sso_settings():
|
|||
Returns a structured object with values and descriptions for UI display.
|
||||
"""
|
||||
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_config
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
|
|
@ -765,59 +761,12 @@ async def get_sso_settings():
|
|||
detail={"error": "Database not connected. Please connect a database."},
|
||||
)
|
||||
|
||||
# Get SSO config from dedicated table
|
||||
# Resolve the effective SSO config: the stored row wins, else the process
|
||||
# environment, else each field's default. Unlike the legacy read path this
|
||||
# does not write os.environ; a GET has no business mutating the environment.
|
||||
sso_db_record = await SSOConfigRepository(prisma_client).table.find_unique(where={"id": "sso_config"})
|
||||
|
||||
# Initialize with defaults
|
||||
sso_settings_dict = {}
|
||||
|
||||
if sso_db_record and sso_db_record.sso_settings:
|
||||
# Load settings from database
|
||||
sso_settings_dict = dict(sso_db_record.sso_settings)
|
||||
|
||||
role_mappings_data = sso_settings_dict.pop("role_mappings", None)
|
||||
role_mappings = None
|
||||
if role_mappings_data:
|
||||
from litellm.types.proxy.management_endpoints.ui_sso import RoleMappings
|
||||
|
||||
if isinstance(role_mappings_data, dict):
|
||||
role_mappings = RoleMappings(**role_mappings_data)
|
||||
elif isinstance(role_mappings_data, RoleMappings):
|
||||
role_mappings = role_mappings_data
|
||||
|
||||
team_mappings_data = sso_settings_dict.pop("team_mappings", None)
|
||||
team_mappings = None
|
||||
if team_mappings_data:
|
||||
from litellm.types.proxy.management_endpoints.ui_sso import TeamMappings
|
||||
|
||||
if isinstance(team_mappings_data, dict):
|
||||
team_mappings = TeamMappings(**team_mappings_data)
|
||||
elif isinstance(team_mappings_data, TeamMappings):
|
||||
team_mappings = team_mappings_data
|
||||
|
||||
decrypted_sso_settings_dict = proxy_config._decrypt_and_set_db_env_variables(
|
||||
environment_variables=sso_settings_dict
|
||||
)
|
||||
|
||||
# Build SSO config with database values or environment fallback
|
||||
|
||||
sso_config = SSOConfig(
|
||||
google_client_id=decrypted_sso_settings_dict.get("google_client_id", None),
|
||||
google_client_secret=decrypted_sso_settings_dict.get("google_client_secret", None),
|
||||
microsoft_client_id=decrypted_sso_settings_dict.get("microsoft_client_id", None),
|
||||
microsoft_client_secret=decrypted_sso_settings_dict.get("microsoft_client_secret", None),
|
||||
microsoft_tenant=decrypted_sso_settings_dict.get("microsoft_tenant", None),
|
||||
generic_client_id=decrypted_sso_settings_dict.get("generic_client_id", None),
|
||||
generic_client_secret=decrypted_sso_settings_dict.get("generic_client_secret", None),
|
||||
generic_authorization_endpoint=decrypted_sso_settings_dict.get("generic_authorization_endpoint", None),
|
||||
generic_token_endpoint=decrypted_sso_settings_dict.get("generic_token_endpoint", None),
|
||||
generic_userinfo_endpoint=decrypted_sso_settings_dict.get("generic_userinfo_endpoint", None),
|
||||
proxy_base_url=decrypted_sso_settings_dict.get("proxy_base_url", None),
|
||||
user_email=decrypted_sso_settings_dict.get("user_email"),
|
||||
ui_access_mode=decrypted_sso_settings_dict.get("ui_access_mode"),
|
||||
role_mappings=role_mappings,
|
||||
team_mappings=team_mappings,
|
||||
)
|
||||
sso_db_settings = dict(sso_db_record.sso_settings) if sso_db_record and sso_db_record.sso_settings else None
|
||||
resolved = resolve_sso_config(sso_db_settings, os.environ)
|
||||
|
||||
# Get the schema for UI display
|
||||
from pydantic import TypeAdapter
|
||||
|
|
@ -826,11 +775,12 @@ async def get_sso_settings():
|
|||
|
||||
# Convert to dict for response, masking OAuth client secrets so plaintext
|
||||
# is never sent to the UI.
|
||||
sso_dict = mask_sensitive_keys(sso_config.model_dump(), _SSO_SENSITIVE_FIELDS)
|
||||
sso_dict = mask_sensitive_keys(resolved.config.model_dump(), set(SSO_SECRET_FIELDS))
|
||||
|
||||
# Add descriptions to the response
|
||||
result = {
|
||||
"values": sso_dict,
|
||||
"provenance": resolved.provenance,
|
||||
"field_schema": {
|
||||
"description": schema.get("description", ""),
|
||||
"properties": {},
|
||||
|
|
@ -881,21 +831,6 @@ async def update_sso_settings(
|
|||
detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."},
|
||||
)
|
||||
|
||||
# Update environment variables
|
||||
env_var_mapping = {
|
||||
"google_client_id": "GOOGLE_CLIENT_ID",
|
||||
"google_client_secret": "GOOGLE_CLIENT_SECRET",
|
||||
"microsoft_client_id": "MICROSOFT_CLIENT_ID",
|
||||
"microsoft_client_secret": "MICROSOFT_CLIENT_SECRET",
|
||||
"microsoft_tenant": "MICROSOFT_TENANT",
|
||||
"generic_client_id": "GENERIC_CLIENT_ID",
|
||||
"generic_client_secret": "GENERIC_CLIENT_SECRET",
|
||||
"generic_authorization_endpoint": "GENERIC_AUTHORIZATION_ENDPOINT",
|
||||
"generic_token_endpoint": "GENERIC_TOKEN_ENDPOINT",
|
||||
"generic_userinfo_endpoint": "GENERIC_USERINFO_ENDPOINT",
|
||||
"proxy_base_url": "PROXY_BASE_URL",
|
||||
}
|
||||
|
||||
# Read the existing SSO row first so the audit log captures a real
|
||||
# before/after diff. Stored values are encrypted; decrypt them so the
|
||||
# before-snapshot has the same shape as after_value, and rely on
|
||||
|
|
@ -924,8 +859,8 @@ async def update_sso_settings(
|
|||
# Update environment variables in config and in memory
|
||||
sso_data = sso_config.model_dump()
|
||||
for field_name, value in sso_data.items():
|
||||
if field_name in env_var_mapping:
|
||||
env_var_name = env_var_mapping[field_name]
|
||||
if field_name in SSO_FIELD_ENV_VARS:
|
||||
env_var_name = SSO_FIELD_ENV_VARS[field_name]
|
||||
if value:
|
||||
os.environ[env_var_name] = value
|
||||
else:
|
||||
|
|
@ -975,7 +910,7 @@ async def update_sso_settings(
|
|||
else:
|
||||
environment_variables = {}
|
||||
|
||||
env_vars_to_remove = set(env_var_mapping.values())
|
||||
env_vars_to_remove = set(SSO_FIELD_ENV_VARS.values())
|
||||
filtered_env_vars = {
|
||||
key: value for key, value in environment_variables.items() if key not in env_vars_to_remove
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ from litellm.types.utils import LLMResponseTypes, LoggedLiteLLMParams
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from opentelemetry.trace import Span as _Span
|
||||
from prisma.client import TransactionManager
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
||||
|
|
@ -2922,6 +2923,14 @@ class PrismaClient:
|
|||
return self.db.writer
|
||||
return self.db
|
||||
|
||||
def tx(self) -> "TransactionManager":
|
||||
"""Open an interactive transaction on the writer.
|
||||
|
||||
Callers go through this instead of reaching into ``self.db`` so writer
|
||||
selection and read-replica routing stay encapsulated in the wrapper.
|
||||
"""
|
||||
return cast("TransactionManager", self.db.tx()) # cast-ok: wrappers delegate tx via __getattr__ (untyped)
|
||||
|
||||
def get_request_status(self, payload: Union[dict, SpendLogsPayload]) -> Literal["success", "failure"]:
|
||||
"""
|
||||
Determine if a request was successful or failed based on payload metadata.
|
||||
|
|
@ -6159,6 +6168,9 @@ def create_model_info_response(
|
|||
if model_cost_info is not None:
|
||||
max_input_tokens = coerce_token_limit(model_cost_info.get("max_input_tokens"))
|
||||
max_output_tokens = coerce_token_limit(model_cost_info.get("max_output_tokens"))
|
||||
mode = model_cost_info.get("mode")
|
||||
if isinstance(mode, str):
|
||||
base["mode"] = mode
|
||||
|
||||
if llm_router is not None:
|
||||
configured_input, configured_output = llm_router.get_configured_token_limits(model_id)
|
||||
|
|
|
|||
|
|
@ -4,11 +4,18 @@ Team repository for database operations on LiteLLM_TeamTable.
|
|||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type
|
||||
|
||||
from litellm.models.team import LiteLLM_TeamTable
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm.models.team import LiteLLM_TeamTable, Member
|
||||
from litellm.repositories.base_repository import BaseRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prisma import Prisma
|
||||
|
||||
_MEMBERS_WITH_ROLES_ADAPTER = TypeAdapter(list[Member])
|
||||
|
||||
|
||||
class TeamRepository(BaseRepository[LiteLLM_TeamTable]):
|
||||
"""Repository for team database operations."""
|
||||
|
|
@ -46,6 +53,24 @@ class TeamRepository(BaseRepository[LiteLLM_TeamTable]):
|
|||
|
||||
return LiteLLM_TeamTable(**data)
|
||||
|
||||
async def get_members_with_roles_locked(self, tx: "Prisma", team_id: str) -> List[Member]:
|
||||
"""Return the team's members_with_roles, locking the row FOR UPDATE.
|
||||
|
||||
Must be called inside a transaction so the row lock is held until
|
||||
commit. This serializes concurrent membership writers on the team row
|
||||
so the losing writer appends onto the winner's committed result instead
|
||||
of overwriting it from a stale snapshot.
|
||||
"""
|
||||
rows = await tx.query_raw(
|
||||
'SELECT members_with_roles FROM "LiteLLM_TeamTable" WHERE team_id = $1 FOR UPDATE',
|
||||
team_id,
|
||||
)
|
||||
raw_value = rows[0]["members_with_roles"] if rows else None
|
||||
parsed = json.loads(raw_value) if isinstance(raw_value, str) else raw_value
|
||||
if not parsed:
|
||||
return []
|
||||
return _MEMBERS_WITH_ROLES_ADAPTER.validate_python(parsed)
|
||||
|
||||
async def find_by_id(self, team_id: str, id_field: str = "team_id") -> Optional[LiteLLM_TeamTable]:
|
||||
return await super().find_by_id(team_id, id_field)
|
||||
|
||||
|
|
|
|||
|
|
@ -494,7 +494,14 @@ async def aresponses(
|
|||
prompt_label=kwargs.get("prompt_label", None),
|
||||
prompt_version=kwargs.get("prompt_version", None),
|
||||
)
|
||||
input = cast(Union[str, ResponseInputParam], merged_input)
|
||||
input = cast(
|
||||
Union[str, ResponseInputParam],
|
||||
ResponsesAPIRequestUtils.merge_prompt_management_input(
|
||||
original_input=input,
|
||||
client_input=client_input,
|
||||
merged_input=merged_input,
|
||||
),
|
||||
)
|
||||
if model != original_model:
|
||||
_, custom_llm_provider, _, _ = litellm.get_llm_provider(model=model)
|
||||
kwargs.pop("prompt_id", None)
|
||||
|
|
@ -609,7 +616,14 @@ def _apply_prompt_management_to_responses_call(
|
|||
prompt_label=kwargs.get("prompt_label", None),
|
||||
prompt_version=kwargs.get("prompt_version", None),
|
||||
)
|
||||
input = cast(Union[str, ResponseInputParam], merged_input)
|
||||
input = cast(
|
||||
Union[str, ResponseInputParam],
|
||||
ResponsesAPIRequestUtils.merge_prompt_management_input(
|
||||
original_input=input,
|
||||
client_input=client_input,
|
||||
merged_input=merged_input,
|
||||
),
|
||||
)
|
||||
local_vars["input"] = input
|
||||
local_vars["model"] = model
|
||||
if model != original_model:
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ import litellm
|
|||
from litellm._logging import verbose_logger
|
||||
from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
ResponseAPIUsage,
|
||||
ResponseInputParam,
|
||||
ResponsesAPIOptionalRequestParams,
|
||||
ResponsesAPIResponse,
|
||||
ResponseText,
|
||||
|
|
@ -36,6 +38,57 @@ from litellm.types.utils import (
|
|||
class ResponsesAPIRequestUtils:
|
||||
"""Helper utils for constructing ResponseAPI requests"""
|
||||
|
||||
@staticmethod
|
||||
def merge_prompt_management_input(
|
||||
original_input: str | ResponseInputParam,
|
||||
client_input: list[AllMessageValues],
|
||||
merged_input: list[AllMessageValues],
|
||||
) -> list[object]:
|
||||
if isinstance(original_input, str):
|
||||
return [*merged_input]
|
||||
|
||||
original_items = tuple(original_input)
|
||||
client_item_ids = frozenset(id(item) for item in client_input)
|
||||
message_positions = tuple(index for index, item in enumerate(original_items) if id(item) in client_item_ids)
|
||||
|
||||
if len(message_positions) == len(original_items):
|
||||
return [*merged_input]
|
||||
if not message_positions:
|
||||
verbose_logger.warning(
|
||||
"Prompt management hook returned messages without Responses API input messages; merged messages were ignored"
|
||||
)
|
||||
return [*original_items]
|
||||
|
||||
corresponding_messages = len(client_input) == len(merged_input) and all(
|
||||
original.get("role") == merged.get("role")
|
||||
and (not isinstance(original.get("id"), str) or original.get("id") == merged.get("id"))
|
||||
for original, merged in zip(client_input, merged_input)
|
||||
)
|
||||
if corresponding_messages:
|
||||
merged_by_position = dict(zip(message_positions, merged_input))
|
||||
return [
|
||||
merged_by_position[index] if index in merged_by_position else item
|
||||
for index, item in enumerate(original_items)
|
||||
]
|
||||
|
||||
all_messages_preserved = all(any(original is merged for merged in merged_input) for original in client_input)
|
||||
if all_messages_preserved:
|
||||
prefixes = {
|
||||
id(original_items[position]): original_items[
|
||||
message_positions[index - 1] + 1 if index else 0 : position
|
||||
]
|
||||
for index, position in enumerate(message_positions)
|
||||
}
|
||||
trailing_items = original_items[message_positions[-1] + 1 :]
|
||||
return [item for merged in merged_input for item in (*prefixes.get(id(merged), ()), merged)] + list(
|
||||
trailing_items
|
||||
)
|
||||
|
||||
verbose_logger.warning(
|
||||
"Prompt management hook replaced Responses API messages; non-message input items were dropped"
|
||||
)
|
||||
return [*merged_input]
|
||||
|
||||
@staticmethod
|
||||
def _check_valid_arg(
|
||||
supported_params: Optional[List[str]],
|
||||
|
|
|
|||
|
|
@ -148,6 +148,10 @@ class SSOConfig(LiteLLMPydanticObjectBase):
|
|||
default=None,
|
||||
description="User info endpoint URL for generic OAuth provider",
|
||||
)
|
||||
generic_scope: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Space-separated OAuth scopes requested from the generic provider, e.g. 'openid email profile'",
|
||||
)
|
||||
|
||||
# Common settings
|
||||
proxy_base_url: Optional[str] = Field(
|
||||
|
|
|
|||
|
|
@ -10,12 +10,16 @@ class ModelInfoMetadata(TypedDict):
|
|||
|
||||
|
||||
class ModelInfoResponse(TypedDict):
|
||||
"""OpenAI-compatible model object. `metadata` is present only when the
|
||||
endpoint is called with include_metadata=true.
|
||||
"""OpenAI-compatible model object. `mode`, `max_input_tokens`, and
|
||||
`max_output_tokens` are attached when the cost map knows them; `metadata`
|
||||
is present only when the endpoint is called with include_metadata=true.
|
||||
"""
|
||||
|
||||
id: str
|
||||
object: Literal["model"]
|
||||
created: int
|
||||
owned_by: str
|
||||
mode: NotRequired[str]
|
||||
max_input_tokens: NotRequired[int]
|
||||
max_output_tokens: NotRequired[int]
|
||||
metadata: NotRequired[ModelInfoMetadata]
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ answers or when credentials/env are missing; they never skip. Pure unit coverage
|
|||
of the harness itself carries no `e2e` marker and runs regardless of whether a
|
||||
proxy is up.
|
||||
|
||||
Lifecycle: the `resources` fixture maps the init -> run -> teardown contract
|
||||
(lifecycle.E2ECase) onto pytest - setup is init(), the test body is run(), and
|
||||
teardown deletes every resource the test created on the long-lived proxy.
|
||||
Lifecycle: the `resources` fixture hands each test a lifecycle.ResourceManager -
|
||||
the test registers a cleanup for every resource it creates, and the fixture's
|
||||
teardown deletes them all on the long-lived proxy, even when the test fails.
|
||||
|
||||
Each suite provides its own `client` fixture (a lifecycle.ResourceClient); these
|
||||
shared fixtures build on it.
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ POLL_TIMEOUT = float(os.environ.get("E2E_POLL_TIMEOUT", "120"))
|
|||
POLL_INTERVAL = float(os.environ.get("E2E_POLL_INTERVAL", "5"))
|
||||
REQUEST_TIMEOUT = float(os.environ.get("E2E_REQUEST_TIMEOUT", "60"))
|
||||
|
||||
EXPECT_RUST = os.environ.get("E2E_EXPECT_RUST", "").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
LOAD_USERS = int(os.environ.get("E2E_LOAD_USERS", "750"))
|
||||
LOAD_SPAWN_RATE = float(os.environ.get("E2E_LOAD_SPAWN_RATE", "50"))
|
||||
LOAD_DURATION_SECONDS = float(os.environ.get("E2E_LOAD_DURATION_SECONDS", "60"))
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
"""Lifecycle contract and resource cleanup for stateful e2e tests.
|
||||
"""Resource cleanup for stateful e2e tests.
|
||||
|
||||
Shared by every e2e suite under tests/e2e/. The proxy under test is
|
||||
long-lived and never reset between tests, so anything a test creates (keys,
|
||||
customers, teams, orgs, users, guardrails, budgets, ...) persists unless
|
||||
explicitly deleted. Every check follows an init -> run -> teardown lifecycle;
|
||||
teardown releases each resource init() created, even when run() raises.
|
||||
|
||||
In pytest terms (see conftest.py): the `resources` fixture's setup is init(),
|
||||
the test body is run(), and the fixture's teardown is teardown().
|
||||
explicitly deleted. The `resources` fixture (see conftest.py) hands each test a
|
||||
ResourceManager; the test registers a cleanup for every resource it creates, and
|
||||
the fixture's teardown releases them all even when the test body raises.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
|
@ -17,38 +15,6 @@ from proxy_client import ProxyClient
|
|||
from models import KeyGenerateBody
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class E2ECase(Protocol):
|
||||
"""A stateful e2e check run against a long-lived proxy.
|
||||
|
||||
init() acquires resources, run() exercises behaviour and asserts, teardown()
|
||||
releases everything init() created. teardown() must run even if init() fails
|
||||
partway or run() raises.
|
||||
"""
|
||||
|
||||
def init(self) -> None: ...
|
||||
|
||||
def run(self) -> None: ...
|
||||
|
||||
def teardown(self) -> None: ...
|
||||
|
||||
|
||||
def run_case(case: E2ECase) -> None:
|
||||
"""Drive a case through its lifecycle: init -> run -> teardown.
|
||||
|
||||
teardown always runs - even when init() fails partway or run() raises (or
|
||||
skips) - so resources the case already registered on the long-lived proxy are
|
||||
released. init() is inside the try because cases register cleanups
|
||||
progressively (e.g. create team, then user, then key), and a failure after
|
||||
the first creation must still release what came before.
|
||||
"""
|
||||
try:
|
||||
case.init()
|
||||
case.run()
|
||||
finally:
|
||||
case.teardown()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ResourceClient(Protocol):
|
||||
"""Proxy operations the convenience creators use. Resource types without a
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from __future__ import annotations
|
|||
|
||||
import pytest
|
||||
|
||||
from e2e_config import unique_marker
|
||||
from e2e_config import EXPECT_RUST, unique_marker
|
||||
from e2e_http import StreamingResponse, require_successful_call, unwrap
|
||||
from endpoints_client import EndpointsClient
|
||||
from lifecycle import ResourceManager
|
||||
|
|
@ -50,6 +50,13 @@ def _assert_streamed_ok(result: StreamingResponse) -> None:
|
|||
assert any("message_stop" in event for event in result.stream_events), (
|
||||
"stream never reached message_stop"
|
||||
)
|
||||
if EXPECT_RUST:
|
||||
assert result.headers.get("x-litellm-rust") == "true", (
|
||||
"E2E_EXPECT_RUST is set, so this gateway must serve /v1/messages through the "
|
||||
"Rust path, but the response carried no x-litellm-rust marker. The request "
|
||||
"still succeeded, which is exactly the failure mode: a gateway whose native "
|
||||
f"extension is unavailable falls back to Python silently. headers={result.headers}"
|
||||
)
|
||||
|
||||
|
||||
class TestAzureFoundryMessages:
|
||||
|
|
|
|||
|
|
@ -1,28 +1,36 @@
|
|||
"""Live e2e: a tiny max_budget on an entity actually blocks requests.
|
||||
|
||||
Each entity is an E2ECase (lifecycle.E2ECase) driven by run_case: init() creates
|
||||
the budgeted entity + a key, run() drives spend until a `budget_exceeded` block,
|
||||
teardown() deletes everything init() created (always runs, even on failure/skip).
|
||||
Covers the entities with no prior live coverage - internal user, end-user,
|
||||
organization, team member - plus key and team. See BUDGET_TEST_COVERAGE_MATRIX.md.
|
||||
One test per budget level (key, team, internal user, end-user, organization,
|
||||
team member): put the tiny cap on that level, drive spend until a
|
||||
`budget_exceeded` block, and where a cap could be confused with a neighbor,
|
||||
prove isolation with an uncapped control key that must keep serving. The
|
||||
capped-key sweep proves the key's own max_budget blocks across mint shapes
|
||||
(personal, team, team-member) with roomy surroundings, so the key-level cap is
|
||||
provably the blocker no matter who the key was minted to.
|
||||
|
||||
A non-budget error fails hard (never a skip); if calls never get blocked, budget
|
||||
enforcement is broken -> fail.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, List, Type
|
||||
|
||||
import pytest
|
||||
|
||||
from budget_client import BudgetClient, is_budget_block
|
||||
from e2e_config import unique_marker
|
||||
from e2e_http import StreamingResponse, require_successful_call
|
||||
from lifecycle import run_case
|
||||
from lifecycle import ResourceManager
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
TINY_CAP = 3e-6
|
||||
ROOMY_CAP = 100.0
|
||||
|
||||
|
||||
def _chat(client: BudgetClient, key: str, *, user: str | None = None) -> StreamingResponse:
|
||||
return client.chat(key, "claude-haiku-4-5", f"spend {unique_marker()}", max_tokens=16, user=user)
|
||||
|
||||
|
||||
def _assert_budget_blocks(client: BudgetClient, key: str, *, user: str = "") -> StreamingResponse:
|
||||
"""Send paid calls until the entity's budget blocks one; return the blocked
|
||||
response so callers can assert on its shape. Key/user/org/member block within
|
||||
|
|
@ -30,13 +38,7 @@ def _assert_budget_blocks(client: BudgetClient, key: str, *, user: str = "") ->
|
|||
enforces off table spend that lands on the batch write, so it takes a few
|
||||
more. A non-budget error fails hard (never a skip)."""
|
||||
for _ in range(40):
|
||||
result = client.chat(
|
||||
key,
|
||||
"claude-haiku-4-5",
|
||||
f"spend {unique_marker()}",
|
||||
max_tokens=16,
|
||||
user=user or None,
|
||||
)
|
||||
result = _chat(client, key, user=user or None)
|
||||
if is_budget_block(result):
|
||||
return result
|
||||
require_successful_call(result)
|
||||
|
|
@ -44,225 +46,154 @@ def _assert_budget_blocks(client: BudgetClient, key: str, *, user: str = "") ->
|
|||
pytest.fail("budget never enforced within the call budget")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _BudgetCase:
|
||||
"""Base E2ECase: a key under some budgeted entity must get blocked.
|
||||
|
||||
Subclasses set up the budgeted entity in init() and register every created id
|
||||
in `_undo` (run LIFO in teardown so a key is deleted before its team/org).
|
||||
"""
|
||||
|
||||
client: BudgetClient
|
||||
key: str = ""
|
||||
_undo: List[Callable[[], None]] = field(
|
||||
default_factory=list
|
||||
) # mutable-ok: per-case teardown registry
|
||||
|
||||
def init(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def run(self) -> None:
|
||||
_assert_budget_blocks(self.client, self.key)
|
||||
|
||||
def teardown(self) -> None:
|
||||
for undo in reversed(self._undo):
|
||||
undo()
|
||||
def _assert_blocked_429(client: BudgetClient, key: str) -> StreamingResponse:
|
||||
blocked = _assert_budget_blocks(client, key)
|
||||
assert blocked.status_code == 429, (
|
||||
f"budget refusal must be 429, got {blocked.status_code}: {blocked.body[:200]}"
|
||||
)
|
||||
return blocked
|
||||
|
||||
|
||||
class KeyBudgetCase(_BudgetCase):
|
||||
"""A bare key (no team_id / user_id) carrying its own max_budget, so only the
|
||||
key-level budget can be the thing that blocks. The refusal must be a 429
|
||||
budget_exceeded; any other error already fails via _assert_budget_blocks."""
|
||||
class TestBudgetBlocksPerLevel:
|
||||
@pytest.mark.covers("quota_management.budget.key.blocks_over_limit")
|
||||
def test_bare_key_blocks_over_its_own_budget(self, client: BudgetClient, resources: ResourceManager) -> None:
|
||||
key = client.generate_key(max_budget=TINY_CAP)
|
||||
resources.defer(lambda: client.delete_key(key))
|
||||
|
||||
def init(self) -> None:
|
||||
self.key = self.client.generate_key(max_budget=3e-6)
|
||||
self._undo.append(lambda: self.client.delete_key(self.key))
|
||||
_assert_blocked_429(client, key)
|
||||
|
||||
def run(self) -> None:
|
||||
blocked = _assert_budget_blocks(self.client, self.key)
|
||||
assert blocked.status_code == 429, (
|
||||
f"budget refusal must be 429, got {blocked.status_code}: {blocked.body[:200]}"
|
||||
)
|
||||
@pytest.mark.covers("quota_management.budget.team.blocks_over_limit")
|
||||
def test_team_budget_blocks_every_team_key(self, client: BudgetClient, resources: ResourceManager) -> None:
|
||||
team_id = client.create_team(alias=f"e2e-budget-team-{unique_marker()}", max_budget=TINY_CAP)
|
||||
resources.defer(lambda: client.delete_team(team_id))
|
||||
spender_key = client.generate_key(team_id=team_id)
|
||||
resources.defer(lambda: client.delete_key(spender_key))
|
||||
sibling_key = client.generate_key(team_id=team_id)
|
||||
resources.defer(lambda: client.delete_key(sibling_key))
|
||||
|
||||
|
||||
class TeamBudgetCase(_BudgetCase):
|
||||
"""An admin caps a whole team: two keys under a tiny-budget team, neither with
|
||||
a key-level budget. Key A is driven until the team cap blocks it; key B's very
|
||||
first call must then be refused too, proving the cap sits on the team, not the
|
||||
key that spent. Both refusals must be 429 budget_exceeded."""
|
||||
|
||||
def init(self) -> None:
|
||||
team_id = self.client.create_team(
|
||||
alias=f"e2e-budget-team-{unique_marker()}", max_budget=3e-6
|
||||
)
|
||||
self._undo.append(lambda: self.client.delete_team(team_id))
|
||||
self.key = self.client.generate_key(team_id=team_id)
|
||||
self._undo.append(lambda: self.client.delete_key(self.key))
|
||||
self._sibling_key = self.client.generate_key(team_id=team_id)
|
||||
self._undo.append(lambda: self.client.delete_key(self._sibling_key))
|
||||
|
||||
def run(self) -> None:
|
||||
blocked = _assert_budget_blocks(self.client, self.key)
|
||||
assert blocked.status_code == 429, (
|
||||
f"budget refusal must be 429, got {blocked.status_code}: {blocked.body[:200]}"
|
||||
)
|
||||
sibling = self.client.chat(
|
||||
self._sibling_key,
|
||||
"claude-haiku-4-5",
|
||||
f"spend {unique_marker()}",
|
||||
max_tokens=16,
|
||||
)
|
||||
_assert_blocked_429(client, spender_key)
|
||||
sibling = _chat(client, sibling_key)
|
||||
assert is_budget_block(sibling) and sibling.status_code == 429, (
|
||||
f"a sibling key on the capped team must get the same 429 budget_exceeded, "
|
||||
f"got {sibling.status_code}: {sibling.body[:200]}"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("quota_management.budget.internal_user.blocks_over_limit")
|
||||
def test_user_budget_enforced_across_all_their_keys(
|
||||
self, client: BudgetClient, resources: ResourceManager
|
||||
) -> None:
|
||||
user_id = client.create_user(max_budget=TINY_CAP)
|
||||
resources.defer(lambda: client.delete_user(user_id))
|
||||
first_key = client.generate_key(user_id=user_id)
|
||||
resources.defer(lambda: client.delete_key(first_key))
|
||||
second_key = client.generate_key(user_id=user_id)
|
||||
resources.defer(lambda: client.delete_key(second_key))
|
||||
team_id = client.create_team(alias=f"e2e-budget-team-{unique_marker()}")
|
||||
resources.defer(lambda: client.delete_team(team_id))
|
||||
client.add_team_member(team_id, user_id)
|
||||
team_key = client.generate_key(team_id=team_id, user_id=user_id)
|
||||
resources.defer(lambda: client.delete_key(team_key))
|
||||
|
||||
class InternalUserBudgetCase(_BudgetCase):
|
||||
"""A user's max_budget follows the person, not the key. The capped user holds
|
||||
two personal keys (no team, no key budgets) plus a team-member key on an
|
||||
uncapped team; once the first personal key is refused, the other two must be
|
||||
refused as well - a second key is not a fresh allowance, and since #32005 the
|
||||
user budget draws down team keys too. All refusals must be 429 budget_exceeded."""
|
||||
|
||||
def init(self) -> None:
|
||||
user_id = self.client.create_user(max_budget=3e-6)
|
||||
self._undo.append(lambda: self.client.delete_user(user_id))
|
||||
self.key = self.client.generate_key(user_id=user_id)
|
||||
self._undo.append(lambda: self.client.delete_key(self.key))
|
||||
self._second_key = self.client.generate_key(user_id=user_id)
|
||||
self._undo.append(lambda: self.client.delete_key(self._second_key))
|
||||
team_id = self.client.create_team(alias=f"e2e-budget-team-{unique_marker()}")
|
||||
self._undo.append(lambda: self.client.delete_team(team_id))
|
||||
self.client.add_team_member(team_id, user_id)
|
||||
self._team_key = self.client.generate_key(team_id=team_id, user_id=user_id)
|
||||
self._undo.append(lambda: self.client.delete_key(self._team_key))
|
||||
|
||||
def run(self) -> None:
|
||||
blocked = _assert_budget_blocks(self.client, self.key)
|
||||
assert blocked.status_code == 429, (
|
||||
f"budget refusal must be 429, got {blocked.status_code}: {blocked.body[:200]}"
|
||||
)
|
||||
for label, key in (("second personal key", self._second_key), ("team-member key", self._team_key)):
|
||||
result = self.client.chat(key, "claude-haiku-4-5", f"spend {unique_marker()}", max_tokens=16)
|
||||
_assert_blocked_429(client, first_key)
|
||||
for label, key in (("second personal key", second_key), ("team-member key", team_key)):
|
||||
result = _chat(client, key)
|
||||
assert is_budget_block(result) and result.status_code == 429, (
|
||||
f"the {label} of a user over budget must get the same 429 budget_exceeded, "
|
||||
f"got {result.status_code}: {result.body[:200]}"
|
||||
)
|
||||
|
||||
|
||||
class EndUserBudgetCase(_BudgetCase):
|
||||
def init(self) -> None:
|
||||
@pytest.mark.covers("quota_management.budget.end_user.blocks_over_limit")
|
||||
def test_end_user_budget_blocks_attributed_calls(
|
||||
self, client: BudgetClient, resources: ResourceManager
|
||||
) -> None:
|
||||
customer = f"e2e-budget-cust-{unique_marker()}"
|
||||
self.client.create_customer(customer, max_budget=3e-6)
|
||||
self._undo.append(lambda: self.client.delete_customers([customer]))
|
||||
self.key = self.client.generate_key(models=["claude-haiku-4-5"])
|
||||
self._undo.append(lambda: self.client.delete_key(self.key))
|
||||
self._customer = customer
|
||||
client.create_customer(customer, max_budget=TINY_CAP)
|
||||
resources.defer(lambda: client.delete_customers([customer]))
|
||||
key = client.generate_key(models=["claude-haiku-4-5"])
|
||||
resources.defer(lambda: client.delete_key(key))
|
||||
|
||||
def run(self) -> None:
|
||||
_assert_budget_blocks(self.client, self.key, user=self._customer)
|
||||
_assert_budget_blocks(client, key, user=customer)
|
||||
|
||||
@pytest.mark.covers("quota_management.budget.organization.blocks_over_limit")
|
||||
def test_org_budget_blocks_keys_under_it(self, client: BudgetClient, resources: ResourceManager) -> None:
|
||||
org_id = client.create_org(max_budget=TINY_CAP, alias=f"e2e-budget-org-{unique_marker()}")
|
||||
resources.defer(lambda: client.delete_org(org_id))
|
||||
team_id = client.create_team(alias=f"e2e-budget-team-{unique_marker()}", organization_id=org_id)
|
||||
resources.defer(lambda: client.delete_team(team_id))
|
||||
key = client.generate_key(team_id=team_id)
|
||||
resources.defer(lambda: client.delete_key(key))
|
||||
|
||||
class OrganizationBudgetCase(_BudgetCase):
|
||||
"""Org carries the tiny budget; the team under it and the key carry none, so
|
||||
the org is the only entity that can block (the historically weak link). The
|
||||
refusal must be a 429 budget_exceeded that names the org as the blocker."""
|
||||
|
||||
def init(self) -> None:
|
||||
self._org_id = self.client.create_org(
|
||||
max_budget=3e-6, alias=f"e2e-budget-org-{unique_marker()}"
|
||||
)
|
||||
self._undo.append(lambda: self.client.delete_org(self._org_id))
|
||||
team_id = self.client.create_team(
|
||||
alias=f"e2e-budget-team-{unique_marker()}", organization_id=self._org_id
|
||||
)
|
||||
self._undo.append(lambda: self.client.delete_team(team_id))
|
||||
self.key = self.client.generate_key(team_id=team_id)
|
||||
self._undo.append(lambda: self.client.delete_key(self.key))
|
||||
|
||||
def run(self) -> None:
|
||||
blocked = _assert_budget_blocks(self.client, self.key)
|
||||
assert blocked.status_code == 429, (
|
||||
f"budget refusal must be 429, got {blocked.status_code}: {blocked.body[:200]}"
|
||||
)
|
||||
assert f"Organization={self._org_id}" in blocked.body, (
|
||||
blocked = _assert_blocked_429(client, key)
|
||||
assert f"Organization={org_id}" in blocked.body, (
|
||||
f"refusal must name the org as the blocker, got: {blocked.body[:200]}"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("quota_management.budget.team_member.blocks_over_limit")
|
||||
def test_member_budget_blocks_without_touching_teammates(
|
||||
self, client: BudgetClient, resources: ResourceManager
|
||||
) -> None:
|
||||
team_id = client.create_team(alias=f"e2e-budget-team-{unique_marker()}", max_budget=ROOMY_CAP)
|
||||
resources.defer(lambda: client.delete_team(team_id))
|
||||
member_id = client.create_user(max_budget=ROOMY_CAP)
|
||||
resources.defer(lambda: client.delete_user(member_id))
|
||||
client.add_team_member(team_id, member_id, max_budget_in_team=TINY_CAP)
|
||||
member_key = client.generate_key(team_id=team_id, user_id=member_id)
|
||||
resources.defer(lambda: client.delete_key(member_key))
|
||||
teammate_id = client.create_user(max_budget=ROOMY_CAP)
|
||||
resources.defer(lambda: client.delete_user(teammate_id))
|
||||
client.add_team_member(team_id, teammate_id)
|
||||
teammate_key = client.generate_key(team_id=team_id, user_id=teammate_id)
|
||||
resources.defer(lambda: client.delete_key(teammate_key))
|
||||
|
||||
class TeamMemberBudgetCase(_BudgetCase):
|
||||
"""Member A's per-team budget is tiny while the team and both members' user
|
||||
budgets are roomy (100.0), so the only cap that can trip is A's: a block
|
||||
proves member-level enforcement and must be a 429 budget_exceeded. Teammate
|
||||
B, uncapped on the same team, must keep serving after A is cut off, proving
|
||||
the member cap does not leak onto the team or its members."""
|
||||
|
||||
def init(self) -> None:
|
||||
self._team_id = self.client.create_team(
|
||||
alias=f"e2e-budget-team-{unique_marker()}", max_budget=100.0
|
||||
)
|
||||
self._undo.append(lambda: self.client.delete_team(self._team_id))
|
||||
self._member_id = self.client.create_user(max_budget=100.0)
|
||||
self._undo.append(lambda: self.client.delete_user(self._member_id))
|
||||
self.client.add_team_member(self._team_id, self._member_id, max_budget_in_team=3e-6)
|
||||
self.key = self.client.generate_key(team_id=self._team_id, user_id=self._member_id)
|
||||
self._undo.append(lambda: self.client.delete_key(self.key))
|
||||
teammate_id = self.client.create_user(max_budget=100.0)
|
||||
self._undo.append(lambda: self.client.delete_user(teammate_id))
|
||||
self.client.add_team_member(self._team_id, teammate_id)
|
||||
self._teammate_key = self.client.generate_key(team_id=self._team_id, user_id=teammate_id)
|
||||
self._undo.append(lambda: self.client.delete_key(self._teammate_key))
|
||||
|
||||
def run(self) -> None:
|
||||
blocked = _assert_budget_blocks(self.client, self.key)
|
||||
assert blocked.status_code == 429, (
|
||||
f"budget refusal must be 429, got {blocked.status_code}: {blocked.body[:200]}"
|
||||
)
|
||||
teammate = self.client.chat(
|
||||
self._teammate_key,
|
||||
"claude-haiku-4-5",
|
||||
f"spend {unique_marker()}",
|
||||
max_tokens=16,
|
||||
)
|
||||
require_successful_call(teammate)
|
||||
_assert_blocked_429(client, member_key)
|
||||
require_successful_call(_chat(client, teammate_key))
|
||||
|
||||
|
||||
def _case_id(case_cls: Type[_BudgetCase]) -> str:
|
||||
return case_cls.__name__
|
||||
class TestKeyBudgetBlocksAcrossKeyKinds:
|
||||
"""The tiny max_budget sits on the key itself while every budget around it
|
||||
(user / team / membership) is roomy, so only the key-level cap can block; the
|
||||
uncapped control key minted to the same surroundings must keep serving after
|
||||
the capped key is refused, proving nothing around the key was the blocker."""
|
||||
|
||||
@pytest.mark.covers("quota_management.budget.key.blocks_over_limit")
|
||||
def test_personal_key_blocks_over_its_own_budget(
|
||||
self, client: BudgetClient, resources: ResourceManager
|
||||
) -> None:
|
||||
user_id = client.create_user(max_budget=ROOMY_CAP)
|
||||
resources.defer(lambda: client.delete_user(user_id))
|
||||
capped_key = client.generate_key(user_id=user_id, max_budget=TINY_CAP)
|
||||
resources.defer(lambda: client.delete_key(capped_key))
|
||||
control_key = client.generate_key(user_id=user_id)
|
||||
resources.defer(lambda: client.delete_key(control_key))
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case_cls",
|
||||
[
|
||||
pytest.param(
|
||||
KeyBudgetCase,
|
||||
marks=pytest.mark.covers("quota_management.budget.key.blocks_over_limit"),
|
||||
),
|
||||
pytest.param(
|
||||
TeamBudgetCase,
|
||||
marks=pytest.mark.covers("quota_management.budget.team.blocks_over_limit"),
|
||||
),
|
||||
pytest.param(
|
||||
InternalUserBudgetCase,
|
||||
marks=pytest.mark.covers("quota_management.budget.internal_user.blocks_over_limit"),
|
||||
),
|
||||
pytest.param(
|
||||
EndUserBudgetCase,
|
||||
marks=pytest.mark.covers("quota_management.budget.end_user.blocks_over_limit"),
|
||||
),
|
||||
pytest.param(
|
||||
OrganizationBudgetCase,
|
||||
marks=pytest.mark.covers("quota_management.budget.organization.blocks_over_limit"),
|
||||
),
|
||||
pytest.param(
|
||||
TeamMemberBudgetCase,
|
||||
marks=pytest.mark.covers("quota_management.budget.team_member.blocks_over_limit"),
|
||||
),
|
||||
],
|
||||
ids=_case_id,
|
||||
)
|
||||
def test_budget_enforcement(
|
||||
client: BudgetClient, case_cls: Type[_BudgetCase]
|
||||
) -> None:
|
||||
run_case(case_cls(client))
|
||||
_assert_blocked_429(client, capped_key)
|
||||
require_successful_call(_chat(client, control_key))
|
||||
|
||||
@pytest.mark.covers("quota_management.budget.key.blocks_over_limit")
|
||||
def test_team_key_blocks_over_its_own_budget(self, client: BudgetClient, resources: ResourceManager) -> None:
|
||||
team_id = client.create_team(alias=f"e2e-key-cap-team-{unique_marker()}", max_budget=ROOMY_CAP)
|
||||
resources.defer(lambda: client.delete_team(team_id))
|
||||
capped_key = client.generate_key(team_id=team_id, max_budget=TINY_CAP)
|
||||
resources.defer(lambda: client.delete_key(capped_key))
|
||||
control_key = client.generate_key(team_id=team_id)
|
||||
resources.defer(lambda: client.delete_key(control_key))
|
||||
|
||||
_assert_blocked_429(client, capped_key)
|
||||
require_successful_call(_chat(client, control_key))
|
||||
|
||||
@pytest.mark.covers("quota_management.budget.key.blocks_over_limit")
|
||||
def test_team_member_key_blocks_over_its_own_budget(
|
||||
self, client: BudgetClient, resources: ResourceManager
|
||||
) -> None:
|
||||
team_id = client.create_team(alias=f"e2e-key-cap-team-{unique_marker()}", max_budget=ROOMY_CAP)
|
||||
resources.defer(lambda: client.delete_team(team_id))
|
||||
member_id = client.create_user(max_budget=ROOMY_CAP)
|
||||
resources.defer(lambda: client.delete_user(member_id))
|
||||
client.add_team_member(team_id, member_id, max_budget_in_team=ROOMY_CAP)
|
||||
capped_key = client.generate_key(team_id=team_id, user_id=member_id, max_budget=TINY_CAP)
|
||||
resources.defer(lambda: client.delete_key(capped_key))
|
||||
control_key = client.generate_key(team_id=team_id, user_id=member_id)
|
||||
resources.defer(lambda: client.delete_key(control_key))
|
||||
|
||||
_assert_blocked_429(client, capped_key)
|
||||
require_successful_call(_chat(client, control_key))
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from lifecycle import ResourceManager
|
|||
pytestmark = pytest.mark.e2e
|
||||
|
||||
TINY_CAP = 3e-6
|
||||
ROOMY_CAP = 100.0
|
||||
WINDOW = "30s"
|
||||
RESET_DEADLINE_SECONDS = 150
|
||||
|
||||
|
|
@ -46,7 +47,7 @@ def _poll_until_serves_again(client: BudgetClient, key: str) -> None:
|
|||
pytest.fail(f"budget never reset within {RESET_DEADLINE_SECONDS}s")
|
||||
|
||||
|
||||
class TestBudgetResetDiagonal:
|
||||
class TestBudgetResetPerLevel:
|
||||
@pytest.mark.covers("quota_management.budget.key.resets_after_window")
|
||||
def test_bare_key_budget_resets_after_window(self, client: BudgetClient, resources: ResourceManager) -> None:
|
||||
key = client.generate_key(max_budget=TINY_CAP, budget_duration=WINDOW)
|
||||
|
|
@ -115,3 +116,42 @@ class TestBudgetResetDiagonal:
|
|||
|
||||
_drive_to_block(client, key)
|
||||
_poll_until_serves_again(client, key)
|
||||
|
||||
|
||||
class TestKeyBudgetResetAcrossKeyKinds:
|
||||
"""The tiny max_budget and its 30s window sit on the key itself while the user,
|
||||
team, and membership around it are roomy (100.0), so the key's own budget is
|
||||
the only thing that can block and the only thing that has to reset."""
|
||||
|
||||
@pytest.mark.covers("quota_management.budget.key.resets_after_window")
|
||||
def test_personal_key_resets_after_window(self, client: BudgetClient, resources: ResourceManager) -> None:
|
||||
user_id = client.create_user(max_budget=ROOMY_CAP)
|
||||
resources.defer(lambda: client.delete_user(user_id))
|
||||
key = client.generate_key(user_id=user_id, max_budget=TINY_CAP, budget_duration=WINDOW)
|
||||
resources.defer(lambda: client.delete_key(key))
|
||||
|
||||
_drive_to_block(client, key)
|
||||
_poll_until_serves_again(client, key)
|
||||
|
||||
@pytest.mark.covers("quota_management.budget.key.resets_after_window")
|
||||
def test_team_key_resets_after_window(self, client: BudgetClient, resources: ResourceManager) -> None:
|
||||
team_id = client.create_team(alias=f"e2e-key-reset-team-{unique_marker()}", max_budget=ROOMY_CAP)
|
||||
resources.defer(lambda: client.delete_team(team_id))
|
||||
key = client.generate_key(team_id=team_id, max_budget=TINY_CAP, budget_duration=WINDOW)
|
||||
resources.defer(lambda: client.delete_key(key))
|
||||
|
||||
_drive_to_block(client, key)
|
||||
_poll_until_serves_again(client, key)
|
||||
|
||||
@pytest.mark.covers("quota_management.budget.key.resets_after_window")
|
||||
def test_team_member_key_resets_after_window(self, client: BudgetClient, resources: ResourceManager) -> None:
|
||||
team_id = client.create_team(alias=f"e2e-key-reset-team-{unique_marker()}", max_budget=ROOMY_CAP)
|
||||
resources.defer(lambda: client.delete_team(team_id))
|
||||
member_id = client.create_user(max_budget=ROOMY_CAP)
|
||||
resources.defer(lambda: client.delete_user(member_id))
|
||||
client.add_team_member(team_id, member_id, max_budget_in_team=ROOMY_CAP)
|
||||
key = client.generate_key(team_id=team_id, user_id=member_id, max_budget=TINY_CAP, budget_duration=WINDOW)
|
||||
resources.defer(lambda: client.delete_key(key))
|
||||
|
||||
_drive_to_block(client, key)
|
||||
_poll_until_serves_again(client, key)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import pytest
|
|||
|
||||
from e2e_http import Result, Success
|
||||
from lifecycle import ResourceManager
|
||||
from models import ChatResponse, SpendLogs, SpendLogsParams
|
||||
from models import ChatResponse, LiteLLMParamsBody, SpendLogs, SpendLogsParams
|
||||
from spend_e2e_client import SpendClient, SpendLogRow, is_ok, unique_marker, unwrap
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
|
@ -232,14 +232,12 @@ def test_cache_hit_is_zero_cost_and_suffixed(
|
|||
rows = client.poll_logs_for_key(
|
||||
scoped_key, predicate=lambda rs: any(r.cache_hit == "True" for r in rs)
|
||||
)
|
||||
cache_rows = [r for r in rows if r.cache_hit == "True"]
|
||||
if not cache_rows:
|
||||
pytest.skip(
|
||||
"no cache-hit row observed; caching may be disabled on this proxy. "
|
||||
f"rows seen: {_summarize(rows)}"
|
||||
)
|
||||
|
||||
cache_row = cache_rows[0]
|
||||
cache_row = _require_row(
|
||||
rows,
|
||||
lambda r: r.cache_hit == "True",
|
||||
"with cache_hit=True (caching is enabled on the e2e proxy, so an identical "
|
||||
"repeat call must hit the cache)",
|
||||
)
|
||||
assert (
|
||||
cache_row.spend or 0
|
||||
) == 0.0, f"cache hit was charged (double-charge regression): {_summarize(rows)}"
|
||||
|
|
@ -504,22 +502,27 @@ def test_each_model_on_a_shared_key_gets_its_own_row(
|
|||
|
||||
@pytest.mark.covers("quota_management.spend_tracking.failure.writes_failure_row")
|
||||
def test_failure_call_writes_failure_status_row(
|
||||
client: SpendClient, scoped_key: str
|
||||
client: SpendClient, resources: ResourceManager, scoped_key: str
|
||||
) -> None:
|
||||
result = client.chat(scoped_key, "gemini-2.5-flash", "", max_tokens=1)
|
||||
if is_ok(result):
|
||||
pytest.skip("call unexpectedly succeeded; could not induce a failure row")
|
||||
model = f"e2e-spend-failure-{unique_marker()}"
|
||||
model_id = client.proxy.create_model(
|
||||
model,
|
||||
LiteLLMParamsBody(model="openai/gpt-5.5", api_key="sk-invalid-e2e-failure-row"),
|
||||
)
|
||||
resources.defer(lambda: client.proxy.delete_model(model_id))
|
||||
|
||||
result = client.chat(scoped_key, model, f"trigger failure {unique_marker()}", max_tokens=1)
|
||||
assert not is_ok(result), (
|
||||
f"a call to a deployment with an invalid upstream key must fail, not succeed: {result}"
|
||||
)
|
||||
|
||||
rows = client.poll_logs_for_key(
|
||||
scoped_key, predicate=lambda rs: any(r.status == "failure" for r in rs)
|
||||
)
|
||||
failure_rows = [r for r in rows if r.status == "failure"]
|
||||
if not failure_rows:
|
||||
pytest.skip(
|
||||
"no failure-status row was logged for the rejected call; "
|
||||
"failure logging is environment-specific"
|
||||
)
|
||||
assert (failure_rows[0].spend or 0) == 0.0, "failed call must not be charged"
|
||||
failure_row = _require_row(
|
||||
rows, lambda r: r.status == "failure", "with status=failure for the rejected call"
|
||||
)
|
||||
assert (failure_row.spend or 0) == 0.0, "failed call must not be charged"
|
||||
|
||||
|
||||
@pytest.mark.covers("quota_management.spend_tracking.spend_calculate.returns_cost")
|
||||
|
|
|
|||
|
|
@ -45,21 +45,14 @@ class TestFilterAnthropicOutputSchema:
|
|||
assert "minimum value: 0" in result["properties"]["age"]["description"]
|
||||
assert "maximum value: 150" in result["properties"]["age"]["description"]
|
||||
# Score had no description, should get one from constraints
|
||||
assert (
|
||||
"exclusive minimum value: 0" in result["properties"]["score"]["description"]
|
||||
)
|
||||
assert (
|
||||
"exclusive maximum value: 100"
|
||||
in result["properties"]["score"]["description"]
|
||||
)
|
||||
assert "exclusive minimum value: 0" in result["properties"]["score"]["description"]
|
||||
assert "exclusive maximum value: 100" in result["properties"]["score"]["description"]
|
||||
|
||||
def test_removes_string_constraints(self):
|
||||
"""Test that minLength/maxLength are removed from string schemas."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "minLength": 1, "maxLength": 100}
|
||||
},
|
||||
"properties": {"name": {"type": "string", "minLength": 1, "maxLength": 100}},
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
|
@ -154,3 +147,203 @@ class TestFilterAnthropicOutputSchema:
|
|||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert result == schema # Should be unchanged
|
||||
|
||||
def test_removes_uniqueitems(self):
|
||||
"""Test that uniqueItems is removed from array schemas.
|
||||
|
||||
Reproduces the 400 ``invalid_request_error``:
|
||||
"output_format.schema: For 'array' type, property 'uniqueItems' is not
|
||||
supported".
|
||||
"""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"uniqueItems": True,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert "uniqueItems" not in result["properties"]["tags"]
|
||||
assert result["properties"]["tags"]["items"] == {"type": "string"}
|
||||
# Constraint intent preserved in the description
|
||||
assert "all array items must be unique" in result["properties"]["tags"]["description"]
|
||||
|
||||
def test_removes_contains_constraints(self):
|
||||
"""Test that contains/minContains/maxContains are removed from arrays."""
|
||||
schema = {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"contains": {"type": "integer", "const": 1},
|
||||
"minContains": 1,
|
||||
"maxContains": 3,
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert "contains" not in result
|
||||
assert "minContains" not in result
|
||||
assert "maxContains" not in result
|
||||
assert result["items"] == {"type": "integer"}
|
||||
# The contains sub-schema is serialized into the advisory note so the model
|
||||
# knows what item the array must contain.
|
||||
assert "array must contain an item matching:" in result["description"]
|
||||
assert '"const": 1' in result["description"]
|
||||
assert "minimum number of matching items: 1" in result["description"]
|
||||
assert "maximum number of matching items: 3" in result["description"]
|
||||
|
||||
def test_removes_object_property_constraints(self):
|
||||
"""Test that minProperties/maxProperties are removed from object schemas."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"a": {"type": "string"}},
|
||||
"minProperties": 1,
|
||||
"maxProperties": 5,
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert "minProperties" not in result
|
||||
assert "maxProperties" not in result
|
||||
assert "minimum number of properties: 1" in result["description"]
|
||||
assert "maximum number of properties: 5" in result["description"]
|
||||
|
||||
def test_uniqueitems_false_skips_misleading_note(self):
|
||||
"""``uniqueItems: false`` is stripped but must not add a 'unique' note."""
|
||||
schema = {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"uniqueItems": False,
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert "uniqueItems" not in result
|
||||
# A disabled constraint imposes no requirement -> no advisory note
|
||||
assert "unique" not in result.get("description", "")
|
||||
|
||||
def test_removes_multipleof(self):
|
||||
"""multipleOf is rejected by Anthropic for integer and number types."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"n": {"type": "integer", "multipleOf": 5}},
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert "multipleOf" not in result["properties"]["n"]
|
||||
assert "must be a multiple of 5" in result["properties"]["n"]["description"]
|
||||
|
||||
def test_removes_conditional_and_negation_keywords(self):
|
||||
"""if/then/else and not are rejected by Anthropic and stripped into notes."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"kind": {"type": "string"}, "sound": {"type": "string", "not": {"const": "moo"}}},
|
||||
"if": {"properties": {"kind": {"const": "dog"}}},
|
||||
"then": {"required": ["sound"]},
|
||||
"else": {"required": ["kind"]},
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert "if" not in result
|
||||
assert "then" not in result
|
||||
assert "else" not in result
|
||||
assert "not" not in result["properties"]["sound"]
|
||||
assert 'conditional (if): {"properties": {"kind": {"const": "dog"}}}' in result["description"]
|
||||
assert 'conditional (then): {"required": ["sound"]}' in result["description"]
|
||||
assert 'conditional (else): {"required": ["kind"]}' in result["description"]
|
||||
assert 'must not match: {"const": "moo"}' in result["properties"]["sound"]["description"]
|
||||
|
||||
def test_removes_object_shape_keywords(self):
|
||||
"""patternProperties/propertyNames/dependent*/unevaluatedProperties are stripped."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"first": {"type": "string"}},
|
||||
"patternProperties": {"^x": {"type": "string"}},
|
||||
"propertyNames": {"pattern": "^[a-z]+$"},
|
||||
"dependentRequired": {"first": ["last"]},
|
||||
"dependentSchemas": {"first": {"required": ["last"]}},
|
||||
"unevaluatedProperties": {"type": "string"},
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
for field in (
|
||||
"patternProperties",
|
||||
"propertyNames",
|
||||
"dependentRequired",
|
||||
"dependentSchemas",
|
||||
"unevaluatedProperties",
|
||||
):
|
||||
assert field not in result
|
||||
assert 'properties whose names match each pattern must satisfy: {"^x": {"type": "string"}}' in result["description"]
|
||||
assert 'property names must satisfy: {"pattern": "^[a-z]+$"}' in result["description"]
|
||||
assert 'dependent required properties: {"first": ["last"]}' in result["description"]
|
||||
assert 'dependent schemas: {"first": {"required": ["last"]}}' in result["description"]
|
||||
assert 'unevaluated properties must satisfy: {"type": "string"}' in result["description"]
|
||||
|
||||
def test_removes_prefixitems(self):
|
||||
"""prefixItems is rejected by Anthropic for array types."""
|
||||
schema = {
|
||||
"type": "array",
|
||||
"prefixItems": [{"type": "number"}, {"type": "string"}],
|
||||
"items": {"type": "number"},
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert "prefixItems" not in result
|
||||
assert result["items"] == {"type": "number"}
|
||||
assert 'leading items must match, in order: [{"type": "number"}, {"type": "string"}]' in result["description"]
|
||||
|
||||
def test_oneof_rewritten_to_anyof(self):
|
||||
"""oneOf 400s ("Schema type 'oneOf' is not supported") and becomes anyOf, like the SDK."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"id": {"oneOf": [{"type": "string", "minLength": 1}, {"type": "integer"}]}},
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
id_schema = result["properties"]["id"]
|
||||
assert "oneOf" not in id_schema
|
||||
assert [v["type"] for v in id_schema["anyOf"]] == ["string", "integer"]
|
||||
assert "minLength" not in id_schema["anyOf"][0]
|
||||
assert "minimum length: 1" in id_schema["anyOf"][0]["description"]
|
||||
|
||||
def test_oneof_merges_into_existing_anyof(self):
|
||||
schema = {
|
||||
"anyOf": [{"type": "string"}],
|
||||
"oneOf": [{"type": "integer"}],
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert "oneOf" not in result
|
||||
assert [v["type"] for v in result["anyOf"]] == ["string", "integer"]
|
||||
|
||||
def test_constraint_note_order_is_deterministic(self):
|
||||
"""Note order must not depend on set iteration order (PYTHONHASHSEED), or the
|
||||
serialized request differs across proxy workers and breaks caching."""
|
||||
schema = {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 1,
|
||||
"maxItems": 10,
|
||||
"uniqueItems": True,
|
||||
"minContains": 2,
|
||||
"maxContains": 3,
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert result["description"] == (
|
||||
"Note: minimum number of items: 1, maximum number of items: 10, "
|
||||
"all array items must be unique, minimum number of matching items: 2, "
|
||||
"maximum number of matching items: 3."
|
||||
)
|
||||
|
|
|
|||
143
tests/proxy_migration_tests/test_offline_image_migration.py
Normal file
143
tests/proxy_migration_tests/test_offline_image_migration.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""Image-level regression net for the prisma bake in the shipped runtime image.
|
||||
|
||||
Boots a built image's migration entrypoint the way an OpenShift / air-gapped
|
||||
deployment does (an internal-only network with no egress, an arbitrary non-root
|
||||
uid in GID 0) against a brand-new Postgres, and asserts the schema was created.
|
||||
|
||||
This catches the whole failure class, not one symptom: a bake that only works
|
||||
under `docker run` as the default uid with network still passes every existing
|
||||
check, because the migration entrypoint exits 0 even when it applied nothing.
|
||||
Asserting the table count is what turns that silent success into a hard fail.
|
||||
|
||||
Gated on LITELLM_IMAGE (the tag of the image to exercise) so it is skipped in
|
||||
the normal unit-test run and exercised only where an image has been built (the
|
||||
image-scan workflow). Requires a working docker CLI.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
IMAGE = os.getenv("LITELLM_IMAGE")
|
||||
POSTGRES_IMAGE = os.getenv("LITELLM_TEST_POSTGRES_IMAGE", "postgres:16-alpine")
|
||||
MIN_TABLES = int(os.getenv("LITELLM_TEST_MIN_TABLES", "20"))
|
||||
NON_ROOT_UID = "12345:0" # arbitrary uid in GID 0, as OpenShift restricted-v2 assigns
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.skipif(IMAGE is None, reason="requires a built image (set LITELLM_IMAGE)"),
|
||||
pytest.mark.skipif(shutil.which("docker") is None, reason="requires the docker CLI"),
|
||||
]
|
||||
|
||||
|
||||
def _docker(*args: str, check: bool = True) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["docker", *args], capture_output=True, text=True, check=check
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def offline_postgres():
|
||||
"""A fresh Postgres reachable only over an internal-only (no egress) network.
|
||||
|
||||
Yields (network_name, postgres_host). Both are torn down afterwards.
|
||||
"""
|
||||
run_id = f"offlinemig-{uuid.uuid4().hex[:8]}"
|
||||
network = f"{run_id}-net"
|
||||
pg = f"{run_id}-pg"
|
||||
|
||||
# Pull Postgres while egress still exists; the internal network below has none.
|
||||
_docker("pull", "--quiet", POSTGRES_IMAGE)
|
||||
# --internal => containers on this network cannot reach the internet, so a
|
||||
# prisma engine download (binaries.prisma.sh / npm) fails instead of masking
|
||||
# a non-self-contained bake.
|
||||
_docker("network", "create", "--internal", network)
|
||||
try:
|
||||
_docker(
|
||||
"run", "-d", "--name", pg, "--network", network,
|
||||
"-e", "POSTGRES_PASSWORD=pw", "-e", "POSTGRES_DB=litellm",
|
||||
POSTGRES_IMAGE,
|
||||
)
|
||||
_wait_until_ready(pg)
|
||||
yield network, pg
|
||||
finally:
|
||||
_docker("rm", "-f", pg, check=False)
|
||||
_docker("network", "rm", network, check=False)
|
||||
|
||||
|
||||
def _wait_until_ready(pg: str, attempts: int = 60) -> None:
|
||||
for _ in range(attempts):
|
||||
running = _docker(
|
||||
"ps", "--filter", f"name={pg}", "--filter", "status=running",
|
||||
"--format", "{{.Names}}", check=False,
|
||||
).stdout
|
||||
if pg not in running:
|
||||
logs = _docker("logs", pg, check=False).stdout + _docker("logs", pg, check=False).stderr
|
||||
pytest.fail(f"postgres container is not running:\n{logs}")
|
||||
ready = _docker(
|
||||
"exec", pg, "pg_isready", "-U", "postgres", "-d", "litellm", check=False
|
||||
)
|
||||
if ready.returncode == 0:
|
||||
return
|
||||
subprocess.run(["sleep", "1"])
|
||||
pytest.fail(f"postgres never became ready after {attempts}s")
|
||||
|
||||
|
||||
def _table_count(pg: str) -> int:
|
||||
result = _docker(
|
||||
"exec", pg, "psql", "-U", "postgres", "-d", "litellm", "-tAc",
|
||||
"SELECT count(*) FROM information_schema.tables WHERE table_schema='public';",
|
||||
)
|
||||
return int(result.stdout.strip() or "0")
|
||||
|
||||
|
||||
def test_migration_offline_as_non_root_uid(offline_postgres):
|
||||
"""The migration entrypoint creates the full schema offline as an arbitrary uid.
|
||||
|
||||
Reproduces the OpenShift / air-gapped failure: on the pre-fix image the
|
||||
migration exits 0 having created 0 tables (every DB endpoint then 500s on
|
||||
missing columns); a self-contained bake creates the full schema.
|
||||
"""
|
||||
network, pg = offline_postgres
|
||||
assert IMAGE is not None
|
||||
|
||||
migrate = _docker(
|
||||
"run", "--rm", "--network", network, "--user", NON_ROOT_UID,
|
||||
"-e", f"DATABASE_URL=postgresql://postgres:pw@{pg}:5432/litellm",
|
||||
"-e", "LITELLM_MASTER_KEY=sk-offline-migration-test",
|
||||
"-e", "DISABLE_SCHEMA_UPDATE=false",
|
||||
"-w", "/app", "--entrypoint", "python",
|
||||
IMAGE, "litellm/proxy/prisma_migration.py",
|
||||
check=False,
|
||||
)
|
||||
tables = _table_count(pg)
|
||||
|
||||
assert migrate.returncode == 0, (
|
||||
f"migration entrypoint exited {migrate.returncode} offline as uid {NON_ROOT_UID}\n"
|
||||
f"stdout:\n{migrate.stdout}\nstderr:\n{migrate.stderr}"
|
||||
)
|
||||
assert tables >= MIN_TABLES, (
|
||||
f"only {tables} tables created (need >= {MIN_TABLES}) offline as uid {NON_ROOT_UID}. "
|
||||
"The prisma bake is not self-contained: it needs a runtime download or a "
|
||||
"writable HOME/cache, so OpenShift and air-gapped deployments start on an "
|
||||
f"empty database.\nstdout:\n{migrate.stdout}\nstderr:\n{migrate.stderr}"
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_cache_env_not_read_only():
|
||||
"""No runtime cache env var may point at the world-read-only /opt/prisma bake.
|
||||
|
||||
/opt/prisma is baked `a+rX` (no write). Pointing XDG_CACHE_HOME (or any cache
|
||||
var an XDG-aware library honours) there would deny writes for every uid, so
|
||||
guard against a future edit reintroducing that.
|
||||
"""
|
||||
assert IMAGE is not None
|
||||
env = _docker("run", "--rm", "--entrypoint", "env", IMAGE).stdout
|
||||
offenders = [
|
||||
line for line in env.splitlines()
|
||||
if line.startswith(("XDG_CACHE_HOME=", "XDG_DATA_HOME=", "HOME="))
|
||||
and line.split("=", 1)[1].startswith("/opt/prisma")
|
||||
]
|
||||
assert not offenders, f"cache/home env points at the read-only bake: {offenders}"
|
||||
|
|
@ -1252,6 +1252,17 @@ async def test_create_team_member_add(prisma_client, new_member_method):
|
|||
return_value=LiteLLM_TeamTableCachedObj(team_id="1234")
|
||||
)
|
||||
|
||||
tx_mock = AsyncMock()
|
||||
tx_mock.query_raw = AsyncMock(return_value=[{"members_with_roles": []}])
|
||||
tx_mock.litellm_teamtable = team_mock_client
|
||||
tx_cm = MagicMock()
|
||||
tx_cm.__aenter__ = AsyncMock(return_value=tx_mock)
|
||||
tx_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
original_tx = litellm.proxy.proxy_server.prisma_client.tx
|
||||
litellm.proxy.proxy_server.prisma_client.tx = MagicMock(
|
||||
return_value=tx_cm
|
||||
)
|
||||
|
||||
print(f"team_member_add_request={team_member_add_request}")
|
||||
await team_member_add(
|
||||
data=team_member_add_request,
|
||||
|
|
@ -1273,6 +1284,7 @@ async def test_create_team_member_add(prisma_client, new_member_method):
|
|||
)
|
||||
|
||||
litellm.proxy.proxy_server.prisma_client.db.litellm_teamtable = original_val
|
||||
litellm.proxy.proxy_server.prisma_client.tx = original_tx
|
||||
|
||||
|
||||
@pytest.mark.parametrize("team_member_role", ["admin", "user"])
|
||||
|
|
@ -1434,42 +1446,51 @@ async def test_create_team_member_add_team_admin(
|
|||
mock_litellm_usertable.find_unique = AsyncMock(return_value=None)
|
||||
|
||||
team_mock_client = AsyncMock()
|
||||
original_val = getattr(
|
||||
litellm.proxy.proxy_server.prisma_client.db, "litellm_teamtable"
|
||||
)
|
||||
litellm.proxy.proxy_server.prisma_client.db.litellm_teamtable = team_mock_client
|
||||
|
||||
team_mock_client.update = AsyncMock(
|
||||
return_value=LiteLLM_TeamTableCachedObj(team_id="1234")
|
||||
)
|
||||
|
||||
try:
|
||||
await team_member_add(
|
||||
data=team_member_add_request,
|
||||
user_api_key_dict=valid_token,
|
||||
tx_mock = AsyncMock()
|
||||
tx_mock.query_raw = AsyncMock(return_value=[{"members_with_roles": []}])
|
||||
tx_mock.litellm_teamtable = team_mock_client
|
||||
tx_cm = MagicMock()
|
||||
tx_cm.__aenter__ = AsyncMock(return_value=tx_mock)
|
||||
tx_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
litellm.proxy.proxy_server.prisma_client.db,
|
||||
"litellm_teamtable",
|
||||
team_mock_client,
|
||||
),
|
||||
patch.object(
|
||||
litellm.proxy.proxy_server.prisma_client,
|
||||
"tx",
|
||||
MagicMock(return_value=tx_cm),
|
||||
),
|
||||
):
|
||||
try:
|
||||
await team_member_add(
|
||||
data=team_member_add_request,
|
||||
user_api_key_dict=valid_token,
|
||||
)
|
||||
except HTTPException as e:
|
||||
if user_role == "user":
|
||||
assert e.status_code == 403
|
||||
return
|
||||
else:
|
||||
raise e
|
||||
|
||||
mock_client.assert_called()
|
||||
|
||||
assert (
|
||||
mock_client.call_args.kwargs["data"]["create"]["max_budget"]
|
||||
== litellm.max_internal_user_budget
|
||||
)
|
||||
assert (
|
||||
mock_client.call_args.kwargs["data"]["create"]["budget_duration"]
|
||||
== litellm.internal_user_budget_duration
|
||||
)
|
||||
except HTTPException as e:
|
||||
if user_role == "user":
|
||||
assert e.status_code == 403
|
||||
return
|
||||
else:
|
||||
raise e
|
||||
|
||||
mock_client.assert_called()
|
||||
|
||||
print(f"mock_client.call_args: {mock_client.call_args}")
|
||||
print("mock_client.call_args.kwargs: {}".format(mock_client.call_args.kwargs))
|
||||
|
||||
assert (
|
||||
mock_client.call_args.kwargs["data"]["create"]["max_budget"]
|
||||
== litellm.max_internal_user_budget
|
||||
)
|
||||
assert (
|
||||
mock_client.call_args.kwargs["data"]["create"]["budget_duration"]
|
||||
== litellm.internal_user_budget_duration
|
||||
)
|
||||
|
||||
litellm.proxy.proxy_server.prisma_client.db.litellm_teamtable = original_val
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import unittest
|
||||
from datetime import datetime, time, timezone
|
||||
from unittest.mock import patch
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from litellm.litellm_core_utils.duration_parser import get_next_standardized_reset_time
|
||||
import litellm.litellm_core_utils.duration_parser as duration_parser
|
||||
from litellm.litellm_core_utils.duration_parser import (
|
||||
duration_in_seconds,
|
||||
get_next_standardized_reset_time,
|
||||
)
|
||||
|
||||
|
||||
class TestStandardizedResetTime(unittest.TestCase):
|
||||
|
|
@ -316,5 +321,69 @@ class TestResetTimeOfDay(unittest.TestCase):
|
|||
)
|
||||
|
||||
|
||||
class TestWordFormBudgetDurations(unittest.TestCase):
|
||||
"""The Admin UI historically persisted word-form budget durations
|
||||
(hourly/daily/weekly/monthly). They must resolve to their real interval
|
||||
instead of silently collapsing to a next-midnight (daily) reset.
|
||||
"""
|
||||
|
||||
def test_word_forms_map_to_correct_reset_times(self):
|
||||
base_time = datetime(2023, 5, 17, 15, 20, 30, tzinfo=timezone.utc)
|
||||
|
||||
self.assertEqual(
|
||||
get_next_standardized_reset_time("hourly", base_time, "UTC"),
|
||||
datetime(2023, 5, 17, 16, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
self.assertEqual(
|
||||
get_next_standardized_reset_time("daily", base_time, "UTC"),
|
||||
datetime(2023, 5, 18, 0, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
self.assertEqual(
|
||||
get_next_standardized_reset_time("weekly", base_time, "UTC"),
|
||||
datetime(2023, 5, 22, 0, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
self.assertEqual(
|
||||
get_next_standardized_reset_time("monthly", base_time, "UTC"),
|
||||
datetime(2023, 6, 1, 0, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
def test_word_forms_are_not_all_collapsed_to_daily(self):
|
||||
base_time = datetime(2023, 5, 17, 15, 20, 30, tzinfo=timezone.utc)
|
||||
results = {
|
||||
word: get_next_standardized_reset_time(word, base_time, "UTC")
|
||||
for word in ("hourly", "daily", "weekly", "monthly")
|
||||
}
|
||||
self.assertEqual(len(set(results.values())), len(results))
|
||||
|
||||
def test_word_forms_match_canonical_int_unit_forms(self):
|
||||
base_time = datetime(2023, 5, 17, 15, 20, 30, tzinfo=timezone.utc)
|
||||
for word, canonical in (("hourly", "1h"), ("daily", "24h"), ("weekly", "7d"), ("monthly", "30d")):
|
||||
self.assertEqual(
|
||||
get_next_standardized_reset_time(word, base_time, "UTC"),
|
||||
get_next_standardized_reset_time(canonical, base_time, "UTC"),
|
||||
)
|
||||
|
||||
def test_word_forms_are_case_and_whitespace_insensitive(self):
|
||||
base_time = datetime(2023, 5, 17, 15, 20, 30, tzinfo=timezone.utc)
|
||||
self.assertEqual(
|
||||
get_next_standardized_reset_time(" Monthly ", base_time, "UTC"),
|
||||
datetime(2023, 6, 1, 0, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
def test_duration_in_seconds_accepts_word_forms(self):
|
||||
self.assertEqual(duration_in_seconds("hourly"), 3600)
|
||||
self.assertEqual(duration_in_seconds("daily"), 86400)
|
||||
self.assertEqual(duration_in_seconds("weekly"), 604800)
|
||||
self.assertEqual(duration_in_seconds("monthly"), 2592000)
|
||||
|
||||
def test_invalid_duration_logs_warning_and_falls_back(self):
|
||||
base_time = datetime(2023, 5, 15, 15, 0, 0, tzinfo=timezone.utc)
|
||||
with patch.object(duration_parser.verbose_logger, "warning") as mock_warning:
|
||||
result = get_next_standardized_reset_time("garbage", base_time, "UTC")
|
||||
self.assertEqual(result, datetime(2023, 5, 16, 0, 0, 0, tzinfo=timezone.utc))
|
||||
mock_warning.assert_called_once()
|
||||
self.assertIn("garbage", mock_warning.call_args.args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
"""
|
||||
Coverage for filter_anthropic_output_schema's array/object constraint stripping.
|
||||
|
||||
Mirrors tests/litellm/llms/anthropic/test_anthropic_schema_filter.py, but lives
|
||||
under tests/test_litellm/ so the coverage-uploading CI job exercises the stripped
|
||||
keyword handling (uniqueItems / contains / minProperties / maxProperties plus
|
||||
multipleOf / patternProperties / propertyNames / dependentRequired /
|
||||
dependentSchemas / unevaluatedProperties / if / then / else / not / prefixItems),
|
||||
the ``uniqueItems: false`` branch, the oneOf to anyOf rewrite, and the
|
||||
deterministic note ordering.
|
||||
"""
|
||||
|
||||
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
|
||||
|
||||
|
||||
class TestOutputFormatArrayObjectConstraints:
|
||||
def test_removes_uniqueitems(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"uniqueItems": True,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert "uniqueItems" not in result["properties"]["tags"]
|
||||
assert "all array items must be unique" in result["properties"]["tags"]["description"]
|
||||
|
||||
def test_uniqueitems_false_skips_misleading_note(self):
|
||||
schema = {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"uniqueItems": False,
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert "uniqueItems" not in result
|
||||
assert "unique" not in result.get("description", "")
|
||||
|
||||
def test_removes_contains_constraints(self):
|
||||
schema = {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"contains": {"type": "integer", "const": 1},
|
||||
"minContains": 1,
|
||||
"maxContains": 3,
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert "contains" not in result
|
||||
assert "minContains" not in result
|
||||
assert "maxContains" not in result
|
||||
assert "array must contain an item matching:" in result["description"]
|
||||
assert '"const": 1' in result["description"]
|
||||
|
||||
def test_removes_object_property_constraints(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"a": {"type": "string"}},
|
||||
"minProperties": 1,
|
||||
"maxProperties": 5,
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert "minProperties" not in result
|
||||
assert "maxProperties" not in result
|
||||
assert "minimum number of properties: 1" in result["description"]
|
||||
assert "maximum number of properties: 5" in result["description"]
|
||||
|
||||
def test_removes_remaining_rejected_keywords(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"n": {"type": "integer", "multipleOf": 5},
|
||||
"pair": {"type": "array", "prefixItems": [{"type": "number"}], "items": {"type": "number"}},
|
||||
"color": {"type": "string", "not": {"const": "red"}},
|
||||
},
|
||||
"patternProperties": {"^x": {"type": "string"}},
|
||||
"propertyNames": {"pattern": "^[a-z]+$"},
|
||||
"dependentRequired": {"n": ["pair"]},
|
||||
"dependentSchemas": {"n": {"required": ["pair"]}},
|
||||
"unevaluatedProperties": {"type": "string"},
|
||||
"if": {"properties": {"n": {"const": 5}}},
|
||||
"then": {"required": ["pair"]},
|
||||
"else": {"required": ["color"]},
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
for field in (
|
||||
"patternProperties",
|
||||
"propertyNames",
|
||||
"dependentRequired",
|
||||
"dependentSchemas",
|
||||
"unevaluatedProperties",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
):
|
||||
assert field not in result
|
||||
assert "multipleOf" not in result["properties"]["n"]
|
||||
assert "must be a multiple of 5" in result["properties"]["n"]["description"]
|
||||
assert "prefixItems" not in result["properties"]["pair"]
|
||||
assert 'leading items must match, in order: [{"type": "number"}]' in result["properties"]["pair"]["description"]
|
||||
assert "not" not in result["properties"]["color"]
|
||||
assert 'must not match: {"const": "red"}' in result["properties"]["color"]["description"]
|
||||
assert 'conditional (if): {"properties": {"n": {"const": 5}}}' in result["description"]
|
||||
|
||||
def test_oneof_rewritten_to_anyof(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"id": {"oneOf": [{"type": "string", "minLength": 1}, {"type": "integer"}]}},
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
id_schema = result["properties"]["id"]
|
||||
assert "oneOf" not in id_schema
|
||||
assert [v["type"] for v in id_schema["anyOf"]] == ["string", "integer"]
|
||||
assert "minLength" not in id_schema["anyOf"][0]
|
||||
|
||||
def test_constraint_note_order_is_deterministic(self):
|
||||
schema = {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 1,
|
||||
"maxItems": 10,
|
||||
"uniqueItems": True,
|
||||
"minContains": 2,
|
||||
"maxContains": 3,
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert result["description"] == (
|
||||
"Note: minimum number of items: 1, maximum number of items: 10, "
|
||||
"all array items must be unique, minimum number of matching items: 2, "
|
||||
"maximum number of matching items: 3."
|
||||
)
|
||||
|
|
@ -70,25 +70,33 @@ class TestAgentCoreAcceptHeader:
|
|||
"""
|
||||
End-to-end test: verify Accept header appears in the final HTTP request
|
||||
when using JWT auth through litellm.completion().
|
||||
|
||||
No exception swallowing: if completion() raises (for example because the
|
||||
injected client was silently ignored and a real network call was made),
|
||||
the test must fail with that error, not a misleading mock assertion.
|
||||
"""
|
||||
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
||||
|
||||
client = HTTPHandler()
|
||||
with patch.object(client, "post", return_value=MagicMock()) as mock_post:
|
||||
try:
|
||||
litellm.completion(
|
||||
model="bedrock/agentcore/arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/test_runtime",
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
api_key="test-jwt-token",
|
||||
client=client,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"content-type": "application/json"}
|
||||
mock_response.json.return_value = {
|
||||
"result": {"role": "assistant", "content": [{"text": "agent reply"}]}
|
||||
}
|
||||
|
||||
mock_post.assert_called_once()
|
||||
headers = mock_post.call_args.kwargs["headers"]
|
||||
assert "Accept" in headers
|
||||
assert headers["Accept"] == "application/json, text/event-stream"
|
||||
with patch.object(client, "post", return_value=mock_response) as mock_post:
|
||||
response = litellm.completion(
|
||||
model="bedrock/agentcore/arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/test_runtime",
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
api_key="test-jwt-token",
|
||||
client=client,
|
||||
)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
headers = mock_post.call_args.kwargs["headers"]
|
||||
assert headers["Accept"] == "application/json, text/event-stream"
|
||||
assert response.choices[0].message.content == "agent reply"
|
||||
|
||||
|
||||
class TestAgentCoreJsonResponseParsing:
|
||||
|
|
|
|||
|
|
@ -633,7 +633,8 @@ def test_parallel_tool_calls_config_kept_for_sonnet_5():
|
|||
)
|
||||
|
||||
assert data["additionalModelRequestFields"]["tool_choice"] == {
|
||||
"disable_parallel_tool_use": True
|
||||
"type": "auto",
|
||||
"disable_parallel_tool_use": True,
|
||||
}
|
||||
finally:
|
||||
litellm.model_cost = old_cost
|
||||
|
|
@ -4251,6 +4252,49 @@ def test_parallel_tool_calls_older_model_drops_disable_flag():
|
|||
assert "parallel_tool_calls" not in additional
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"parallel_tool_calls, expected_disable",
|
||||
[(True, False), (False, True)],
|
||||
)
|
||||
def test_parallel_tool_calls_emits_typed_auto_tool_choice(parallel_tool_calls, expected_disable):
|
||||
config = AmazonConverseConfig()
|
||||
model = "us.anthropic.claude-opus-4-8"
|
||||
messages = [{"role": "user", "content": "What's the weather in SF and NYC?"}]
|
||||
|
||||
optional_params = config.map_openai_params(
|
||||
non_default_params={"parallel_tool_calls": parallel_tool_calls, "tools": _TOOL_PARAM},
|
||||
optional_params={},
|
||||
model=model,
|
||||
drop_params=False,
|
||||
)
|
||||
|
||||
request_data = config.transform_request(
|
||||
model=model,
|
||||
messages=messages,
|
||||
optional_params=optional_params,
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert request_data["additionalModelRequestFields"]["tool_choice"] == {
|
||||
"type": "auto",
|
||||
"disable_parallel_tool_use": expected_disable,
|
||||
}
|
||||
|
||||
|
||||
def test_parallel_tool_use_merge_preserves_user_tool_choice_type():
|
||||
merged = AmazonConverseConfig._merge_parallel_tool_use_config(
|
||||
{"tool_choice": {"type": "tool", "name": "get_weather", "disable_parallel_tool_use": False}},
|
||||
{"tool_choice": {"type": "auto", "disable_parallel_tool_use": True}},
|
||||
)
|
||||
|
||||
assert merged["tool_choice"] == {
|
||||
"type": "tool",
|
||||
"name": "get_weather",
|
||||
"disable_parallel_tool_use": True,
|
||||
}
|
||||
|
||||
|
||||
class TestBedrockMinThinkingBudgetTokens:
|
||||
"""Test that thinking.budget_tokens is clamped to the Bedrock minimum (1024)."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
|
@ -16,22 +15,7 @@ MOCK_EMBEDDING_RESPONSE = [[0.1, 0.2, 0.3, 0.4, 0.5]]
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def reload_huggingface_modules():
|
||||
"""
|
||||
Reload modules to ensure fresh references after conftest reloads litellm.
|
||||
This ensures the HTTPHandler class being patched is the same one used by
|
||||
the embedding handler during parallel test execution.
|
||||
"""
|
||||
import litellm.llms.custom_httpx.http_handler as http_handler_module
|
||||
import litellm.llms.huggingface.embedding.handler as hf_embedding_handler_module
|
||||
|
||||
importlib.reload(http_handler_module)
|
||||
importlib.reload(hf_embedding_handler_module)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_embedding_http_handler(reload_huggingface_modules):
|
||||
def mock_embedding_http_handler():
|
||||
"""Fixture to mock the HTTP handler for embedding tests"""
|
||||
with patch("litellm.llms.custom_httpx.http_handler.HTTPHandler.post") as mock_post:
|
||||
mock_response = MagicMock()
|
||||
|
|
@ -43,7 +27,7 @@ def mock_embedding_http_handler(reload_huggingface_modules):
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_embedding_async_http_handler(reload_huggingface_modules):
|
||||
def mock_embedding_async_http_handler():
|
||||
"""Fixture to mock the async HTTP handler for embedding tests"""
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
"""
|
||||
Regression tests for LIT-4313: sagemaker_chat streaming must forward each AWS
|
||||
event-stream frame as it arrives instead of buffering to a fixed 1024-byte
|
||||
threshold and then draining a burst of deltas.
|
||||
|
||||
The buffering came from `response.iter_bytes(chunk_size=1024)` /
|
||||
`response.aiter_bytes(chunk_size=1024)`: httpx's ByteChunker withholds bytes until
|
||||
`chunk_size` accumulates, so the first client delta could not be produced until
|
||||
enough later frames had arrived to cross 1024 bytes, inflating TTFT and turning a
|
||||
steady provider stream into gap-then-burst delivery.
|
||||
"""
|
||||
|
||||
import binascii
|
||||
import json
|
||||
import struct
|
||||
from typing import AsyncIterator, Iterator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from litellm.llms.sagemaker.chat.transformation import SagemakerChatConfig
|
||||
|
||||
|
||||
def _encode_header(name: str, value: str) -> bytes:
|
||||
name_b = name.encode("utf-8")
|
||||
value_b = value.encode("utf-8")
|
||||
return struct.pack("B", len(name_b)) + name_b + struct.pack("B", 7) + struct.pack(">H", len(value_b)) + value_b
|
||||
|
||||
|
||||
def _encode_event_frame(payload: bytes) -> bytes:
|
||||
"""Encode one AWS event-stream message that botocore's EventStreamBuffer decodes."""
|
||||
headers = {
|
||||
":event-type": "PayloadPart",
|
||||
":content-type": "application/json",
|
||||
":message-type": "event",
|
||||
}
|
||||
headers_b = b"".join(_encode_header(k, v) for k, v in headers.items())
|
||||
total_len = 16 + len(headers_b) + len(payload)
|
||||
prelude = struct.pack(">I", total_len) + struct.pack(">I", len(headers_b))
|
||||
prelude_crc = struct.pack(">I", binascii.crc32(prelude) & 0xFFFFFFFF)
|
||||
message = prelude + prelude_crc + headers_b + payload
|
||||
message_crc = struct.pack(">I", binascii.crc32(message) & 0xFFFFFFFF)
|
||||
return message + message_crc
|
||||
|
||||
|
||||
def _delta_frame(index: int, content: str) -> bytes:
|
||||
sse = (
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1700000000,
|
||||
"choices": [{"index": 0, "delta": {"content": content}, "finish_reason": None}],
|
||||
}
|
||||
)
|
||||
+ "\n\n"
|
||||
)
|
||||
return _encode_event_frame(sse.encode("utf-8"))
|
||||
|
||||
|
||||
def _make_frames(n: int) -> list[bytes]:
|
||||
# Small single-token frames (< 1024 bytes each) so a fixed 1024-byte chunker
|
||||
# would have to swallow several frames before releasing the first delta.
|
||||
frames = [_delta_frame(i, f"token{i} ") for i in range(n)]
|
||||
assert all(len(f) < 1024 for f in frames)
|
||||
return frames
|
||||
|
||||
|
||||
class _CountingSyncStream(httpx.SyncByteStream):
|
||||
"""Yields provider frames one at a time and records how many have been pulled."""
|
||||
|
||||
def __init__(self, frames: list[bytes]) -> None:
|
||||
self._frames = frames
|
||||
self.consumed = 0
|
||||
|
||||
def __iter__(self) -> Iterator[bytes]:
|
||||
for frame in self._frames:
|
||||
self.consumed += 1
|
||||
yield frame
|
||||
|
||||
|
||||
class _CountingAsyncStream(httpx.AsyncByteStream):
|
||||
def __init__(self, frames: list[bytes]) -> None:
|
||||
self._frames = frames
|
||||
self.consumed = 0
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
for frame in self._frames:
|
||||
self.consumed += 1
|
||||
yield frame
|
||||
|
||||
|
||||
class _FakeSyncClient:
|
||||
def __init__(self, response: httpx.Response) -> None:
|
||||
self._response = response
|
||||
|
||||
def post(self, *args, **kwargs) -> httpx.Response:
|
||||
return self._response
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, response: httpx.Response) -> None:
|
||||
self._response = response
|
||||
|
||||
async def post(self, *args, **kwargs) -> httpx.Response:
|
||||
return self._response
|
||||
|
||||
|
||||
def _content_of(chunk) -> str | None:
|
||||
return chunk.choices[0].delta.content
|
||||
|
||||
|
||||
def test_sync_first_event_emitted_after_a_single_frame():
|
||||
"""The first delta must be available after exactly one source frame is pulled.
|
||||
|
||||
With the old chunk_size=1024 the httpx chunker would consume several small
|
||||
frames before yielding, so `consumed` would be > 1 at the first delta.
|
||||
"""
|
||||
frames = _make_frames(24)
|
||||
stream = _CountingSyncStream(frames)
|
||||
response = httpx.Response(200, stream=stream)
|
||||
|
||||
wrapper = SagemakerChatConfig().get_sync_custom_stream_wrapper(
|
||||
model="phi-4",
|
||||
custom_llm_provider="sagemaker_chat",
|
||||
logging_obj=MagicMock(),
|
||||
api_base="https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/phi-4/invocations-response-stream",
|
||||
headers={},
|
||||
data={},
|
||||
messages=[],
|
||||
client=_FakeSyncClient(response),
|
||||
)
|
||||
|
||||
first = next(c for c in wrapper.completion_stream if c is not None and _content_of(c) is not None)
|
||||
assert _content_of(first) == "token0 "
|
||||
assert stream.consumed == 1
|
||||
|
||||
|
||||
def test_sync_events_emitted_incrementally_without_bursting():
|
||||
"""Each successive delta must correspond to exactly one newly-pulled frame."""
|
||||
frames = _make_frames(24)
|
||||
stream = _CountingSyncStream(frames)
|
||||
response = httpx.Response(200, stream=stream)
|
||||
|
||||
wrapper = SagemakerChatConfig().get_sync_custom_stream_wrapper(
|
||||
model="phi-4",
|
||||
custom_llm_provider="sagemaker_chat",
|
||||
logging_obj=MagicMock(),
|
||||
api_base="https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/phi-4/invocations-response-stream",
|
||||
headers={},
|
||||
data={},
|
||||
messages=[],
|
||||
client=_FakeSyncClient(response),
|
||||
)
|
||||
|
||||
consumed_at_delta = [
|
||||
stream.consumed for chunk in wrapper.completion_stream if chunk is not None and _content_of(chunk) is not None
|
||||
]
|
||||
|
||||
assert consumed_at_delta == list(range(1, len(frames) + 1))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_first_event_emitted_after_a_single_frame():
|
||||
frames = _make_frames(24)
|
||||
stream = _CountingAsyncStream(frames)
|
||||
response = httpx.Response(200, stream=stream)
|
||||
|
||||
wrapper = await SagemakerChatConfig().get_async_custom_stream_wrapper(
|
||||
model="phi-4",
|
||||
custom_llm_provider="sagemaker_chat",
|
||||
logging_obj=MagicMock(),
|
||||
api_base="https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/phi-4/invocations-response-stream",
|
||||
headers={},
|
||||
data={},
|
||||
messages=[],
|
||||
client=_FakeAsyncClient(response),
|
||||
)
|
||||
|
||||
consumed_at_delta = []
|
||||
async for chunk in wrapper.completion_stream:
|
||||
if chunk is not None and _content_of(chunk) is not None:
|
||||
consumed_at_delta.append(stream.consumed)
|
||||
|
||||
assert consumed_at_delta == list(range(1, len(frames) + 1))
|
||||
|
||||
|
||||
def test_signed_body_includes_stream_flag():
|
||||
"""A streaming request must carry `stream: true` in the signed body sent to SageMaker.
|
||||
|
||||
`stream` flows into the request body through the transformed request (`{**optional_params}`)
|
||||
and must survive SigV4 signing so the endpoint enables token-level streaming.
|
||||
"""
|
||||
headers, signed_body = SagemakerChatConfig().sign_request(
|
||||
headers={},
|
||||
optional_params={
|
||||
"aws_access_key_id": "AKIATESTTESTTESTTEST",
|
||||
"aws_secret_access_key": "test-secret-key",
|
||||
"aws_region_name": "us-east-1",
|
||||
},
|
||||
request_data={"model": "phi-4", "messages": [{"role": "user", "content": "hi"}], "stream": True},
|
||||
api_base="https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/phi-4/invocations-response-stream",
|
||||
model="phi-4",
|
||||
stream=True,
|
||||
)
|
||||
assert signed_body is not None
|
||||
assert json.loads(signed_body)["stream"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("split_size", [1, 3, 7, 64, 4096])
|
||||
def test_decoder_reassembles_frames_across_arbitrary_byte_boundaries(split_size):
|
||||
"""Correctness must not depend on chunk boundaries falling on frame edges.
|
||||
|
||||
Removing `chunk_size=1024` lets httpx yield raw transport reads, so in
|
||||
production a single read can straddle several frames or split one frame in
|
||||
half. This re-chunks the concatenated stream at boundaries that deliberately
|
||||
ignore frame edges and asserts every delta still decodes, in order, exactly
|
||||
once - the guarantee botocore's EventStreamBuffer provides.
|
||||
"""
|
||||
from litellm.llms.sagemaker.chat.transformation import AWSEventStreamDecoder
|
||||
|
||||
frames = _make_frames(24)
|
||||
blob = b"".join(frames)
|
||||
chunks = [blob[i : i + split_size] for i in range(0, len(blob), split_size)]
|
||||
|
||||
decoder = AWSEventStreamDecoder(model="phi-4", is_messages_api=True)
|
||||
texts = [
|
||||
_content_of(chunk)
|
||||
for chunk in decoder.iter_bytes(iter(chunks))
|
||||
if chunk is not None and _content_of(chunk) is not None
|
||||
]
|
||||
|
||||
assert texts == [f"token{i} " for i in range(len(frames))]
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
"""
|
||||
Regression tests for LIT-4313: the native `sagemaker/` streaming path must
|
||||
forward each AWS event-stream frame as it arrives instead of buffering to a
|
||||
fixed 1024-byte threshold and then draining a burst of tokens.
|
||||
|
||||
The buffering came from `response.aiter_bytes(chunk_size=1024)`: httpx's
|
||||
ByteChunker withholds bytes until `chunk_size` accumulates, so the first token
|
||||
could not be produced until enough later frames had arrived to cross 1024 bytes,
|
||||
inflating TTFT and turning a steady provider stream into gap-then-burst delivery.
|
||||
"""
|
||||
|
||||
import binascii
|
||||
import json
|
||||
import struct
|
||||
from typing import AsyncIterator, Iterator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from litellm.llms.sagemaker.common_utils import SagemakerError
|
||||
from litellm.llms.sagemaker.completion.handler import SagemakerLLM
|
||||
|
||||
|
||||
def _encode_header(name: str, value: str) -> bytes:
|
||||
name_b = name.encode("utf-8")
|
||||
value_b = value.encode("utf-8")
|
||||
return struct.pack("B", len(name_b)) + name_b + struct.pack("B", 7) + struct.pack(">H", len(value_b)) + value_b
|
||||
|
||||
|
||||
def _encode_event_frame(payload: bytes) -> bytes:
|
||||
"""Encode one AWS event-stream message that botocore's EventStreamBuffer decodes."""
|
||||
headers = {
|
||||
":event-type": "PayloadPart",
|
||||
":content-type": "application/json",
|
||||
":message-type": "event",
|
||||
}
|
||||
headers_b = b"".join(_encode_header(k, v) for k, v in headers.items())
|
||||
total_len = 16 + len(headers_b) + len(payload)
|
||||
prelude = struct.pack(">I", total_len) + struct.pack(">I", len(headers_b))
|
||||
prelude_crc = struct.pack(">I", binascii.crc32(prelude) & 0xFFFFFFFF)
|
||||
message = prelude + prelude_crc + headers_b + payload
|
||||
message_crc = struct.pack(">I", binascii.crc32(message) & 0xFFFFFFFF)
|
||||
return message + message_crc
|
||||
|
||||
|
||||
def _token_frame(text: str) -> bytes:
|
||||
# SageMaker HF TGI streaming payloads are `{"token": {"text": ...}}` blobs.
|
||||
sse = "data: " + json.dumps({"token": {"text": text}}) + "\n\n"
|
||||
return _encode_event_frame(sse.encode("utf-8"))
|
||||
|
||||
|
||||
def _make_frames(n: int) -> list[bytes]:
|
||||
frames = [_token_frame(f"token{i} ") for i in range(n)]
|
||||
assert all(len(f) < 1024 for f in frames)
|
||||
return frames
|
||||
|
||||
|
||||
class _CountingSyncStream(httpx.SyncByteStream):
|
||||
"""Yields provider frames one at a time and records how many have been pulled."""
|
||||
|
||||
def __init__(self, frames: list[bytes]) -> None:
|
||||
self._frames = frames
|
||||
self.consumed = 0
|
||||
|
||||
def __iter__(self) -> Iterator[bytes]:
|
||||
for frame in self._frames:
|
||||
self.consumed += 1
|
||||
yield frame
|
||||
|
||||
|
||||
class _CountingAsyncStream(httpx.AsyncByteStream):
|
||||
"""Yields provider frames one at a time and records how many have been pulled."""
|
||||
|
||||
def __init__(self, frames: list[bytes]) -> None:
|
||||
self._frames = frames
|
||||
self.consumed = 0
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
for frame in self._frames:
|
||||
self.consumed += 1
|
||||
yield frame
|
||||
|
||||
|
||||
class _FakeSyncClient:
|
||||
def __init__(self, response: httpx.Response) -> None:
|
||||
self._response = response
|
||||
|
||||
def post(self, *args, **kwargs) -> httpx.Response:
|
||||
return self._response
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, response: httpx.Response) -> None:
|
||||
self._response = response
|
||||
|
||||
async def post(self, *args, **kwargs) -> httpx.Response:
|
||||
return self._response
|
||||
|
||||
|
||||
def test_sync_native_streaming_forwards_each_frame_incrementally():
|
||||
"""Each token must be emitted after exactly one newly-pulled source frame.
|
||||
|
||||
With the old `chunk_size=1024` the httpx chunker would swallow several small
|
||||
frames before yielding, so the first token would arrive only after `consumed`
|
||||
had already crossed multiple frames, and tokens would then replay in a burst.
|
||||
"""
|
||||
frames = _make_frames(24)
|
||||
stream = _CountingSyncStream(frames)
|
||||
response = httpx.Response(200, stream=stream)
|
||||
|
||||
completion_stream = SagemakerLLM().make_sync_call(
|
||||
api_base="https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/phi-4/invocations-response-stream",
|
||||
headers={},
|
||||
data="",
|
||||
logging_obj=MagicMock(),
|
||||
client=_FakeSyncClient(response),
|
||||
)
|
||||
|
||||
consumed_at_token = []
|
||||
texts = []
|
||||
for chunk in completion_stream:
|
||||
if chunk is not None and chunk["text"]:
|
||||
consumed_at_token.append(stream.consumed)
|
||||
texts.append(chunk["text"])
|
||||
|
||||
assert texts == [f"token{i} " for i in range(len(frames))]
|
||||
assert consumed_at_token == list(range(1, len(frames) + 1))
|
||||
|
||||
|
||||
def test_sync_native_streaming_raises_sagemaker_error_on_non_200():
|
||||
response = httpx.Response(500, text="boom")
|
||||
|
||||
with pytest.raises(SagemakerError) as exc_info:
|
||||
SagemakerLLM().make_sync_call(
|
||||
api_base="https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/phi-4/invocations-response-stream",
|
||||
headers={},
|
||||
data="",
|
||||
logging_obj=MagicMock(),
|
||||
client=_FakeSyncClient(response),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_native_streaming_forwards_each_frame_incrementally():
|
||||
"""Each token must be emitted after exactly one newly-pulled source frame.
|
||||
|
||||
With the old `chunk_size=1024` the httpx chunker would swallow several small
|
||||
frames before yielding, so the first token would arrive only after `consumed`
|
||||
had already crossed multiple frames, and tokens would then replay in a burst.
|
||||
"""
|
||||
frames = _make_frames(24)
|
||||
stream = _CountingAsyncStream(frames)
|
||||
response = httpx.Response(200, stream=stream)
|
||||
|
||||
completion_stream = await SagemakerLLM().make_async_call(
|
||||
api_base="https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/phi-4/invocations-response-stream",
|
||||
headers={},
|
||||
data="",
|
||||
logging_obj=MagicMock(),
|
||||
client=_FakeAsyncClient(response),
|
||||
)
|
||||
|
||||
consumed_at_token = []
|
||||
texts = []
|
||||
async for chunk in completion_stream:
|
||||
if chunk is not None and chunk["text"]:
|
||||
consumed_at_token.append(stream.consumed)
|
||||
texts.append(chunk["text"])
|
||||
|
||||
assert texts == [f"token{i} " for i in range(len(frames))]
|
||||
assert consumed_at_token == list(range(1, len(frames) + 1))
|
||||
|
|
@ -3,26 +3,16 @@ Integration tests for Vertex AI rerank functionality.
|
|||
These tests demonstrate end-to-end usage of the Vertex AI rerank feature.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm.llms.vertex_ai.rerank.transformation import VertexAIRerankConfig
|
||||
|
||||
|
||||
class TestVertexAIRerankIntegration:
|
||||
def setup_method(self):
|
||||
# Reload modules to ensure fresh references after conftest reloads litellm.
|
||||
# This ensures the class being patched is the same one used by the tests.
|
||||
import litellm.llms.vertex_ai.rerank.transformation as rerank_transformation_module
|
||||
|
||||
importlib.reload(rerank_transformation_module)
|
||||
|
||||
# Re-import after reload to get the fresh class
|
||||
from litellm.llms.vertex_ai.rerank.transformation import (
|
||||
VertexAIRerankConfig as FreshConfig,
|
||||
)
|
||||
|
||||
self.config = FreshConfig()
|
||||
self.config = VertexAIRerankConfig()
|
||||
self.model = "semantic-ranker-default@latest"
|
||||
|
||||
def test_end_to_end_rerank_flow(self):
|
||||
|
|
|
|||
|
|
@ -8148,3 +8148,489 @@ async def test_register_wall_names_the_fix_for_urlless_servers():
|
|||
detail_text = str(exc_info.value.detail)
|
||||
assert "set Authorization URL and Token URL" in detail_text
|
||||
assert "Issuer" in detail_text
|
||||
|
||||
|
||||
def test_passthrough_authorization_code_round_trips_and_rejects_hostile_input():
|
||||
"""The passthrough gateway code seals and recovers the ephemeral DCR client and upstream code,
|
||||
and is total over hostile input: a raw upstream code opens to None, and a tampered or
|
||||
non-gateway value opens to None rather than raising, so every existing caller-supplied-client
|
||||
flow is untouched."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
open_passthrough_authorization_code,
|
||||
seal_passthrough_authorization_code,
|
||||
)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY):
|
||||
sealed = seal_passthrough_authorization_code(
|
||||
upstream_code="up-code",
|
||||
client_id="minted-77",
|
||||
client_secret="mint-secret",
|
||||
mcp_server_id="srv-1",
|
||||
token_endpoint_auth_method="client_secret_basic",
|
||||
)
|
||||
opened = open_passthrough_authorization_code(sealed)
|
||||
assert opened is not None
|
||||
assert opened.upstream_code == "up-code"
|
||||
assert opened.client_id == "minted-77"
|
||||
assert opened.client_secret == "mint-secret"
|
||||
assert opened.mcp_server_id == "srv-1"
|
||||
assert opened.token_endpoint_auth_method == "client_secret_basic"
|
||||
assert open_passthrough_authorization_code("raw-upstream-code") is None
|
||||
assert open_passthrough_authorization_code(sealed[:-4] + "aaaa") is None
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
_BRIDGE_AUTH_CODE_PREFIX,
|
||||
_PASSTHROUGH_AUTH_CODE_PREFIX,
|
||||
open_bridge_authorization_code,
|
||||
seal_bridge_authorization_code,
|
||||
)
|
||||
|
||||
bridge_sealed = seal_bridge_authorization_code(
|
||||
upstream_code="up-code", litellm_user_id="sso-user-9", mcp_server_id="srv-1"
|
||||
)
|
||||
reprefixed_as_passthrough = _PASSTHROUGH_AUTH_CODE_PREFIX + bridge_sealed[len(_BRIDGE_AUTH_CODE_PREFIX) :]
|
||||
reprefixed_as_bridge = _BRIDGE_AUTH_CODE_PREFIX + sealed[len(_PASSTHROUGH_AUTH_CODE_PREFIX) :]
|
||||
assert open_passthrough_authorization_code(reprefixed_as_passthrough) is None
|
||||
assert open_bridge_authorization_code(reprefixed_as_bridge) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorize_with_ephemeral_dcr_client_seals_client_into_state():
|
||||
"""When mcp_authorize fell through to a gateway-side DCR mint, authorize_with_server seals the
|
||||
minted client and the target server into the encrypted OAuth state, so the callback can bind
|
||||
them into the forwarded authorization code while the gateway stores nothing."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
EphemeralDcrClient,
|
||||
authorize_with_server,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.true_passthrough, dcr_bridge=None)
|
||||
captured: dict = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return "mocked_encrypted_state"
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.encode_state_with_base_url",
|
||||
side_effect=_capture,
|
||||
):
|
||||
response = await authorize_with_server(
|
||||
request=_bridge_mock_request(),
|
||||
mcp_server=server,
|
||||
client_id="minted-77",
|
||||
redirect_uri="http://127.0.0.1:60108/callback",
|
||||
state="s",
|
||||
code_challenge="chal",
|
||||
code_challenge_method="S256",
|
||||
ephemeral_dcr_client=EphemeralDcrClient(
|
||||
client_id="minted-77", client_secret="mint-secret", token_endpoint_auth_method="client_secret_basic"
|
||||
),
|
||||
)
|
||||
|
||||
assert captured["dcr_client_id"] == "minted-77"
|
||||
assert captured["dcr_client_secret"] == "mint-secret"
|
||||
assert captured["dcr_token_endpoint_auth_method"] == "client_secret_basic"
|
||||
assert captured["mcp_server_id"] == server.server_id
|
||||
assert "client_id=minted-77" in response.headers["location"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_wraps_code_into_passthrough_code_for_ephemeral_dcr_state():
|
||||
"""When the OAuth state carries an ephemeral DCR client, the callback forwards a sealed
|
||||
passthrough code (binding the client and the upstream code to the server) instead of the raw
|
||||
upstream code, so the client's later token call can authenticate the exchange with a client the
|
||||
gateway never stored."""
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
callback,
|
||||
open_passthrough_authorization_code,
|
||||
)
|
||||
|
||||
state_data = {
|
||||
"original_state": "client-state",
|
||||
"client_redirect_uri": "http://127.0.0.1:60108/cb",
|
||||
"base_url": "http://127.0.0.1:60108/cb",
|
||||
"mcp_server_id": "srv-1",
|
||||
"dcr_client_id": "minted-77",
|
||||
"dcr_client_secret": "mint-secret",
|
||||
"dcr_token_endpoint_auth_method": "client_secret_basic",
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._resolve_encoded_oauth_state",
|
||||
return_value="enc",
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.decode_state_hash",
|
||||
return_value=state_data,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._get_validated_client_redirect_uri",
|
||||
return_value="http://127.0.0.1:60108/cb",
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
||||
):
|
||||
response = await callback(request=_bridge_mock_request(), code="REAL-UPSTREAM-CODE", state="relay")
|
||||
|
||||
forwarded_code = parse_qs(urlparse(response.headers["location"]).query)["code"][0]
|
||||
opened = open_passthrough_authorization_code(forwarded_code)
|
||||
|
||||
assert opened is not None
|
||||
assert opened.upstream_code == "REAL-UPSTREAM-CODE"
|
||||
assert opened.client_id == "minted-77"
|
||||
assert opened.client_secret == "mint-secret"
|
||||
assert opened.mcp_server_id == "srv-1"
|
||||
assert opened.token_endpoint_auth_method == "client_secret_basic"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorize_bridge_server_with_ephemeral_client_takes_short_circuit_arm():
|
||||
"""A gateway-minted client is registered against {base}/callback, so a bridge server's
|
||||
authorize with an ephemeral client must run the short-circuit (gateway /callback) arm with a
|
||||
relay state cookie, never the verbatim relay: relaying would send the browser's redirect_uri
|
||||
to an IdP that has the gateway callback registered, stranding the flow."""
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
EphemeralDcrClient,
|
||||
authorize_with_server,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.true_passthrough)
|
||||
with patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY):
|
||||
response = await authorize_with_server(
|
||||
request=_bridge_mock_request(),
|
||||
mcp_server=server,
|
||||
client_id="minted-77",
|
||||
redirect_uri="http://127.0.0.1:60108/callback",
|
||||
state="client-state",
|
||||
code_challenge="chal",
|
||||
code_challenge_method="S256",
|
||||
ephemeral_dcr_client=EphemeralDcrClient(client_id="minted-77", client_secret=None),
|
||||
)
|
||||
|
||||
location = response.headers["location"]
|
||||
params = parse_qs(urlparse(location).query)
|
||||
assert params["redirect_uri"] == ["https://litellm.example.com/callback"]
|
||||
assert params["client_id"] == ["minted-77"]
|
||||
assert params["state"] != ["client-state"]
|
||||
assert any(cookie.startswith("mcp_oauth_state_") for cookie in response.headers.get("set-cookie", "").split(";"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_forwards_raw_code_when_dcr_state_lacks_server_binding():
|
||||
"""A state carrying a dcr client but no server id cannot produce a server-bound sealed code, so
|
||||
the callback falls back to forwarding the raw upstream code instead of sealing an unbindable
|
||||
one."""
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import callback
|
||||
|
||||
state_data = {
|
||||
"original_state": "client-state",
|
||||
"client_redirect_uri": "http://127.0.0.1:60108/cb",
|
||||
"base_url": "http://127.0.0.1:60108/cb",
|
||||
"dcr_client_id": "minted-77",
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._resolve_encoded_oauth_state",
|
||||
return_value="enc",
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.decode_state_hash",
|
||||
return_value=state_data,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._get_validated_client_redirect_uri",
|
||||
return_value="http://127.0.0.1:60108/cb",
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
||||
):
|
||||
response = await callback(request=_bridge_mock_request(), code="REAL-UPSTREAM-CODE", state="relay")
|
||||
|
||||
forwarded_code = parse_qs(urlparse(response.headers["location"]).query)["code"][0]
|
||||
assert forwarded_code == "REAL-UPSTREAM-CODE"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"auth_type_value",
|
||||
[
|
||||
"none",
|
||||
"api_key",
|
||||
"bearer_token",
|
||||
"basic",
|
||||
"authorization",
|
||||
"oauth2",
|
||||
"aws_sigv4",
|
||||
"token",
|
||||
"oauth2_token_exchange",
|
||||
"oauth2_id_jag",
|
||||
"true_passthrough",
|
||||
"oauth_delegate",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("dcr_bridge", [True, False])
|
||||
async def test_resolve_ephemeral_dcr_client_mint_set_is_exact(auth_type_value, dcr_bridge):
|
||||
"""The full authorize-time mint decision matrix, one cell per (auth_type, dcr_bridge). The gateway
|
||||
mints iff true_passthrough (any bridge) or oauth_delegate-and-not-dcr_bridge; every other mode
|
||||
returns None so no non-OAuth mode ever registers an upstream client, and the interactive
|
||||
oauth_delegate dcr_bridge sign-in is left to its own browser-front-door flow. The UI
|
||||
gatewayMintsClientFor helper mirrors this exact set; ui/.../mcp_tools/types.test.tsx pins the
|
||||
frontend side against the same table, so a divergence fails on one side or the other."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
EphemeralDcrClient,
|
||||
resolve_ephemeral_dcr_client,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(
|
||||
auth_type=MCPAuth(auth_type_value),
|
||||
dcr_bridge=dcr_bridge,
|
||||
server_id=f"matrix_{auth_type_value}_{dcr_bridge}",
|
||||
server_name=f"matrix_{auth_type_value}_{dcr_bridge}",
|
||||
)
|
||||
expected_mint = server.is_true_passthrough or (server.is_oauth_delegate and not server.is_dcr_bridge)
|
||||
mint_mock = AsyncMock(return_value=EphemeralDcrClient(client_id="minted", client_secret=None))
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.mint_ephemeral_dcr_client",
|
||||
mint_mock,
|
||||
):
|
||||
result = await resolve_ephemeral_dcr_client(
|
||||
request=_bridge_mock_request(),
|
||||
mcp_server=server,
|
||||
code_challenge="chal",
|
||||
code_challenge_method="S256",
|
||||
redirect_uri="http://127.0.0.1:9/callback",
|
||||
)
|
||||
|
||||
if expected_mint:
|
||||
mint_mock.assert_awaited_once()
|
||||
assert result is not None
|
||||
else:
|
||||
mint_mock.assert_not_awaited()
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mint_ephemeral_dcr_client_returns_none_without_registration_endpoint():
|
||||
"""A server whose upstream exposes no RFC 7591 registration endpoint cannot mint, so the
|
||||
fall-through reports None and the caller keeps its existing missing_client_id failure."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
mint_ephemeral_dcr_client,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.true_passthrough, dcr_bridge=None, registration_url=None)
|
||||
assert await mint_ephemeral_dcr_client(_bridge_mock_request(), server) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mint_ephemeral_dcr_client_posts_rfc7591_and_returns_client():
|
||||
"""The mint POSTs a public-client RFC 7591 registration bound to the gateway /callback and hands
|
||||
back the upstream's client without persisting it anywhere."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
mint_ephemeral_dcr_client,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(
|
||||
auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id="mint_posts_srv", server_name="mint_posts_srv"
|
||||
)
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = json.dumps(
|
||||
{"client_id": "minted-77", "client_secret": "mint-secret", "token_endpoint_auth_method": "client_secret_basic"}
|
||||
)
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_async_client = MagicMock()
|
||||
mock_async_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
||||
return_value=mock_async_client,
|
||||
):
|
||||
minted = await mint_ephemeral_dcr_client(_bridge_mock_request(), server)
|
||||
|
||||
assert minted is not None
|
||||
assert minted.client_id == "minted-77"
|
||||
assert minted.client_secret == "mint-secret"
|
||||
assert minted.token_endpoint_auth_method == "client_secret_basic"
|
||||
register_data = mock_async_client.post.call_args.kwargs["json"]
|
||||
assert register_data["redirect_uris"] == ["https://litellm.example.com/callback"]
|
||||
assert register_data["token_endpoint_auth_method"] == "none"
|
||||
assert register_data["grant_types"] == ["authorization_code", "refresh_token"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mint_ephemeral_dcr_client_reuses_minted_client_within_flow_ttl():
|
||||
"""Reloading the authorize page must not spam the upstream registration endpoint with orphan
|
||||
clients: within the OAuth state's lifetime a second mint for the same server and gateway origin
|
||||
reuses the cached client and performs no second upstream POST."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
mint_ephemeral_dcr_client,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(
|
||||
auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id="mint_reuse_srv", server_name="mint_reuse_srv"
|
||||
)
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = json.dumps({"client_id": "minted-77"})
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_async_client = MagicMock()
|
||||
mock_async_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
||||
return_value=mock_async_client,
|
||||
):
|
||||
first = await mint_ephemeral_dcr_client(_bridge_mock_request(), server)
|
||||
second = await mint_ephemeral_dcr_client(_bridge_mock_request(), server)
|
||||
|
||||
assert first is not None
|
||||
assert second == first
|
||||
mock_async_client.post.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mint_ephemeral_dcr_client_single_flights_concurrent_mints():
|
||||
"""Two in-flight authorize requests for the same server must not both register an upstream
|
||||
client: the per-key lock makes the second waiter reuse the first mint, so exactly one upstream
|
||||
POST happens."""
|
||||
import asyncio
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
mint_ephemeral_dcr_client,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(
|
||||
auth_type=MCPAuth.true_passthrough,
|
||||
dcr_bridge=None,
|
||||
server_id="mint_concurrent_srv",
|
||||
server_name="mint_concurrent_srv",
|
||||
)
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = json.dumps({"client_id": "minted-77"})
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
async def _slow_post(*args, **kwargs):
|
||||
await asyncio.sleep(0.05)
|
||||
return mock_response
|
||||
|
||||
mock_async_client = MagicMock()
|
||||
mock_async_client.post = AsyncMock(side_effect=_slow_post)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
||||
return_value=mock_async_client,
|
||||
):
|
||||
first, second = await asyncio.gather(
|
||||
mint_ephemeral_dcr_client(_bridge_mock_request(), server),
|
||||
mint_ephemeral_dcr_client(_bridge_mock_request(), server),
|
||||
)
|
||||
|
||||
assert first is not None
|
||||
assert second == first
|
||||
mock_async_client.post.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"payload, server_id",
|
||||
[
|
||||
({"unexpected": "shape"}, "mint_bad_shape_srv"),
|
||||
({"client_id": ""}, "mint_empty_id_srv"),
|
||||
],
|
||||
)
|
||||
async def test_mint_ephemeral_dcr_client_unusable_registration_response_is_502(payload, server_id):
|
||||
"""An upstream registration response without a usable client_id, whether the field is missing or
|
||||
an empty string, surfaces as a loud 502 instead of letting the authorize proceed with an empty
|
||||
client and fail opaquely at the IdP."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
mint_ephemeral_dcr_client,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id=server_id, server_name=server_id)
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = json.dumps(payload)
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_async_client = MagicMock()
|
||||
mock_async_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
||||
return_value=mock_async_client,
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await mint_ephemeral_dcr_client(_bridge_mock_request(), server)
|
||||
|
||||
assert exc.value.status_code == 502
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"sealed_auth_method, expects_basic_header",
|
||||
[
|
||||
("client_secret_basic", True),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
async def test_token_exchange_authenticates_with_the_sealed_clients_own_auth_method(
|
||||
sealed_auth_method, expects_basic_header
|
||||
):
|
||||
"""The id, secret, and token-endpoint auth method must come from the same source: a client
|
||||
recovered from a sealed passthrough code authenticates the upstream exchange the way its own
|
||||
registration was granted, not the way the server row is configured. A sealed
|
||||
``client_secret_basic`` grant sends the Basic header and keeps the secret out of the body; a
|
||||
sealed public client (no method) keeps the body-credential path."""
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
exchange_token_with_server,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id="sealed_method_srv")
|
||||
upstream_request = httpx.Request("POST", server.token_url)
|
||||
upstream_response = httpx.Response(
|
||||
200, json={"access_token": "up-token", "token_type": "Bearer"}, request=upstream_request
|
||||
)
|
||||
mock_async_client = MagicMock()
|
||||
mock_async_client.post = AsyncMock(return_value=upstream_response)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
||||
return_value=mock_async_client,
|
||||
):
|
||||
await exchange_token_with_server(
|
||||
request=_bridge_mock_request(),
|
||||
mcp_server=server,
|
||||
grant_type="authorization_code",
|
||||
code="up-code",
|
||||
redirect_uri="https://litellm.example.com/callback",
|
||||
client_id="minted-77",
|
||||
client_secret="mint-secret",
|
||||
code_verifier="verifier",
|
||||
client_token_endpoint_auth_method=sealed_auth_method,
|
||||
)
|
||||
|
||||
sent_headers = mock_async_client.post.call_args.kwargs["headers"]
|
||||
sent_body = mock_async_client.post.call_args.kwargs["data"]
|
||||
if expects_basic_header:
|
||||
expected = base64.b64encode(b"minted-77:mint-secret").decode()
|
||||
assert sent_headers["Authorization"] == f"Basic {expected}"
|
||||
assert "client_secret" not in sent_body
|
||||
else:
|
||||
assert "Authorization" not in sent_headers
|
||||
assert sent_body["client_id"] == "minted-77"
|
||||
assert sent_body["client_secret"] == "mint-secret"
|
||||
|
|
|
|||
|
|
@ -48,27 +48,22 @@ def _base_config(**overrides: Any) -> AutorouteConfig:
|
|||
class TestParseDiscoveredModels:
|
||||
def test_parses_valid_raw_list_into_typed_tuple(self):
|
||||
raw = [
|
||||
{
|
||||
"model_group": "gpt-4o",
|
||||
"mode": "chat",
|
||||
"input_cost_per_token": 0.01,
|
||||
"output_cost_per_token": 0.02,
|
||||
},
|
||||
{"model_group": "text-embedding-3-small", "mode": "embedding"},
|
||||
{"id": "gpt-4o", "object": "model", "mode": "chat"},
|
||||
{"id": "text-embedding-3-small", "object": "model", "mode": "embedding"},
|
||||
]
|
||||
result = parse_discovered_models(raw)
|
||||
assert result == (
|
||||
DiscoveredModel(name="gpt-4o", mode="chat", input_cost_per_token=0.01, output_cost_per_token=0.02),
|
||||
DiscoveredModel(name="gpt-4o", mode="chat"),
|
||||
DiscoveredModel(name="text-embedding-3-small", mode="embedding"),
|
||||
)
|
||||
|
||||
def test_ignores_unknown_extra_fields(self):
|
||||
raw = [{"model_group": "gpt-4o", "mode": "chat", "totally_unknown_field": "whatever"}]
|
||||
raw = [{"id": "gpt-4o", "mode": "chat", "created": 123, "owned_by": "openai", "max_input_tokens": 128000}]
|
||||
result = parse_discovered_models(raw)
|
||||
assert result == (DiscoveredModel(name="gpt-4o", mode="chat"),)
|
||||
|
||||
def test_missing_mode_defaults_to_chat(self):
|
||||
raw = [{"model_group": "gpt-4o"}]
|
||||
raw = [{"id": "gpt-4o", "object": "model"}]
|
||||
result = parse_discovered_models(raw)
|
||||
assert result[0].mode == "chat"
|
||||
|
||||
|
|
|
|||
|
|
@ -16,22 +16,22 @@ from litellm.proxy.client.cli.commands.autoroute.config import DiscoveredModel
|
|||
from litellm.proxy.client.cli.commands.autoroute.wizard import run_configure_wizard
|
||||
|
||||
CHAT_AND_EMBEDDING_GROUPS: List[Dict[str, Any]] = [
|
||||
{"model_group": "gpt-4o-mini", "mode": "chat", "input_cost_per_token": 0.01, "output_cost_per_token": 0.02},
|
||||
{"model_group": "gpt-4o", "mode": "chat", "input_cost_per_token": 0.01, "output_cost_per_token": 0.02},
|
||||
{"model_group": "claude-opus", "mode": "chat"},
|
||||
{"model_group": "o1", "mode": "chat"},
|
||||
{"model_group": "text-embedding-3-small", "mode": "embedding"},
|
||||
{"id": "gpt-4o-mini", "object": "model", "mode": "chat", "max_input_tokens": 128000},
|
||||
{"id": "gpt-4o", "object": "model", "mode": "chat", "max_input_tokens": 128000},
|
||||
{"id": "claude-opus", "object": "model", "mode": "chat"},
|
||||
{"id": "o1", "object": "model", "mode": "chat"},
|
||||
{"id": "text-embedding-3-small", "object": "model", "mode": "embedding"},
|
||||
]
|
||||
|
||||
CHAT_ONLY_GROUPS: List[Dict[str, Any]] = [
|
||||
{"model_group": "gpt-4o-mini", "mode": "chat"},
|
||||
{"model_group": "gpt-4o", "mode": "chat"},
|
||||
{"model_group": "claude-opus", "mode": "chat"},
|
||||
{"model_group": "o1", "mode": "chat"},
|
||||
{"id": "gpt-4o-mini", "object": "model", "mode": "chat"},
|
||||
{"id": "gpt-4o", "object": "model", "mode": "chat"},
|
||||
{"id": "claude-opus", "object": "model", "mode": "chat"},
|
||||
{"id": "o1", "object": "model", "mode": "chat"},
|
||||
]
|
||||
|
||||
EMBEDDING_ONLY_GROUPS: List[Dict[str, Any]] = [
|
||||
{"model_group": "text-embedding-3-small", "mode": "embedding"},
|
||||
{"id": "text-embedding-3-small", "object": "model", "mode": "embedding"},
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ def _run(
|
|||
patch.object(wizard_module, "_render_and_prompt_for_models", side_effect=_fake_prompt_for_models),
|
||||
patch.object(wizard_module, "_render_and_prompt_for_model", side_effect=_fake_prompt_for_model),
|
||||
):
|
||||
mock_client_cls.return_value.model_groups.info.return_value = raw_groups
|
||||
mock_client_cls.return_value.models.list.return_value = raw_groups
|
||||
result = runner.invoke(
|
||||
_invoke_wizard,
|
||||
obj={"base_url": "http://localhost:4000", "api_key": "sk-test"},
|
||||
|
|
@ -262,7 +262,7 @@ class TestRunConfigureWizardNoChatModels:
|
|||
|
||||
assert result.exit_code != 0
|
||||
assert result.exception is None or not isinstance(result.exception, AssertionError)
|
||||
assert "Unexpected response from /model_group/info" in result.output
|
||||
assert "Unexpected response from /v1/models" in result.output
|
||||
assert not config_path.exists()
|
||||
|
||||
|
||||
|
|
@ -275,7 +275,7 @@ class TestRunConfigureWizardNotInteractive:
|
|||
patch.object(wizard_module, "CONFIG_PATH", config_path),
|
||||
patch.object(wizard_module, "_is_interactive", return_value=False),
|
||||
):
|
||||
mock_client_cls.return_value.model_groups.info.return_value = CHAT_AND_EMBEDDING_GROUPS
|
||||
mock_client_cls.return_value.models.list.return_value = CHAT_AND_EMBEDDING_GROUPS
|
||||
result = runner.invoke(_invoke_wizard, obj={"base_url": "http://localhost:4000", "api_key": "sk-test"})
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
import os
|
||||
|
||||
from litellm.proxy.config_resolvers._descriptors import FieldDescriptor, resolve_fields
|
||||
from litellm.proxy.config_resolvers.sso import (
|
||||
SSO_FIELD_ENV_VARS,
|
||||
SSO_SECRET_FIELDS,
|
||||
resolve_sso_config,
|
||||
)
|
||||
|
||||
_D = (
|
||||
FieldDescriptor("client_id", "client_id", "CLIENT_ID"),
|
||||
FieldDescriptor("scope", "scope", "SCOPE", default="openid"),
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_fields_db_wins_over_env():
|
||||
values, provenance = resolve_fields(_D, {"client_id": "from-db"}, {"CLIENT_ID": "from-env"})
|
||||
assert values["client_id"] == "from-db"
|
||||
assert provenance["client_id"] == "db"
|
||||
|
||||
|
||||
def test_resolve_fields_blank_db_falls_back_to_env():
|
||||
values, provenance = resolve_fields(_D, {"client_id": " "}, {"CLIENT_ID": "from-env"})
|
||||
assert values["client_id"] == "from-env"
|
||||
assert provenance["client_id"] == "env"
|
||||
|
||||
|
||||
def test_resolve_fields_blank_everywhere_falls_to_default():
|
||||
values, provenance = resolve_fields(_D, {}, {"SCOPE": ""})
|
||||
assert values["scope"] == "openid"
|
||||
assert provenance["scope"] == "default"
|
||||
|
||||
|
||||
def test_resolve_fields_unset_everywhere():
|
||||
values, provenance = resolve_fields(_D, {}, {})
|
||||
assert values["client_id"] is None
|
||||
assert provenance["client_id"] == "unset"
|
||||
|
||||
|
||||
def test_resolve_fields_empty_db_absent_by_default_falls_to_env():
|
||||
# SSO semantics: a present-but-empty stored value is absent, so env wins.
|
||||
values, provenance = resolve_fields(_D, {"client_id": ""}, {"CLIENT_ID": "from-env"})
|
||||
assert values["client_id"] == "from-env"
|
||||
assert provenance["client_id"] == "env"
|
||||
|
||||
|
||||
def test_resolve_fields_empty_db_is_explicit_clear_when_flag_set():
|
||||
# Alerting semantics: a present-but-empty stored value is an explicit clear
|
||||
# that must win over a stale env var.
|
||||
values, provenance = resolve_fields(
|
||||
_D, {"client_id": ""}, {"CLIENT_ID": "stale-env"}, empty_db_is_set=True
|
||||
)
|
||||
assert values["client_id"] == ""
|
||||
assert provenance["client_id"] == "db"
|
||||
|
||||
|
||||
def test_sso_descriptor_mapping_is_single_sourced():
|
||||
# The write path and read path both consume this mapping; it must cover every
|
||||
# env-backed SSO field and map to the uppercase env var.
|
||||
assert SSO_FIELD_ENV_VARS["generic_client_id"] == "GENERIC_CLIENT_ID"
|
||||
assert SSO_SECRET_FIELDS == frozenset(
|
||||
{"google_client_secret", "microsoft_client_secret", "generic_client_secret"}
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_sso_config_returns_unmasked_secret_and_provenance():
|
||||
# The resolver hands back plaintext; masking is the endpoint's job. If the
|
||||
# resolver masked, the login path would consume a masked secret and fail.
|
||||
resolved = resolve_sso_config(
|
||||
{"generic_client_secret": "super-secret-value"},
|
||||
{"GENERIC_CLIENT_ID": "env-id"},
|
||||
)
|
||||
assert resolved.config.generic_client_secret == "super-secret-value"
|
||||
assert resolved.provenance["generic_client_secret"] == "db"
|
||||
assert resolved.config.generic_client_id == "env-id"
|
||||
assert resolved.provenance["generic_client_id"] == "env"
|
||||
|
||||
|
||||
def test_resolve_sso_config_parses_structured_mappings():
|
||||
resolved = resolve_sso_config(
|
||||
{
|
||||
"generic_client_id": "id",
|
||||
"role_mappings": {
|
||||
"provider": "generic",
|
||||
"group_claim": "groups",
|
||||
"default_role": "internal_user",
|
||||
"roles": {},
|
||||
},
|
||||
"team_mappings": {"team_ids_jwt_field": "teams"},
|
||||
},
|
||||
{},
|
||||
)
|
||||
assert resolved.config.role_mappings is not None
|
||||
assert resolved.config.role_mappings.group_claim == "groups"
|
||||
assert resolved.config.team_mappings is not None
|
||||
assert resolved.config.team_mappings.team_ids_jwt_field == "teams"
|
||||
|
||||
|
||||
def test_resolve_sso_config_does_not_mutate_os_environ(monkeypatch):
|
||||
# Unlike the legacy read path, resolving must not write os.environ.
|
||||
monkeypatch.delenv("GENERIC_CLIENT_ID", raising=False)
|
||||
before = dict(os.environ)
|
||||
resolve_sso_config({"generic_client_id": "id-from-db"}, os.environ)
|
||||
assert dict(os.environ) == before
|
||||
assert "GENERIC_CLIENT_ID" not in os.environ
|
||||
|
|
@ -465,3 +465,58 @@ def test_apply_patch_ops_filtered_path_raises_400_instead_of_junk_metadata():
|
|||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
def test_apply_patch_ops_remove_group_filtered_path_without_value():
|
||||
"""Okta removes a user from a team with groups[value eq "..."] and no body
|
||||
value; the team id must be parsed from the filter so the remove takes effect"""
|
||||
user = LiteLLM_UserTable(
|
||||
user_id="user-fp",
|
||||
user_email="fp@example.com",
|
||||
teams=["team-1", "team-2"],
|
||||
metadata={},
|
||||
)
|
||||
patch_ops = SCIMPatchOp(
|
||||
Operations=[SCIMPatchOperation(op="remove", path='groups[value eq "team-1"]')]
|
||||
)
|
||||
|
||||
_, final_team_set = _apply_patch_ops(existing_user=user, patch_ops=patch_ops)
|
||||
|
||||
assert final_team_set == {"team-2"}
|
||||
|
||||
|
||||
def test_apply_patch_ops_add_group_filtered_path_without_value():
|
||||
"""A filtered add path with no body value adds the team id from the filter."""
|
||||
user = LiteLLM_UserTable(
|
||||
user_id="user-fp",
|
||||
user_email="fp@example.com",
|
||||
teams=["team-1"],
|
||||
metadata={},
|
||||
)
|
||||
patch_ops = SCIMPatchOp(
|
||||
Operations=[SCIMPatchOperation(op="add", path="groups[value eq 'team-3']")]
|
||||
)
|
||||
|
||||
_, final_team_set = _apply_patch_ops(existing_user=user, patch_ops=patch_ops)
|
||||
|
||||
assert final_team_set == {"team-1", "team-3"}
|
||||
|
||||
|
||||
def test_apply_patch_ops_replace_groups_empty_value_does_not_use_path_filter():
|
||||
"""A filtered replace with an explicit empty value must not resurrect the
|
||||
filter id; the team set is replaced with the empty value as given."""
|
||||
user = LiteLLM_UserTable(
|
||||
user_id="user-fp",
|
||||
user_email="fp@example.com",
|
||||
teams=["team-1", "team-2"],
|
||||
metadata={},
|
||||
)
|
||||
patch_ops = SCIMPatchOp(
|
||||
Operations=[
|
||||
SCIMPatchOperation(op="replace", path='groups[value eq "team-1"]', value=[])
|
||||
]
|
||||
)
|
||||
|
||||
_, final_team_set = _apply_patch_ops(existing_user=user, patch_ops=patch_ops)
|
||||
|
||||
assert final_team_set == set()
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import time
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -17,12 +18,14 @@ from litellm.proxy.management_endpoints.scim.scim_v2 import (
|
|||
UserProvisionerHelpers,
|
||||
_apply_group_patch_updates,
|
||||
_extract_group_member_ids,
|
||||
_extract_ids_from_path_filter,
|
||||
_handle_team_membership_changes,
|
||||
_process_group_patch_operations,
|
||||
_recompute_scim_member_roles,
|
||||
create_group,
|
||||
create_user,
|
||||
delete_group,
|
||||
delete_user,
|
||||
get_groups,
|
||||
get_users,
|
||||
get_service_provider_config,
|
||||
|
|
@ -1908,7 +1911,7 @@ async def test_process_group_patch_operations_with_flag_true_creates_users(mocke
|
|||
)
|
||||
|
||||
# Execute the function
|
||||
update_data, final_members = await _process_group_patch_operations(
|
||||
update_data, final_members, _ = await _process_group_patch_operations(
|
||||
patch_ops=patch_ops,
|
||||
existing_team=mock_existing_team,
|
||||
prisma_client=mock_prisma_client,
|
||||
|
|
@ -2947,7 +2950,7 @@ async def test_process_group_patch_operations_add_retains_existing_members(
|
|||
return_value=mocker.MagicMock(user_id="new-user")
|
||||
)
|
||||
|
||||
_, final_members = await _process_group_patch_operations(
|
||||
_, final_members, _ = await _process_group_patch_operations(
|
||||
patch_ops=patch_ops,
|
||||
existing_team=existing_team,
|
||||
prisma_client=mock_prisma_client,
|
||||
|
|
@ -2995,7 +2998,7 @@ async def test_process_group_patch_operations_remove_uses_members_with_roles(
|
|||
return_value=mocker.MagicMock(user_id="drop-user")
|
||||
)
|
||||
|
||||
_, final_members = await _process_group_patch_operations(
|
||||
_, final_members, _ = await _process_group_patch_operations(
|
||||
patch_ops=patch_ops,
|
||||
existing_team=existing_team,
|
||||
prisma_client=mock_prisma_client,
|
||||
|
|
@ -3062,3 +3065,457 @@ async def test_apply_group_patch_updates_does_not_write_legacy_members(mocker):
|
|||
written = mock_prisma_client.db.litellm_teamtable.update.call_args.kwargs["data"]
|
||||
assert "members" not in written
|
||||
assert written["team_alias"] == "Renamed"
|
||||
|
||||
|
||||
def _mock_prisma_for_delete_user(mocker, team):
|
||||
mock_prisma_client = mocker.MagicMock()
|
||||
mock_prisma_client.db = mocker.MagicMock()
|
||||
mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
|
||||
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=team)
|
||||
mock_prisma_client.db.litellm_teamtable.update = AsyncMock()
|
||||
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
|
||||
mock_prisma_client.db.litellm_usertable.delete = AsyncMock()
|
||||
return mock_prisma_client
|
||||
|
||||
|
||||
def _patch_delete_user_dependencies(mocker, mock_prisma_client, existing_user):
|
||||
mocker.patch(
|
||||
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
|
||||
AsyncMock(return_value=mock_prisma_client),
|
||||
)
|
||||
mocker.patch(
|
||||
"litellm.proxy.management_endpoints.scim.scim_v2._check_user_exists",
|
||||
AsyncMock(return_value=existing_user),
|
||||
)
|
||||
mocker.patch(
|
||||
"litellm.proxy.management_endpoints.scim.scim_v2._set_user_keys_blocked",
|
||||
AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"litellm.proxy.management_endpoints.scim.scim_v2._delete_rows_referencing_user",
|
||||
AsyncMock(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user_prunes_members_with_roles(mocker):
|
||||
"""Deleting a SCIM user must remove them from every team they belong to via
|
||||
team_member_delete, which prunes members_with_roles (the source of truth for
|
||||
SCIM group membership) so GET /Groups no longer returns a dangling reference
|
||||
to the now-deleted user."""
|
||||
user_id = "scim-del-user"
|
||||
|
||||
existing_user = mocker.MagicMock()
|
||||
existing_user.teams = ["team-1"]
|
||||
|
||||
team = LiteLLM_TeamTable(
|
||||
team_id="team-1",
|
||||
members=[user_id, "other-user"],
|
||||
members_with_roles=[Member(user_id=user_id, role="user"), Member(user_id="other-user", role="admin")],
|
||||
)
|
||||
|
||||
mock_prisma_client = _mock_prisma_for_delete_user(mocker, team)
|
||||
_patch_delete_user_dependencies(mocker, mock_prisma_client, existing_user)
|
||||
team_member_delete_mock = mocker.patch(
|
||||
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete",
|
||||
AsyncMock(),
|
||||
)
|
||||
|
||||
await delete_user(user_id=user_id)
|
||||
|
||||
team_member_delete_mock.assert_awaited_once()
|
||||
call = team_member_delete_mock.call_args
|
||||
assert call.kwargs["data"].team_id == "team-1"
|
||||
assert call.kwargs["data"].user_id == user_id
|
||||
assert call.kwargs["user_api_key_dict"].user_role == LitellmUserRoles.PROXY_ADMIN
|
||||
mock_prisma_client.db.litellm_usertable.delete.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user_surfaces_prune_failure_and_keeps_user(mocker):
|
||||
"""A genuine failure while pruning members_with_roles must surface: the
|
||||
endpoint fails loudly and the user row is NOT deleted, so we never report a
|
||||
successful delete while leaving a dangling member (SCIM DELETE is idempotent,
|
||||
so the IdP retries)."""
|
||||
user_id = "scim-del-user"
|
||||
|
||||
existing_user = mocker.MagicMock()
|
||||
existing_user.teams = ["team-1"]
|
||||
|
||||
team = LiteLLM_TeamTable(
|
||||
team_id="team-1",
|
||||
members=[user_id],
|
||||
members_with_roles=[Member(user_id=user_id, role="user")],
|
||||
)
|
||||
|
||||
mock_prisma_client = _mock_prisma_for_delete_user(mocker, team)
|
||||
_patch_delete_user_dependencies(mocker, mock_prisma_client, existing_user)
|
||||
mocker.patch(
|
||||
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete",
|
||||
AsyncMock(side_effect=Exception("database connection lost")),
|
||||
)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
await delete_user(user_id=user_id)
|
||||
|
||||
mock_prisma_client.db.litellm_usertable.delete.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user_skips_teams_where_not_a_member(mocker):
|
||||
"""If the user is not in a team's members_with_roles, deletion must treat that
|
||||
team as a no-op (no team_member_delete call, no error) and still delete the
|
||||
user, so a stale legacy membership can't block the delete."""
|
||||
user_id = "scim-del-user"
|
||||
|
||||
existing_user = mocker.MagicMock()
|
||||
existing_user.teams = ["team-1"]
|
||||
|
||||
team = LiteLLM_TeamTable(
|
||||
team_id="team-1",
|
||||
members=[user_id],
|
||||
members_with_roles=[Member(user_id="someone-else", role="admin")],
|
||||
)
|
||||
|
||||
mock_prisma_client = _mock_prisma_for_delete_user(mocker, team)
|
||||
_patch_delete_user_dependencies(mocker, mock_prisma_client, existing_user)
|
||||
team_member_delete_mock = mocker.patch(
|
||||
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete",
|
||||
AsyncMock(),
|
||||
)
|
||||
|
||||
await delete_user(user_id=user_id)
|
||||
|
||||
team_member_delete_mock.assert_not_awaited()
|
||||
mock_prisma_client.db.litellm_usertable.delete.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_group_add_applies_delta_and_keeps_concurrent_add(mocker):
|
||||
"""A group PATCH op:add must be applied as a delta against the live roster,
|
||||
not as a snapshot-based absolute target.
|
||||
|
||||
When a concurrent PATCH has already added a member between this request's
|
||||
initial read and its post-write refresh, that member shows up in the
|
||||
refreshed roster but not in this request's snapshot-derived target. Diffing
|
||||
the refreshed roster against the snapshot target would issue a spurious
|
||||
team_member_delete for the concurrently-added member. Applying only this
|
||||
request's intended delta on top of the refreshed roster must retain them.
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.scim.scim_transformations import (
|
||||
ScimTransformations,
|
||||
)
|
||||
|
||||
group_id = "team-concurrent"
|
||||
|
||||
snapshot_team = LiteLLM_TeamTable(
|
||||
team_id=group_id,
|
||||
team_alias="Group",
|
||||
members_with_roles=[Member(user_id="zed", role="user")],
|
||||
metadata={"externalId": "grp-ext"},
|
||||
)
|
||||
refreshed_team = LiteLLM_TeamTable(
|
||||
team_id=group_id,
|
||||
team_alias="Group",
|
||||
members_with_roles=[
|
||||
Member(user_id="zed", role="user"),
|
||||
Member(user_id="alice", role="user"),
|
||||
],
|
||||
metadata={"externalId": "grp-ext"},
|
||||
)
|
||||
final_team = LiteLLM_TeamTable(
|
||||
team_id=group_id,
|
||||
team_alias="Group",
|
||||
members_with_roles=[
|
||||
Member(user_id="zed", role="user"),
|
||||
Member(user_id="alice", role="user"),
|
||||
Member(user_id="bob", role="user"),
|
||||
],
|
||||
metadata={"externalId": "grp-ext"},
|
||||
)
|
||||
|
||||
patch_ops = SCIMPatchOp(
|
||||
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
Operations=[SCIMPatchOperation(op="add", path="members", value=[{"value": "bob"}])],
|
||||
)
|
||||
|
||||
mock_prisma_client = mocker.MagicMock()
|
||||
mock_prisma_client.db = mocker.MagicMock()
|
||||
mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
|
||||
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(
|
||||
side_effect=[snapshot_team, refreshed_team, final_team]
|
||||
)
|
||||
mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=final_team)
|
||||
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
|
||||
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mocker.MagicMock())
|
||||
|
||||
mocker.patch(
|
||||
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
|
||||
AsyncMock(return_value=mock_prisma_client),
|
||||
)
|
||||
patch_membership_mock = mocker.patch(
|
||||
"litellm.proxy.management_endpoints.scim.scim_v2.patch_team_membership",
|
||||
AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"litellm.proxy.management_endpoints.scim.scim_v2._recompute_scim_member_roles",
|
||||
AsyncMock(),
|
||||
)
|
||||
mocker.patch.object(
|
||||
ScimTransformations,
|
||||
"transform_litellm_team_to_scim_group",
|
||||
AsyncMock(
|
||||
return_value=SCIMGroup(
|
||||
schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
id=group_id,
|
||||
displayName="Group",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
await patch_group(group_id=group_id, patch_ops=patch_ops)
|
||||
|
||||
calls = patch_membership_mock.call_args_list
|
||||
|
||||
removed_user_ids = {
|
||||
call.kwargs["user_id"] for call in calls if call.kwargs.get("teams_ids_to_remove_user_from") == [group_id]
|
||||
}
|
||||
assert removed_user_ids == set()
|
||||
|
||||
added_user_ids = {
|
||||
call.kwargs["user_id"] for call in calls if call.kwargs.get("teams_ids_to_add_user_to") == [group_id]
|
||||
}
|
||||
assert added_user_ids == {"bob"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_group_replace_stays_absolute_against_concurrent_roster(mocker):
|
||||
"""A group PATCH ``replace`` op declares the roster is exactly the given set,
|
||||
so it must reconcile as a set-to-target, not as a delta.
|
||||
|
||||
Unlike ``add``/``remove``, ``replace`` is absolute. A member that another
|
||||
request added concurrently is present in the refreshed roster but not in the
|
||||
replace target, and ``replace`` must drop it. Rebasing the replace onto the
|
||||
refreshed roster (the delta behavior correct only for add/remove) would
|
||||
wrongly retain that concurrently-added member.
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.scim.scim_transformations import (
|
||||
ScimTransformations,
|
||||
)
|
||||
|
||||
group_id = "team-replace-concurrent"
|
||||
|
||||
snapshot_team = LiteLLM_TeamTable(
|
||||
team_id=group_id,
|
||||
team_alias="Group",
|
||||
members_with_roles=[Member(user_id="zed", role="user")],
|
||||
metadata={"externalId": "grp-ext"},
|
||||
)
|
||||
refreshed_team = LiteLLM_TeamTable(
|
||||
team_id=group_id,
|
||||
team_alias="Group",
|
||||
members_with_roles=[
|
||||
Member(user_id="alice", role="user"),
|
||||
Member(user_id="bob", role="user"),
|
||||
],
|
||||
metadata={"externalId": "grp-ext"},
|
||||
)
|
||||
final_team = LiteLLM_TeamTable(
|
||||
team_id=group_id,
|
||||
team_alias="Group",
|
||||
members_with_roles=[Member(user_id="alice", role="user")],
|
||||
metadata={"externalId": "grp-ext"},
|
||||
)
|
||||
|
||||
patch_ops = SCIMPatchOp(
|
||||
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
Operations=[SCIMPatchOperation(op="replace", path="members", value=[{"value": "alice"}])],
|
||||
)
|
||||
|
||||
mock_prisma_client = mocker.MagicMock()
|
||||
mock_prisma_client.db = mocker.MagicMock()
|
||||
mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
|
||||
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(
|
||||
side_effect=[snapshot_team, refreshed_team, final_team]
|
||||
)
|
||||
mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=final_team)
|
||||
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
|
||||
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mocker.MagicMock())
|
||||
|
||||
mocker.patch(
|
||||
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
|
||||
AsyncMock(return_value=mock_prisma_client),
|
||||
)
|
||||
patch_membership_mock = mocker.patch(
|
||||
"litellm.proxy.management_endpoints.scim.scim_v2.patch_team_membership",
|
||||
AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"litellm.proxy.management_endpoints.scim.scim_v2._recompute_scim_member_roles",
|
||||
AsyncMock(),
|
||||
)
|
||||
mocker.patch.object(
|
||||
ScimTransformations,
|
||||
"transform_litellm_team_to_scim_group",
|
||||
AsyncMock(
|
||||
return_value=SCIMGroup(
|
||||
schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
id=group_id,
|
||||
displayName="Group",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
await patch_group(group_id=group_id, patch_ops=patch_ops)
|
||||
|
||||
calls = patch_membership_mock.call_args_list
|
||||
|
||||
removed_user_ids = {
|
||||
call.kwargs["user_id"] for call in calls if call.kwargs.get("teams_ids_to_remove_user_from") == [group_id]
|
||||
}
|
||||
assert removed_user_ids == {"bob"}
|
||||
|
||||
added_user_ids = {
|
||||
call.kwargs["user_id"] for call in calls if call.kwargs.get("teams_ids_to_add_user_to") == [group_id]
|
||||
}
|
||||
assert added_user_ids == set()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path, attribute, expected",
|
||||
[
|
||||
('members[value eq "user-1"]', "members", ["user-1"]),
|
||||
("members[value eq 'user-1']", "members", ["user-1"]),
|
||||
('members[value EQ "user-1"]', "members", ["user-1"]),
|
||||
('members[ value eq "user-1" ]', "members", ["user-1"]),
|
||||
('groups[value eq "team-1"]', "groups", ["team-1"]),
|
||||
('members[value eq "Mixed-CASE-Id"]', "members", ["Mixed-CASE-Id"]),
|
||||
('members[value eq "a\\"b"]', "members", ['a"b']),
|
||||
('members[value eq "a\\\\b"]', "members", ["a\\b"]),
|
||||
("members[value eq 'a\\'b']", "members", ["a'b"]),
|
||||
("members", "members", []),
|
||||
('groups[value eq "team-1"]', "members", []),
|
||||
(None, "members", []),
|
||||
('members[value eq ""]', "members", []),
|
||||
("members[value eq user-1]", "members", []),
|
||||
("members[value eq unintendeduser]", "members", []),
|
||||
],
|
||||
)
|
||||
def test_extract_ids_from_path_filter(path, attribute, expected):
|
||||
assert _extract_ids_from_path_filter(path, attribute) == expected
|
||||
|
||||
|
||||
def test_extract_ids_from_path_filter_unterminated_is_linear():
|
||||
"""A pathological unterminated quoted filter must not trigger super-linear
|
||||
backtracking; it returns no id and completes near-instantly."""
|
||||
pathological = 'members[value eq "' + ("\\" * 200)
|
||||
|
||||
start = time.perf_counter()
|
||||
result = _extract_ids_from_path_filter(pathological, "members")
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
assert result == []
|
||||
assert elapsed < 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_group_patch_remove_filtered_path_without_value(mocker):
|
||||
"""Okta sends group membership removals as a filtered path with no request
|
||||
body value; the member id must be parsed out of members[value eq "..."]"""
|
||||
patch_ops = SCIMPatchOp(
|
||||
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
Operations=[SCIMPatchOperation(op="remove", path='members[value eq "user-1"]')],
|
||||
)
|
||||
|
||||
existing_team = LiteLLM_TeamTable(
|
||||
team_id="team-1",
|
||||
team_alias="Team One",
|
||||
members=[],
|
||||
members_with_roles=[
|
||||
Member(user_id="user-1", role="user"),
|
||||
Member(user_id="user-2", role="user"),
|
||||
],
|
||||
)
|
||||
|
||||
prisma_client = mocker.MagicMock()
|
||||
prisma_client.db = mocker.MagicMock()
|
||||
prisma_client.db.litellm_usertable = mocker.MagicMock()
|
||||
prisma_client.db.litellm_usertable.find_unique = AsyncMock(
|
||||
return_value=LiteLLM_UserTable(user_id="user-1")
|
||||
)
|
||||
|
||||
_, final_members, _ = await _process_group_patch_operations(
|
||||
patch_ops=patch_ops,
|
||||
existing_team=existing_team,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
|
||||
assert final_members == {"user-2"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_group_patch_add_filtered_path_without_value(mocker):
|
||||
"""A filtered add path with no body value adds the id parsed from the filter."""
|
||||
patch_ops = SCIMPatchOp(
|
||||
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
Operations=[SCIMPatchOperation(op="add", path='members[value eq "user-3"]')],
|
||||
)
|
||||
|
||||
existing_team = LiteLLM_TeamTable(
|
||||
team_id="team-1",
|
||||
team_alias="Team One",
|
||||
members=[],
|
||||
members_with_roles=[Member(user_id="user-1", role="user")],
|
||||
)
|
||||
|
||||
prisma_client = mocker.MagicMock()
|
||||
prisma_client.db = mocker.MagicMock()
|
||||
prisma_client.db.litellm_usertable = mocker.MagicMock()
|
||||
prisma_client.db.litellm_usertable.find_unique = AsyncMock(
|
||||
return_value=LiteLLM_UserTable(user_id="user-3")
|
||||
)
|
||||
|
||||
_, final_members, _ = await _process_group_patch_operations(
|
||||
patch_ops=patch_ops,
|
||||
existing_team=existing_team,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
|
||||
assert final_members == {"user-1", "user-3"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_group_patch_replace_empty_value_does_not_use_path_filter(mocker):
|
||||
"""An explicit empty replace value must clear membership rather than pull an
|
||||
id from the filtered path, which would retain one member and drop the rest."""
|
||||
patch_ops = SCIMPatchOp(
|
||||
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
Operations=[
|
||||
SCIMPatchOperation(op="replace", path='members[value eq "user-1"]', value=[])
|
||||
],
|
||||
)
|
||||
|
||||
existing_team = LiteLLM_TeamTable(
|
||||
team_id="team-1",
|
||||
team_alias="Team One",
|
||||
members=[],
|
||||
members_with_roles=[
|
||||
Member(user_id="user-1", role="user"),
|
||||
Member(user_id="user-2", role="user"),
|
||||
],
|
||||
)
|
||||
|
||||
prisma_client = mocker.MagicMock()
|
||||
prisma_client.db = mocker.MagicMock()
|
||||
prisma_client.db.litellm_usertable = mocker.MagicMock()
|
||||
prisma_client.db.litellm_usertable.find_unique = AsyncMock(
|
||||
return_value=LiteLLM_UserTable(user_id="user-1")
|
||||
)
|
||||
|
||||
_, final_members, _ = await _process_group_patch_operations(
|
||||
patch_ops=patch_ops,
|
||||
existing_team=existing_team,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
|
||||
assert final_members == set()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import os
|
|||
import sys
|
||||
import types
|
||||
import json
|
||||
from contextlib import ExitStack
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import List, Optional
|
||||
|
|
@ -2025,8 +2026,469 @@ class TestTemporaryMCPSessionEndpoints:
|
|||
code_challenge_method="S256",
|
||||
response_type="code",
|
||||
scope="scope1",
|
||||
ephemeral_dcr_client=None,
|
||||
)
|
||||
|
||||
async def _authorize_without_client_id(
|
||||
self, server, mint_mock=None, code_challenge="chal", code_challenge_method="S256"
|
||||
):
|
||||
"""Drive mcp_authorize with no caller client_id against ``server``, returning the
|
||||
(authorize_with_server mock, raised HTTPException or None) pair. Sends a valid S256 PKCE
|
||||
pair by default because the ephemeral mint requires it."""
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
|
||||
mcp_authorize,
|
||||
)
|
||||
|
||||
request = MagicMock()
|
||||
admin_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
patches = [
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints._get_cached_temporary_mcp_server_or_404",
|
||||
return_value=server,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints.authorize_with_server",
|
||||
AsyncMock(return_value=MagicMock()),
|
||||
),
|
||||
]
|
||||
if mint_mock is not None:
|
||||
patches.append(
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.mint_ephemeral_dcr_client",
|
||||
mint_mock,
|
||||
)
|
||||
)
|
||||
with ExitStack() as stack:
|
||||
entered = [stack.enter_context(p) for p in patches]
|
||||
authorize_mock = entered[1]
|
||||
try:
|
||||
await mcp_authorize(
|
||||
request=request,
|
||||
server_id=server.server_id,
|
||||
user_api_key_dict=admin_auth,
|
||||
client_id=None,
|
||||
redirect_uri="http://127.0.0.1:60108/callback",
|
||||
state="state123",
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method=code_challenge_method,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
return authorize_mock, exc
|
||||
return authorize_mock, None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"code_challenge, code_challenge_method",
|
||||
[(None, None), ("chal", "plain"), ("chal", None)],
|
||||
)
|
||||
async def test_mcp_authorize_mint_requires_s256_pkce(self, code_challenge, code_challenge_method):
|
||||
"""Without PKCE the sealed code would be bearer-redeemable by any authenticated caller who
|
||||
intercepts the redirect, so the ephemeral mint refuses to run for a downgraded flow (no
|
||||
challenge, or a non-S256 method) before any upstream registration happens."""
|
||||
server = generate_mock_mcp_server_config_record(server_id="server-1")
|
||||
server.auth_type = MCPAuth.true_passthrough
|
||||
server.authorization_url = "https://idp.example.com/authorize"
|
||||
server.registration_url = "https://idp.example.com/register"
|
||||
mint_mock = AsyncMock()
|
||||
|
||||
authorize_mock, exc = await self._authorize_without_client_id(
|
||||
server, mint_mock=mint_mock, code_challenge=code_challenge, code_challenge_method=code_challenge_method
|
||||
)
|
||||
|
||||
assert exc is not None
|
||||
assert exc.status_code == 400
|
||||
assert "PKCE" in str(exc.detail)
|
||||
mint_mock.assert_not_awaited()
|
||||
authorize_mock.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
|
||||
async def test_mcp_authorize_client_forwarded_modes_mint_ephemeral_dcr_client_when_none_supplied(self, auth_type):
|
||||
"""LIT-4581 regression: a client-forwarded-token server created without an auth step has no
|
||||
stored client_id and the tools-tab browser flow supplies none, so authorize must fall
|
||||
through to a gateway-side DCR mint and proceed with the minted client instead of
|
||||
dead-ending on a 400 missing_client_id. Both modes share the caller-held-client contract,
|
||||
so both get the fall-through."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
EphemeralDcrClient,
|
||||
)
|
||||
|
||||
server = generate_mock_mcp_server_config_record(server_id="server-1")
|
||||
server.auth_type = auth_type
|
||||
server.authorization_url = "https://idp.example.com/authorize"
|
||||
server.registration_url = "https://idp.example.com/register"
|
||||
minted = EphemeralDcrClient(client_id="minted-77", client_secret="mint-secret")
|
||||
mint_mock = AsyncMock(return_value=minted)
|
||||
|
||||
authorize_mock, exc = await self._authorize_without_client_id(server, mint_mock=mint_mock)
|
||||
|
||||
assert exc is None
|
||||
mint_mock.assert_awaited_once()
|
||||
assert authorize_mock.await_args.kwargs["client_id"] == "minted-77"
|
||||
assert authorize_mock.await_args.kwargs["ephemeral_dcr_client"] is minted
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_authorize_rejects_untrusted_redirect_before_minting(self):
|
||||
"""An untrusted redirect_uri must be rejected before the gateway performs any upstream
|
||||
registration, so bad-redirect requests cannot be used to generate orphan clients at the
|
||||
IdP."""
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
|
||||
mcp_authorize,
|
||||
)
|
||||
|
||||
server = generate_mock_mcp_server_config_record(server_id="server-1")
|
||||
server.auth_type = MCPAuth.true_passthrough
|
||||
server.authorization_url = "https://idp.example.com/authorize"
|
||||
server.registration_url = "https://idp.example.com/register"
|
||||
mint_mock = AsyncMock()
|
||||
admin_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
request = MagicMock()
|
||||
request.base_url = "https://litellm.example.com/"
|
||||
request.headers = {}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints._get_cached_temporary_mcp_server_or_404",
|
||||
return_value=server,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.mint_ephemeral_dcr_client",
|
||||
mint_mock,
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await mcp_authorize(
|
||||
request=request,
|
||||
server_id="server-1",
|
||||
user_api_key_dict=admin_auth,
|
||||
client_id=None,
|
||||
redirect_uri="https://evil.example.net/steal",
|
||||
state="state123",
|
||||
code_challenge="chal",
|
||||
code_challenge_method="S256",
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
mint_mock.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_authorize_true_passthrough_without_authorization_url_reports_the_real_fault(self):
|
||||
"""A passthrough server whose discovery never yielded an authorize endpoint cannot start any
|
||||
flow, minted client or not, so the error names the missing authorization url instead of the
|
||||
misleading missing_client_id remedy."""
|
||||
server = generate_mock_mcp_server_config_record(server_id="server-1")
|
||||
server.auth_type = MCPAuth.true_passthrough
|
||||
server.authorization_url = None
|
||||
server.registration_url = "https://idp.example.com/register"
|
||||
mint_mock = AsyncMock()
|
||||
|
||||
authorize_mock, exc = await self._authorize_without_client_id(server, mint_mock=mint_mock)
|
||||
|
||||
assert exc is not None
|
||||
assert exc.status_code == 400
|
||||
assert "authorization url" in str(exc.detail)
|
||||
mint_mock.assert_not_awaited()
|
||||
authorize_mock.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_authorize_true_passthrough_without_registration_endpoint_keeps_missing_client_id(self):
|
||||
"""When the upstream exposes no registration endpoint the mint is impossible, so the
|
||||
authorize fails closed with the existing missing_client_id 400 instead of proceeding with an
|
||||
empty client."""
|
||||
server = generate_mock_mcp_server_config_record(server_id="server-1")
|
||||
server.auth_type = MCPAuth.true_passthrough
|
||||
server.authorization_url = "https://idp.example.com/authorize"
|
||||
server.registration_url = None
|
||||
|
||||
authorize_mock, exc = await self._authorize_without_client_id(server)
|
||||
|
||||
assert exc is not None
|
||||
assert exc.status_code == 400
|
||||
assert exc.detail["error"] == "missing_client_id"
|
||||
authorize_mock.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_authorize_oauth2_server_does_not_mint(self):
|
||||
"""The ephemeral mint is scoped to the client-forwarded-token modes: a plain oauth2 server
|
||||
keeps the gateway-held-client contract (its client is persisted by the admin register flow),
|
||||
so an empty client_id stays a 400 and no upstream registration is attempted."""
|
||||
server = generate_mock_mcp_server_config_record(server_id="server-1")
|
||||
server.auth_type = MCPAuth.oauth2
|
||||
server.authorization_url = "https://idp.example.com/authorize"
|
||||
server.registration_url = "https://idp.example.com/register"
|
||||
mint_mock = AsyncMock()
|
||||
|
||||
authorize_mock, exc = await self._authorize_without_client_id(server, mint_mock=mint_mock)
|
||||
|
||||
assert exc is not None
|
||||
assert exc.status_code == 400
|
||||
assert exc.detail["error"] == "missing_client_id"
|
||||
mint_mock.assert_not_awaited()
|
||||
authorize_mock.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_authorize_true_passthrough_dcr_bridge_mints_too(self):
|
||||
"""The UI creates passthrough servers with dcr_bridge enabled by default, so the default
|
||||
clientless tools-page authorize is a bridge server; it must mint exactly like a non-bridge
|
||||
one (the minted flow runs the bridge short-circuit arm) instead of dead-ending on
|
||||
missing_client_id. The relay front door stays reserved for clients that present their own
|
||||
client_id."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
EphemeralDcrClient,
|
||||
)
|
||||
|
||||
server = generate_mock_mcp_server_config_record(server_id="server-1")
|
||||
server.auth_type = MCPAuth.true_passthrough
|
||||
server.dcr_bridge = True
|
||||
server.authorization_url = "https://idp.example.com/authorize"
|
||||
server.registration_url = "https://idp.example.com/register"
|
||||
minted = EphemeralDcrClient(client_id="minted-77", client_secret=None)
|
||||
mint_mock = AsyncMock(return_value=minted)
|
||||
|
||||
authorize_mock, exc = await self._authorize_without_client_id(server, mint_mock=mint_mock)
|
||||
|
||||
assert exc is None
|
||||
mint_mock.assert_awaited_once()
|
||||
assert authorize_mock.await_args.kwargs["client_id"] == "minted-77"
|
||||
assert authorize_mock.await_args.kwargs["ephemeral_dcr_client"] is minted
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_authorize_oauth_delegate_dcr_bridge_does_not_mint(self):
|
||||
"""The interactive oauth_delegate dcr_bridge sign-in has its own sealed-identity flow that
|
||||
captures the SSO user at authorize; the ephemeral mint must not preempt it."""
|
||||
server = generate_mock_mcp_server_config_record(server_id="server-1")
|
||||
server.auth_type = MCPAuth.oauth_delegate
|
||||
server.dcr_bridge = True
|
||||
server.authorization_url = "https://idp.example.com/authorize"
|
||||
server.registration_url = "https://idp.example.com/register"
|
||||
mint_mock = AsyncMock()
|
||||
|
||||
authorize_mock, exc = await self._authorize_without_client_id(server, mint_mock=mint_mock)
|
||||
|
||||
assert exc is not None
|
||||
assert exc.status_code == 400
|
||||
assert exc.detail["error"] == "missing_client_id"
|
||||
mint_mock.assert_not_awaited()
|
||||
authorize_mock.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_token_opens_sealed_passthrough_code_and_exchanges_with_minted_client(self):
|
||||
"""LIT-4581 regression, token leg: the client echoes back the sealed passthrough code the
|
||||
callback forwarded, so the token endpoint recovers the ephemeral client and the real
|
||||
upstream code from it and authenticates the exchange with them, with no client_id supplied
|
||||
by the caller and none stored on the server."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
seal_passthrough_authorization_code,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
|
||||
mcp_token,
|
||||
)
|
||||
|
||||
request = MagicMock()
|
||||
request.base_url = "https://litellm.example.com/"
|
||||
request.headers = {}
|
||||
server = generate_mock_mcp_server_config_record(server_id="server-1")
|
||||
server.auth_type = MCPAuth.true_passthrough
|
||||
admin_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.master_key", "sk-lit4581-test-master-key"),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints._get_cached_temporary_mcp_server_or_404",
|
||||
return_value=server,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints.exchange_token_with_server",
|
||||
AsyncMock(return_value={"access_token": "token"}),
|
||||
) as exchange_mock,
|
||||
):
|
||||
sealed = seal_passthrough_authorization_code(
|
||||
upstream_code="up-code",
|
||||
client_id="minted-77",
|
||||
client_secret="mint-secret",
|
||||
mcp_server_id="server-1",
|
||||
token_endpoint_auth_method="client_secret_basic",
|
||||
)
|
||||
result = await mcp_token(
|
||||
request=request,
|
||||
server_id="server-1",
|
||||
user_api_key_dict=admin_auth,
|
||||
grant_type="authorization_code",
|
||||
code=sealed,
|
||||
redirect_uri="https://example.com/callback",
|
||||
client_id=None,
|
||||
client_secret=None,
|
||||
code_verifier="verifier",
|
||||
refresh_token=None,
|
||||
scope=None,
|
||||
)
|
||||
|
||||
assert result == {"access_token": "token"}
|
||||
assert exchange_mock.await_args.kwargs["code"] == "up-code"
|
||||
assert exchange_mock.await_args.kwargs["client_id"] == "minted-77"
|
||||
assert exchange_mock.await_args.kwargs["client_secret"] == "mint-secret"
|
||||
assert exchange_mock.await_args.kwargs["redirect_uri"] == "https://litellm.example.com/callback"
|
||||
assert exchange_mock.await_args.kwargs["client_token_endpoint_auth_method"] == "client_secret_basic"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_token_refresh_grant_never_opens_sealed_code(self):
|
||||
"""The minted client is unrecoverable outside the single authorization_code flow by
|
||||
contract: a refresh_token grant that echoes a leftover sealed passthrough code (plus any
|
||||
verifier) must not recover the minted credentials, so a clientless server answers
|
||||
missing_client_id and the client re-runs authorize instead."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
seal_passthrough_authorization_code,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
|
||||
mcp_token,
|
||||
)
|
||||
|
||||
request = MagicMock()
|
||||
request.base_url = "https://litellm.example.com/"
|
||||
request.headers = {}
|
||||
server = generate_mock_mcp_server_config_record(server_id="server-1")
|
||||
server.auth_type = MCPAuth.true_passthrough
|
||||
admin_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.master_key", "sk-lit4581-test-master-key"),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints._get_cached_temporary_mcp_server_or_404",
|
||||
return_value=server,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints.exchange_token_with_server",
|
||||
AsyncMock(return_value={"access_token": "token"}),
|
||||
) as exchange_mock,
|
||||
):
|
||||
sealed = seal_passthrough_authorization_code(
|
||||
upstream_code="up-code",
|
||||
client_id="minted-77",
|
||||
client_secret="mint-secret",
|
||||
mcp_server_id="server-1",
|
||||
token_endpoint_auth_method="client_secret_basic",
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await mcp_token(
|
||||
request=request,
|
||||
server_id="server-1",
|
||||
user_api_key_dict=admin_auth,
|
||||
grant_type="refresh_token",
|
||||
code=sealed,
|
||||
redirect_uri="https://example.com/callback",
|
||||
client_id=None,
|
||||
client_secret=None,
|
||||
code_verifier="verifier",
|
||||
refresh_token="leftover-refresh",
|
||||
scope=None,
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail["error"] == "missing_client_id"
|
||||
exchange_mock.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_token_sealed_code_requires_code_verifier(self):
|
||||
"""A sealed code is minted only for S256 PKCE flows, so redeeming one without the
|
||||
corresponding verifier is refused at the gateway rather than trusting the upstream to
|
||||
enforce the binding."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
seal_passthrough_authorization_code,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
|
||||
mcp_token,
|
||||
)
|
||||
|
||||
request = MagicMock()
|
||||
server = generate_mock_mcp_server_config_record(server_id="server-1")
|
||||
server.auth_type = MCPAuth.true_passthrough
|
||||
admin_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.master_key", "sk-lit4581-test-master-key"),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints._get_cached_temporary_mcp_server_or_404",
|
||||
return_value=server,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints.exchange_token_with_server",
|
||||
AsyncMock(),
|
||||
) as exchange_mock,
|
||||
):
|
||||
sealed = seal_passthrough_authorization_code(
|
||||
upstream_code="up-code", client_id="minted-77", client_secret=None, mcp_server_id="server-1"
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await mcp_token(
|
||||
request=request,
|
||||
server_id="server-1",
|
||||
user_api_key_dict=admin_auth,
|
||||
grant_type="authorization_code",
|
||||
code=sealed,
|
||||
redirect_uri="https://example.com/callback",
|
||||
client_id=None,
|
||||
client_secret=None,
|
||||
code_verifier=None,
|
||||
refresh_token=None,
|
||||
scope=None,
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert "code_verifier" in str(exc.value.detail)
|
||||
exchange_mock.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_token_rejects_sealed_code_for_another_server(self):
|
||||
"""A sealed passthrough code is bound to the server it was minted for: presenting it at
|
||||
another server's token endpoint is a 400 before any upstream exchange, so a code cannot be
|
||||
replayed across a server boundary."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
seal_passthrough_authorization_code,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
|
||||
mcp_token,
|
||||
)
|
||||
|
||||
request = MagicMock()
|
||||
server = generate_mock_mcp_server_config_record(server_id="server-1")
|
||||
server.auth_type = MCPAuth.true_passthrough
|
||||
admin_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.master_key", "sk-lit4581-test-master-key"),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints._get_cached_temporary_mcp_server_or_404",
|
||||
return_value=server,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints.exchange_token_with_server",
|
||||
AsyncMock(),
|
||||
) as exchange_mock,
|
||||
):
|
||||
sealed = seal_passthrough_authorization_code(
|
||||
upstream_code="up-code",
|
||||
client_id="minted-77",
|
||||
client_secret=None,
|
||||
mcp_server_id="a-different-server",
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await mcp_token(
|
||||
request=request,
|
||||
server_id="server-1",
|
||||
user_api_key_dict=admin_auth,
|
||||
grant_type="authorization_code",
|
||||
code=sealed,
|
||||
redirect_uri="https://example.com/callback",
|
||||
client_id=None,
|
||||
client_secret=None,
|
||||
code_verifier="verifier",
|
||||
refresh_token=None,
|
||||
scope=None,
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
exchange_mock.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_authorize_rejects_non_oauth2_server(self):
|
||||
"""mcp_authorize must reject a none-auth server with an accurate 'does not use OAuth'
|
||||
|
|
@ -2163,6 +2625,7 @@ class TestTemporaryMCPSessionEndpoints:
|
|||
code_verifier="verifier",
|
||||
refresh_token=None,
|
||||
scope=None,
|
||||
client_token_endpoint_auth_method=None,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -2216,6 +2679,7 @@ class TestTemporaryMCPSessionEndpoints:
|
|||
code_verifier=None,
|
||||
refresh_token="rt-123",
|
||||
scope=None,
|
||||
client_token_endpoint_auth_method=None,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -2270,8 +2734,59 @@ class TestTemporaryMCPSessionEndpoints:
|
|||
token_endpoint_auth_method="client_secret_basic",
|
||||
fallback_client_id="server-1",
|
||||
persist_credentials=True,
|
||||
client_redirect_uris=None,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"raw_redirect_uris, forwarded",
|
||||
[
|
||||
(["https://app.example.com/ui/callback"], ["https://app.example.com/ui/callback"]),
|
||||
(["https://app.example.com/ui/callback", 42, "", None], None),
|
||||
("not-a-list", None),
|
||||
([], None),
|
||||
([123], None),
|
||||
],
|
||||
)
|
||||
async def test_mcp_register_forwards_validated_redirect_uris(self, raw_redirect_uris, forwarded):
|
||||
"""dcr_bridge servers relay the registration upstream and require the browser client's own
|
||||
redirect_uris, so mcp_register must forward them; the value is caller-controlled and is
|
||||
validated by the same client_supplied_redirect_uris boundary helper as the root /register
|
||||
door, so a malformed list is rejected whole at both doors (RFC 7591 redirect_uris is
|
||||
all-or-nothing) rather than silently forwarding the surviving entries here and rejecting
|
||||
them there."""
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
|
||||
mcp_register,
|
||||
)
|
||||
|
||||
request = MagicMock()
|
||||
server = generate_mock_mcp_server_config_record(server_id="server-1")
|
||||
server.auth_type = MCPAuth.oauth2
|
||||
request_body = {"client_name": "LiteLLM", "redirect_uris": raw_redirect_uris}
|
||||
admin_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints._get_cached_temporary_mcp_server_or_404",
|
||||
return_value=server,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints._read_request_body",
|
||||
AsyncMock(return_value=request_body),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints.register_client_with_server",
|
||||
AsyncMock(return_value={"client_id": "generated"}),
|
||||
) as register_mock,
|
||||
):
|
||||
await mcp_register(
|
||||
request=request,
|
||||
server_id="server-1",
|
||||
user_api_key_dict=admin_auth,
|
||||
)
|
||||
|
||||
assert register_mock.await_args.kwargs["client_redirect_uris"] == forwarded
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_register_does_not_persist_for_non_admin(self):
|
||||
"""A non-admin caller (who may have access to a real server) must not persist the DCR
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@ import pytest
|
|||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../../../")
|
||||
) # Adds the parent directory to the system path
|
||||
sys.path.insert(0, os.path.abspath("../../../")) # Adds the parent directory to the system path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -58,16 +56,12 @@ async def test_organization_update_object_permissions_existing_permission(monkey
|
|||
"vector_stores": ["old_store_1", "old_store_2"],
|
||||
}
|
||||
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(
|
||||
return_value=existing_object_permission
|
||||
)
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(return_value=existing_object_permission)
|
||||
|
||||
# Mock upsert operation
|
||||
updated_permission = MagicMock()
|
||||
updated_permission.object_permission_id = "existing_perm_id_123"
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.upsert = AsyncMock(
|
||||
return_value=updated_permission
|
||||
)
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.upsert = AsyncMock(return_value=updated_permission)
|
||||
|
||||
# Test data with new object permission
|
||||
data_json = {
|
||||
|
|
@ -107,9 +101,7 @@ async def test_get_organization_daily_activity_admin_param_passing(monkeypatch):
|
|||
|
||||
# Mock prisma client
|
||||
mock_prisma_client = AsyncMock()
|
||||
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(
|
||||
return_value=[]
|
||||
)
|
||||
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
# Admin view -> skip membership restriction
|
||||
|
|
@ -121,9 +113,7 @@ async def test_get_organization_daily_activity_admin_param_passing(monkeypatch):
|
|||
# Patch downstream common function and verify call args
|
||||
mocked_response = MagicMock(name="SpendAnalyticsPaginatedResponse")
|
||||
get_daily_activity_mock = AsyncMock(return_value=mocked_response)
|
||||
monkeypatch.setattr(
|
||||
organization_endpoints, "get_daily_activity", get_daily_activity_mock
|
||||
)
|
||||
monkeypatch.setattr(organization_endpoints, "get_daily_activity", get_daily_activity_mock)
|
||||
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin1")
|
||||
result = await get_organization_daily_activity(
|
||||
|
|
@ -172,17 +162,11 @@ async def test_get_organization_daily_activity_non_admin_defaults_to_admin_orgs(
|
|||
|
||||
# Mock prisma client and memberships
|
||||
mock_prisma_client = AsyncMock()
|
||||
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(
|
||||
return_value=[]
|
||||
)
|
||||
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(return_value=[])
|
||||
mock_prisma_client.db.litellm_organizationmembership.find_many = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(
|
||||
organization_id="orgA", user_role=LitellmUserRoles.ORG_ADMIN.value
|
||||
),
|
||||
SimpleNamespace(
|
||||
organization_id="orgB", user_role=LitellmUserRoles.ORG_ADMIN.value
|
||||
),
|
||||
SimpleNamespace(organization_id="orgA", user_role=LitellmUserRoles.ORG_ADMIN.value),
|
||||
SimpleNamespace(organization_id="orgB", user_role=LitellmUserRoles.ORG_ADMIN.value),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
|
@ -196,13 +180,9 @@ async def test_get_organization_daily_activity_non_admin_defaults_to_admin_orgs(
|
|||
# Patch downstream aggregator
|
||||
mocked_response = MagicMock(name="SpendAnalyticsPaginatedResponse")
|
||||
get_daily_activity_mock = AsyncMock(return_value=mocked_response)
|
||||
monkeypatch.setattr(
|
||||
organization_endpoints, "get_daily_activity", get_daily_activity_mock
|
||||
)
|
||||
monkeypatch.setattr(organization_endpoints, "get_daily_activity", get_daily_activity_mock)
|
||||
|
||||
auth = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id="regular-user"
|
||||
)
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="regular-user")
|
||||
await get_organization_daily_activity(
|
||||
organization_ids=None,
|
||||
start_date="2024-02-01",
|
||||
|
|
@ -238,15 +218,9 @@ async def test_get_organization_daily_activity_non_admin_unauthorized_org_raises
|
|||
# Mock prisma client and memberships (only orgA is admin)
|
||||
mock_prisma_client = AsyncMock()
|
||||
mock_prisma_client.db.litellm_organizationmembership.find_many = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(
|
||||
organization_id="orgA", user_role=LitellmUserRoles.ORG_ADMIN.value
|
||||
)
|
||||
]
|
||||
)
|
||||
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(
|
||||
return_value=[]
|
||||
return_value=[SimpleNamespace(organization_id="orgA", user_role=LitellmUserRoles.ORG_ADMIN.value)]
|
||||
)
|
||||
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
# Non-admin view
|
||||
|
|
@ -255,9 +229,7 @@ async def test_get_organization_daily_activity_non_admin_unauthorized_org_raises
|
|||
lambda _: False,
|
||||
)
|
||||
|
||||
auth = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id="regular-user"
|
||||
)
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="regular-user")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_organization_daily_activity(
|
||||
|
|
@ -312,21 +284,17 @@ async def test_organization_update_object_permissions_no_existing_permission(
|
|||
)
|
||||
|
||||
# Mock find_unique to return None (no existing permission)
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(
|
||||
return_value=None
|
||||
)
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(return_value=None)
|
||||
|
||||
# Mock upsert to create new record
|
||||
new_permission = MagicMock()
|
||||
new_permission.object_permission_id = "new_perm_id_456"
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.upsert = AsyncMock(
|
||||
return_value=new_permission
|
||||
)
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.upsert = AsyncMock(return_value=new_permission)
|
||||
|
||||
data_json = {
|
||||
"object_permission": LiteLLM_ObjectPermissionBase(
|
||||
vector_stores=["brand_new_store"]
|
||||
).model_dump(exclude_unset=True, exclude_none=True),
|
||||
"object_permission": LiteLLM_ObjectPermissionBase(vector_stores=["brand_new_store"]).model_dump(
|
||||
exclude_unset=True, exclude_none=True
|
||||
),
|
||||
"organization_alias": "updated_org_2",
|
||||
}
|
||||
|
||||
|
|
@ -381,21 +349,17 @@ async def test_organization_update_object_permissions_missing_permission_record(
|
|||
)
|
||||
|
||||
# Mock find_unique to return None (permission record not found)
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(
|
||||
return_value=None
|
||||
)
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(return_value=None)
|
||||
|
||||
# Mock upsert to create new record
|
||||
new_permission = MagicMock()
|
||||
new_permission.object_permission_id = "recreated_perm_id_789"
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.upsert = AsyncMock(
|
||||
return_value=new_permission
|
||||
)
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.upsert = AsyncMock(return_value=new_permission)
|
||||
|
||||
data_json = {
|
||||
"object_permission": LiteLLM_ObjectPermissionBase(
|
||||
vector_stores=["recreated_store"]
|
||||
).model_dump(exclude_unset=True, exclude_none=True),
|
||||
"object_permission": LiteLLM_ObjectPermissionBase(vector_stores=["recreated_store"]).model_dump(
|
||||
exclude_unset=True, exclude_none=True
|
||||
),
|
||||
"organization_alias": "updated_org_3",
|
||||
}
|
||||
|
||||
|
|
@ -446,18 +410,14 @@ async def test_list_organization_filter_by_org_id(monkeypatch):
|
|||
)
|
||||
|
||||
# Mock find_many to return filtered results
|
||||
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(
|
||||
return_value=[mock_org1]
|
||||
)
|
||||
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(return_value=[mock_org1])
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
# Test as proxy admin
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-user")
|
||||
|
||||
result = await list_organization(
|
||||
org_id="org-123", org_alias=None, user_api_key_dict=auth
|
||||
)
|
||||
result = await list_organization(org_id="org-123", org_alias=None, user_api_key_dict=auth)
|
||||
|
||||
# Verify the correct organization was returned
|
||||
assert len(result) == 1
|
||||
|
|
@ -512,18 +472,14 @@ async def test_list_organization_filter_by_org_alias(monkeypatch):
|
|||
)
|
||||
|
||||
# Mock find_many to return filtered results
|
||||
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(
|
||||
return_value=[mock_org1, mock_org2]
|
||||
)
|
||||
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(return_value=[mock_org1, mock_org2])
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
# Test as proxy admin with org_alias filter
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-user")
|
||||
|
||||
result = await list_organization(
|
||||
org_id=None, org_alias="test", user_api_key_dict=auth
|
||||
)
|
||||
result = await list_organization(org_id=None, org_alias="test", user_api_key_dict=auth)
|
||||
|
||||
# Verify organizations with "test" in alias were returned
|
||||
assert len(result) == 2
|
||||
|
|
@ -532,9 +488,7 @@ async def test_list_organization_filter_by_org_alias(monkeypatch):
|
|||
# Verify find_many was called with correct where conditions (case-insensitive contains)
|
||||
mock_prisma_client.db.litellm_organizationtable.find_many.assert_called_once()
|
||||
call_args = mock_prisma_client.db.litellm_organizationtable.find_many.call_args
|
||||
assert call_args.kwargs["where"] == {
|
||||
"organization_alias": {"contains": "test", "mode": "insensitive"}
|
||||
}
|
||||
assert call_args.kwargs["where"] == {"organization_alias": {"contains": "test", "mode": "insensitive"}}
|
||||
assert call_args.kwargs["include"] == {
|
||||
"litellm_budget_table": True,
|
||||
"members": True,
|
||||
|
|
@ -612,16 +566,12 @@ def patched_org_prisma():
|
|||
),
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj"),
|
||||
):
|
||||
mock_prisma.db.litellm_organizationtable.find_unique = AsyncMock(
|
||||
return_value=victim_row
|
||||
)
|
||||
mock_prisma.db.litellm_organizationtable.find_unique = AsyncMock(return_value=victim_row)
|
||||
yield mock_prisma
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_organization_member_add_rejects_unauthorized_caller(
|
||||
patched_org_prisma, unauthorized_caller
|
||||
):
|
||||
async def test_organization_member_add_rejects_unauthorized_caller(patched_org_prisma, unauthorized_caller):
|
||||
# ``organization_member_add`` catches HTTPException in its
|
||||
# catch-all and re-wraps as ProxyException with the original status
|
||||
# code preserved.
|
||||
|
|
@ -653,9 +603,7 @@ async def test_organization_member_add_rejects_unauthorized_caller(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_organization_member_update_rejects_unauthorized_caller(
|
||||
patched_org_prisma, unauthorized_caller
|
||||
):
|
||||
async def test_organization_member_update_rejects_unauthorized_caller(patched_org_prisma, unauthorized_caller):
|
||||
from litellm.proxy._types import OrganizationMemberUpdateRequest
|
||||
from litellm.proxy.management_endpoints.organization_endpoints import (
|
||||
organization_member_update,
|
||||
|
|
@ -676,9 +624,7 @@ async def test_organization_member_update_rejects_unauthorized_caller(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_organization_member_delete_rejects_unauthorized_caller(
|
||||
patched_org_prisma, unauthorized_caller
|
||||
):
|
||||
async def test_organization_member_delete_rejects_unauthorized_caller(patched_org_prisma, unauthorized_caller):
|
||||
from litellm.proxy._types import OrganizationMemberDeleteRequest
|
||||
from litellm.proxy.management_endpoints.organization_endpoints import (
|
||||
organization_member_delete,
|
||||
|
|
@ -695,3 +641,354 @@ async def test_organization_member_delete_rejects_unauthorized_caller(
|
|||
user_api_key_dict=unauthorized_caller,
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[{"tpm_limit": ""}, {"tmp_limit": None}],
|
||||
ids=["non-numeric-limit", "unknown-key"],
|
||||
)
|
||||
def test_v2_model_rejects_invalid_body(body):
|
||||
"""A non-numeric limit and an unknown/misspelled key are both rejected at model validation (422 at the route)."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
from litellm.proxy._types import OrganizationUpdateRequestV2
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
OrganizationUpdateRequestV2.model_validate(body)
|
||||
|
||||
|
||||
class _FakeTxContext:
|
||||
def __init__(self, tx):
|
||||
self._tx = tx
|
||||
|
||||
async def __aenter__(self):
|
||||
return self._tx
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
async def _run_update_organization_v2(
|
||||
monkeypatch,
|
||||
*,
|
||||
body: dict,
|
||||
existing_budget_id,
|
||||
existing_metadata,
|
||||
existing_object_permission_id=None,
|
||||
existing_object_permission_row=None,
|
||||
):
|
||||
from litellm.proxy._types import (
|
||||
LitellmUserRoles,
|
||||
OrganizationUpdateRequestV2,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.management_endpoints import organization_endpoints
|
||||
from litellm.proxy.management_endpoints.organization_endpoints import (
|
||||
update_organization_v2,
|
||||
)
|
||||
from litellm.proxy.utils import jsonify_object
|
||||
|
||||
mock_prisma_client = AsyncMock()
|
||||
mock_prisma_client.jsonify_object = jsonify_object
|
||||
|
||||
existing_org = MagicMock()
|
||||
existing_org.budget_id = existing_budget_id
|
||||
existing_org.object_permission_id = existing_object_permission_id
|
||||
existing_org.metadata = existing_metadata
|
||||
|
||||
mock_prisma_client.db.litellm_organizationtable.find_unique = AsyncMock(return_value=existing_org)
|
||||
mock_prisma_client.db.litellm_organizationtable.update = AsyncMock(return_value=MagicMock())
|
||||
mock_prisma_client.db.litellm_budgettable.update = AsyncMock()
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(
|
||||
return_value=existing_object_permission_row
|
||||
)
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.upsert = AsyncMock()
|
||||
|
||||
tx = MagicMock()
|
||||
tx.litellm_organizationtable = mock_prisma_client.db.litellm_organizationtable
|
||||
tx.litellm_budgettable = mock_prisma_client.db.litellm_budgettable
|
||||
tx.litellm_objectpermissiontable.upsert = AsyncMock()
|
||||
mock_prisma_client.db.tx = MagicMock(return_value=_FakeTxContext(tx))
|
||||
mock_prisma_client.tx = tx
|
||||
|
||||
call_order = MagicMock()
|
||||
call_order.attach_mock(tx.litellm_objectpermissiontable.upsert, "permission_upsert")
|
||||
call_order.attach_mock(mock_prisma_client.db.litellm_organizationtable.update, "org_update")
|
||||
mock_prisma_client.call_order = call_order
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
monkeypatch.setattr(organization_endpoints, "_verify_org_access", AsyncMock())
|
||||
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1")
|
||||
await update_organization_v2(
|
||||
organization_id="org-1",
|
||||
data=OrganizationUpdateRequestV2.model_validate(body),
|
||||
user_api_key_dict=auth,
|
||||
)
|
||||
return mock_prisma_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_update_clears_tpm_limit_and_metadata(monkeypatch):
|
||||
"""A cleared tpm_limit is written to the budget row as None; a cleared metadata is written as {}."""
|
||||
prisma = await _run_update_organization_v2(
|
||||
monkeypatch,
|
||||
body={"tpm_limit": None, "metadata": None},
|
||||
existing_budget_id="budget-1",
|
||||
existing_metadata={"stale": "value"},
|
||||
)
|
||||
|
||||
budget_write = prisma.db.litellm_budgettable.update.await_args
|
||||
assert budget_write.kwargs["where"] == {"budget_id": "budget-1"}
|
||||
assert budget_write.kwargs["data"]["tpm_limit"] is None
|
||||
assert "soft_budget" not in budget_write.kwargs["data"]
|
||||
|
||||
write_data = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"]
|
||||
assert json.loads(write_data["metadata"]) == {}
|
||||
assert "budget_id" not in write_data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_update_untouched_fields_not_written(monkeypatch):
|
||||
"""Omitted fields are left untouched: only organization_alias is written, no budget-row write."""
|
||||
prisma = await _run_update_organization_v2(
|
||||
monkeypatch,
|
||||
body={"organization_alias": "renamed"},
|
||||
existing_budget_id="budget-1",
|
||||
existing_metadata={"keep": "me"},
|
||||
)
|
||||
|
||||
prisma.db.litellm_budgettable.update.assert_not_awaited()
|
||||
write_data = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"]
|
||||
assert write_data["organization_alias"] == "renamed"
|
||||
assert "metadata" not in write_data
|
||||
assert "tpm_limit" not in write_data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_update_metadata_replaces_not_merges(monkeypatch):
|
||||
"""Sending metadata replaces the stored blob wholesale; a previously-present key is gone."""
|
||||
prisma = await _run_update_organization_v2(
|
||||
monkeypatch,
|
||||
body={"metadata": {"a": 1}},
|
||||
existing_budget_id="budget-1",
|
||||
existing_metadata={"stale": "value"},
|
||||
)
|
||||
write_data = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"]
|
||||
assert json.loads(write_data["metadata"]) == {"a": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_rejects_null_clear_of_non_nullable_fields(monkeypatch):
|
||||
"""organization_alias and models are non-nullable columns, so a null clear is a 422, not a 500."""
|
||||
from litellm.proxy._types import LitellmUserRoles, OrganizationUpdateRequestV2, UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints.organization_endpoints import update_organization_v2
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", AsyncMock())
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1")
|
||||
|
||||
for body in ({"organization_alias": None}, {"models": None}):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_organization_v2(
|
||||
organization_id="org-1",
|
||||
data=OrganizationUpdateRequestV2.model_validate(body),
|
||||
user_api_key_dict=auth,
|
||||
)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_rejects_negative_max_budget(monkeypatch):
|
||||
"""v2 rejects a negative max_budget with a 422 before touching the DB."""
|
||||
from litellm.proxy._types import LitellmUserRoles, OrganizationUpdateRequestV2, UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints.organization_endpoints import update_organization_v2
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", AsyncMock())
|
||||
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_organization_v2(
|
||||
organization_id="org-1",
|
||||
data=OrganizationUpdateRequestV2.model_validate({"max_budget": -5}),
|
||||
user_api_key_dict=auth,
|
||||
)
|
||||
assert exc.value.status_code == 422
|
||||
assert "max_budget" in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_rejects_caller_without_org_access(monkeypatch):
|
||||
"""v2 runs the real _verify_org_access guard: a non-admin without ORG_ADMIN on the org gets 403 and no write."""
|
||||
from litellm.proxy._types import LitellmUserRoles, OrganizationUpdateRequestV2, UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints import organization_endpoints
|
||||
from litellm.proxy.management_endpoints.organization_endpoints import update_organization_v2
|
||||
|
||||
mock_prisma_client = AsyncMock()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
monkeypatch.setattr(organization_endpoints, "_user_has_admin_view", lambda _: False)
|
||||
|
||||
caller = MagicMock()
|
||||
caller.organization_memberships = []
|
||||
monkeypatch.setattr(organization_endpoints, "get_user_object", AsyncMock(return_value=caller))
|
||||
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="user-1")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_organization_v2(
|
||||
organization_id="org-1",
|
||||
data=OrganizationUpdateRequestV2.model_validate({"tpm_limit": 5}),
|
||||
user_api_key_dict=auth,
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
mock_prisma_client.db.litellm_organizationtable.update.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_wires_object_permission_onto_org_write(monkeypatch):
|
||||
"""A sent object_permission merges over the existing permission row and its id is linked onto the org write."""
|
||||
existing_row = MagicMock()
|
||||
existing_row.model_dump.return_value = {
|
||||
"object_permission_id": "op-123",
|
||||
"mcp_servers": ["server-1"],
|
||||
}
|
||||
|
||||
prisma = await _run_update_organization_v2(
|
||||
monkeypatch,
|
||||
body={"object_permission": {"vector_stores": ["vs-1"]}},
|
||||
existing_budget_id="budget-1",
|
||||
existing_metadata={},
|
||||
existing_object_permission_id="op-123",
|
||||
existing_object_permission_row=existing_row,
|
||||
)
|
||||
|
||||
upsert = prisma.tx.litellm_objectpermissiontable.upsert.await_args.kwargs
|
||||
assert upsert["where"] == {"object_permission_id": "op-123"}
|
||||
assert upsert["data"]["update"]["mcp_servers"] == ["server-1"]
|
||||
assert upsert["data"]["update"]["vector_stores"] == ["vs-1"]
|
||||
write_data = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"]
|
||||
assert write_data["object_permission_id"] == "op-123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_object_permission_upsert_runs_inside_transaction(monkeypatch):
|
||||
"""The permission upsert runs on the tx client, before the org write that links it, so a rollback cannot
|
||||
leave merged grants live on a row the org still points at."""
|
||||
prisma = await _run_update_organization_v2(
|
||||
monkeypatch,
|
||||
body={"object_permission": {"vector_stores": ["vs-1"]}},
|
||||
existing_budget_id="budget-1",
|
||||
existing_metadata={},
|
||||
)
|
||||
|
||||
prisma.tx.litellm_objectpermissiontable.upsert.assert_awaited_once()
|
||||
prisma.db.litellm_objectpermissiontable.upsert.assert_not_awaited()
|
||||
|
||||
upsert = prisma.tx.litellm_objectpermissiontable.upsert.await_args.kwargs
|
||||
linked_id = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"]["object_permission_id"]
|
||||
assert upsert["where"] == {"object_permission_id": linked_id}
|
||||
assert upsert["data"]["create"]["object_permission_id"] == linked_id
|
||||
|
||||
ordered = [name for name, _, _ in prisma.call_order.mock_calls if name in ("permission_upsert", "org_update")]
|
||||
assert ordered == ["permission_upsert", "org_update"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_clears_object_permission_when_sent_null(monkeypatch):
|
||||
"""object_permission: null detaches the org's permission row (object_permission_id -> None), no merge."""
|
||||
prisma = await _run_update_organization_v2(
|
||||
monkeypatch,
|
||||
body={"object_permission": None},
|
||||
existing_budget_id="budget-1",
|
||||
existing_metadata={},
|
||||
)
|
||||
|
||||
prisma.tx.litellm_objectpermissiontable.upsert.assert_not_awaited()
|
||||
prisma.db.litellm_objectpermissiontable.find_unique.assert_not_awaited()
|
||||
write_data = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"]
|
||||
assert write_data["object_permission_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_rejects_empty_object_permission(monkeypatch):
|
||||
"""object_permission: {} merges nothing, so it is rejected (send null to clear) rather than silently leaving grants."""
|
||||
from litellm.proxy._types import LitellmUserRoles, OrganizationUpdateRequestV2, UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints import organization_endpoints
|
||||
from litellm.proxy.management_endpoints.organization_endpoints import update_organization_v2
|
||||
|
||||
mock_prisma_client = AsyncMock()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
monkeypatch.setattr(organization_endpoints, "_verify_org_access", AsyncMock())
|
||||
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_organization_v2(
|
||||
organization_id="org-1",
|
||||
data=OrganizationUpdateRequestV2.model_validate({"object_permission": {}}),
|
||||
user_api_key_dict=auth,
|
||||
)
|
||||
assert exc.value.status_code == 422
|
||||
assert "object_permission" in str(exc.value.detail)
|
||||
mock_prisma_client.db.litellm_organizationtable.update.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_writes_budget_and_org_in_one_transaction(monkeypatch):
|
||||
"""A change touching both the budget row and the org row runs both writes inside one prisma transaction."""
|
||||
prisma = await _run_update_organization_v2(
|
||||
monkeypatch,
|
||||
body={"tpm_limit": 500, "metadata": {"a": 1}},
|
||||
existing_budget_id="budget-1",
|
||||
existing_metadata={},
|
||||
)
|
||||
|
||||
prisma.db.tx.assert_called_once()
|
||||
prisma.db.litellm_budgettable.update.assert_awaited_once()
|
||||
prisma.db.litellm_organizationtable.update.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_serializes_model_max_budget_on_budget_write(monkeypatch):
|
||||
"""model_max_budget is a Json column, so it is JSON-serialized on the budget-row write like new_budget/metadata."""
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints.validate_model_max_budget",
|
||||
lambda _: None,
|
||||
)
|
||||
|
||||
prisma = await _run_update_organization_v2(
|
||||
monkeypatch,
|
||||
body={"model_max_budget": {"gpt-4o": {"max_budget": 10}}},
|
||||
existing_budget_id="budget-1",
|
||||
existing_metadata={},
|
||||
)
|
||||
|
||||
written = prisma.db.litellm_budgettable.update.await_args.kwargs["data"]["model_max_budget"]
|
||||
assert isinstance(written, str)
|
||||
assert json.loads(written) == {"gpt-4o": {"max_budget": 10}}
|
||||
|
||||
|
||||
def test_build_budget_write_data_recomputes_reset_at_on_duration():
|
||||
"""A sent budget_duration recomputes budget_reset_at so the reset window follows the new duration."""
|
||||
from litellm.proxy.management_endpoints.organization_endpoints import build_budget_write_data
|
||||
|
||||
data = build_budget_write_data({"budget_duration": "30d"}, "admin-1")
|
||||
assert data["budget_duration"] == "30d"
|
||||
assert "budget_reset_at" in data
|
||||
assert data["updated_by"] == "admin-1"
|
||||
|
||||
|
||||
def test_build_budget_write_data_no_reset_at_without_duration():
|
||||
"""Clearing a limit writes it through untouched and does not recompute budget_reset_at."""
|
||||
from litellm.proxy.management_endpoints.organization_endpoints import build_budget_write_data
|
||||
|
||||
data = build_budget_write_data({"tpm_limit": None}, "admin-1")
|
||||
assert data["tpm_limit"] is None
|
||||
assert "budget_reset_at" not in data
|
||||
|
||||
|
||||
def test_build_budget_write_data_clears_reset_at_with_null_duration():
|
||||
"""Clearing budget_duration also nulls budget_reset_at so no stale reset timestamp survives."""
|
||||
from litellm.proxy.management_endpoints.organization_endpoints import build_budget_write_data
|
||||
|
||||
data = build_budget_write_data({"budget_duration": None}, "admin-1")
|
||||
assert data["budget_duration"] is None
|
||||
assert data["budget_reset_at"] is None
|
||||
|
|
|
|||
|
|
@ -1693,6 +1693,78 @@ async def test_update_team_members_list_duplicate_prevention():
|
|||
assert len(mock_team.members_with_roles) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_team_members_reconciles_against_freshly_locked_row():
|
||||
"""
|
||||
Regression: _add_team_members_to_team must build the new members_with_roles
|
||||
from the row it re-reads under a lock inside the write transaction, not from
|
||||
the stale complete_team_data snapshot captured at the start of the request.
|
||||
|
||||
Two concurrent /team/member_add calls for the same team read the same
|
||||
snapshot; without the locked re-read the losing write rewrites the whole
|
||||
JSON array from its stale copy and silently drops the member the other call
|
||||
already committed. Here the snapshot holds only "zed", a concurrent writer
|
||||
has already committed "alice" (returned by the locked SELECT), and this call
|
||||
adds "bob". The write must contain all three.
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.team_endpoints import (
|
||||
_add_team_members_to_team,
|
||||
)
|
||||
|
||||
stale_snapshot = LiteLLM_TeamTable(
|
||||
team_id="test-team-lock",
|
||||
members_with_roles=[Member(user_id="zed", role="user")],
|
||||
)
|
||||
|
||||
freshly_committed = [
|
||||
{"user_id": "zed", "user_email": None, "role": "user"},
|
||||
{"user_id": "alice", "user_email": None, "role": "user"},
|
||||
]
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def _capture_update(where, data):
|
||||
captured["data"] = data
|
||||
return LiteLLM_TeamTable(
|
||||
team_id="test-team-lock",
|
||||
members_with_roles=json.loads(data["members_with_roles"]),
|
||||
)
|
||||
|
||||
tx = MagicMock()
|
||||
tx.query_raw = AsyncMock(return_value=[{"members_with_roles": freshly_committed}])
|
||||
tx.litellm_teamtable.update = AsyncMock(side_effect=_capture_update)
|
||||
|
||||
tx_cm = MagicMock()
|
||||
tx_cm.__aenter__ = AsyncMock(return_value=tx)
|
||||
tx_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
prisma_client = MagicMock()
|
||||
prisma_client.tx = MagicMock(return_value=tx_cm)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.team_endpoints._process_team_members",
|
||||
new=AsyncMock(return_value=([], [])),
|
||||
):
|
||||
updated_team, _, _ = await _add_team_members_to_team(
|
||||
data=TeamMemberAddRequest(
|
||||
team_id="test-team-lock",
|
||||
member=Member(user_id="bob", role="user"),
|
||||
),
|
||||
complete_team_data=stale_snapshot,
|
||||
prisma_client=cast(object, prisma_client),
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
||||
litellm_proxy_admin_name="admin",
|
||||
)
|
||||
|
||||
written_ids = sorted(m["user_id"] for m in json.loads(captured["data"]["members_with_roles"]))
|
||||
assert written_ids == ["alice", "bob", "zed"]
|
||||
|
||||
lock_reads = [call for call in tx.query_raw.call_args_list if "FOR UPDATE" in str(call.args[0])]
|
||||
assert lock_reads, "expected a SELECT ... FOR UPDATE row-lock read before the write"
|
||||
|
||||
assert [m.user_id for m in updated_team.members_with_roles] == ["zed", "alice", "bob"]
|
||||
|
||||
|
||||
def test_add_new_models_to_team_with_existing_models():
|
||||
"""
|
||||
Test add_new_models_to_team function with existing models
|
||||
|
|
@ -9741,7 +9813,6 @@ async def _drive_team_write(
|
|||
raw_body=None,
|
||||
user=None,
|
||||
find_returns_none=False,
|
||||
json_side_effect=None,
|
||||
):
|
||||
"""Drive POST ``update_team`` or PATCH ``patch_team`` against a mocked team.
|
||||
|
||||
|
|
@ -9756,6 +9827,7 @@ async def _drive_team_write(
|
|||
from litellm.proxy._types import (
|
||||
LiteLLM_TeamTable,
|
||||
LitellmUserRoles,
|
||||
PatchTeamRequest,
|
||||
UpdateTeamRequest,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
|
|
@ -9801,14 +9873,10 @@ async def _drive_team_write(
|
|||
litellm_changed_by=None,
|
||||
)
|
||||
else:
|
||||
if json_side_effect is not None:
|
||||
req.json = AsyncMock(side_effect=json_side_effect)
|
||||
else:
|
||||
req.json = AsyncMock(
|
||||
return_value=raw_body if raw_body is not None else dict(payload or {})
|
||||
)
|
||||
body = raw_body if raw_body is not None else dict(payload or {})
|
||||
result = await patch_team(
|
||||
team_id=_PATCH_TEAM_ID,
|
||||
data=PatchTeamRequest.model_validate(body),
|
||||
http_request=req,
|
||||
user_api_key_dict=auth,
|
||||
litellm_changed_by=None,
|
||||
|
|
@ -9956,25 +10024,36 @@ async def test_patch_strips_system_managed_metadata_key_like_post():
|
|||
assert patch_meta == {"cost_center": "9999"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("raw_body", [["not", "an", "object"], "a-string", 42, True])
|
||||
async def test_patch_rejects_non_object_body(raw_body):
|
||||
from litellm.proxy._types import ProxyException
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"json": ["not", "an", "object"]},
|
||||
{"json": "a-string"},
|
||||
{"json": 42},
|
||||
{"content": b"{not json"},
|
||||
{"json": {"tpm_limit": "not-an-int"}},
|
||||
],
|
||||
ids=["list", "string", "number", "malformed-json", "wrong-field-type"],
|
||||
)
|
||||
def test_patch_rejects_a_malformed_body_with_422(kwargs):
|
||||
"""The body is a declared parameter, so FastAPI rejects a malformed one before the
|
||||
handler runs. This is the same 422 POST /team/update already returns; the route
|
||||
previously answered 400 here and 500 for a wrongly typed field, reporting a caller
|
||||
mistake as a server fault."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with pytest.raises(ProxyException) as exc:
|
||||
await _drive_team_write("patch", existing_metadata={"a": 1}, raw_body=raw_body)
|
||||
assert exc.value.code == "400" or exc.value.code == 400
|
||||
from litellm.proxy._types import PatchTeamRequest
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_rejects_invalid_json_body():
|
||||
from litellm.proxy._types import ProxyException
|
||||
@app.patch("/team/{team_id}")
|
||||
async def _route(team_id: str, data: PatchTeamRequest): # pragma: no cover - schema only
|
||||
return {}
|
||||
|
||||
with pytest.raises(ProxyException) as exc:
|
||||
await _drive_team_write(
|
||||
"patch", existing_metadata={"a": 1}, json_side_effect=ValueError("no body")
|
||||
)
|
||||
assert exc.value.code == "400" or exc.value.code == 400
|
||||
response = TestClient(app).patch("/team/abc", **kwargs)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -10044,3 +10123,103 @@ async def test_patch_returns_full_team_object_not_wrapper():
|
|||
)
|
||||
assert isinstance(result, LiteLLM_TeamTable)
|
||||
assert result.team_id == _PATCH_TEAM_ID
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH body is validated through PatchTeamRequest before it is handed to
|
||||
# update_team. The write below must stay byte-identical to what the untyped
|
||||
# **body construction produced, or a partial update starts writing columns the
|
||||
# caller never mentioned.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_writes_only_the_keys_the_caller_sent():
|
||||
"""An omitted field must not reach the DB write at all. If validation ever
|
||||
materialises defaults, every unmentioned column gets overwritten with null."""
|
||||
_, update_mock = await _drive_team_write("patch", raw_body={"tpm_limit": 5})
|
||||
written = update_mock.call_args.kwargs["data"]
|
||||
|
||||
assert written["tpm_limit"] == 5
|
||||
for untouched in ("rpm_limit", "max_budget", "models", "blocked", "budget_duration"):
|
||||
assert untouched not in written, f"{untouched} was written despite not being sent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_preserves_explicit_null_as_a_clear():
|
||||
"""null is a clear, not an omission: it has to survive validation and reach the write."""
|
||||
_, update_mock = await _drive_team_write("patch", raw_body={"max_budget": None})
|
||||
written = update_mock.call_args.kwargs["data"]
|
||||
|
||||
assert "max_budget" in written
|
||||
assert written["max_budget"] is None
|
||||
|
||||
|
||||
def _patch_body_to_update_request(body: dict):
|
||||
"""The exact reshaping patch_team performs between the raw body and update_team."""
|
||||
from litellm.proxy._types import PatchTeamRequest, UpdateTeamRequest
|
||||
|
||||
parsed = PatchTeamRequest.model_validate(body)
|
||||
return UpdateTeamRequest(
|
||||
team_id=_PATCH_TEAM_ID,
|
||||
**parsed.model_dump(exclude_unset=True, exclude={"team_id"}),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
{"tpm_limit": 5},
|
||||
{"max_budget": None},
|
||||
{"object_permission": {"vector_stores": []}},
|
||||
{"metadata": {"a": 1, "b": None}},
|
||||
{"models": ["gpt-4"], "blocked": False},
|
||||
],
|
||||
ids=["scalar", "explicit-null", "partial-nested", "metadata-with-null", "list-and-false"],
|
||||
)
|
||||
def test_patch_body_reshaping_adds_no_keys_the_caller_did_not_send(body):
|
||||
"""Validating through PatchTeamRequest must be shape-preserving. If it ever
|
||||
materialises defaults, a partial update silently overwrites untouched columns,
|
||||
and for the merge-only object_permission it would wipe sibling sub-keys."""
|
||||
reshaped = _patch_body_to_update_request(body)
|
||||
dumped = reshaped.model_dump(exclude_unset=True, exclude={"team_id"})
|
||||
|
||||
assert dumped == body
|
||||
assert reshaped.model_fields_set == set(body) | {"team_id"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_ignores_unknown_body_keys():
|
||||
"""Unknown keys were silently dropped by the previous construction; keep that."""
|
||||
_, update_mock = await _drive_team_write(
|
||||
"patch", raw_body={"tpm_limit": 5, "not_a_team_field": "x"}
|
||||
)
|
||||
written = update_mock.call_args.kwargs["data"]
|
||||
|
||||
assert written["tpm_limit"] == 5
|
||||
assert "not_a_team_field" not in written
|
||||
|
||||
|
||||
def test_patch_team_request_makes_team_id_optional():
|
||||
"""PATCH takes team_id from the path, so the body model must not require it,
|
||||
while still inheriting every UpdateTeamRequest field."""
|
||||
from litellm.proxy._types import PatchTeamRequest, UpdateTeamRequest
|
||||
|
||||
parsed = PatchTeamRequest.model_validate({"tpm_limit": 5})
|
||||
|
||||
assert parsed.team_id is None
|
||||
assert parsed.model_fields_set == {"tpm_limit"}
|
||||
assert set(UpdateTeamRequest.model_fields).issubset(set(PatchTeamRequest.model_fields))
|
||||
|
||||
|
||||
def test_patch_team_route_publishes_its_request_body_schema():
|
||||
"""The dashboard's generated client types this call off the OpenAPI spec, which
|
||||
FastAPI can only emit because the body is a declared parameter."""
|
||||
from litellm.proxy.proxy_server import app
|
||||
|
||||
operation = app.openapi()["paths"]["/team/{team_id}"]["patch"]
|
||||
schema = operation["requestBody"]["content"]["application/json"]["schema"]
|
||||
|
||||
assert schema == {"$ref": "#/components/schemas/PatchTeamRequest"}
|
||||
properties = app.openapi()["components"]["schemas"]["PatchTeamRequest"]["properties"]
|
||||
assert "tpm_limit" in properties and "metadata" in properties
|
||||
|
|
|
|||
|
|
@ -1174,6 +1174,113 @@ def test_get_config_returns_email_settings(monkeypatch):
|
|||
assert "*" in variables["SMTP_PASSWORD"]
|
||||
|
||||
|
||||
def _get_email_alert_variables(monkeypatch, config_data):
|
||||
from litellm.proxy.proxy_server import app, proxy_config, user_api_key_auth
|
||||
|
||||
mock_router = MagicMock()
|
||||
mock_router.get_settings.return_value = {}
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router)
|
||||
monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=config_data))
|
||||
|
||||
original_overrides = app.dependency_overrides.copy()
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1234"
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
try:
|
||||
response = client.get("/get/config/callbacks")
|
||||
finally:
|
||||
app.dependency_overrides = original_overrides
|
||||
|
||||
assert response.status_code == 200
|
||||
email_alert = next((a for a in response.json()["alerts"] if a["name"] == "email"), None)
|
||||
assert email_alert is not None
|
||||
return email_alert["variables"]
|
||||
|
||||
|
||||
def test_get_config_returns_email_settings_set_only_in_process_env(monkeypatch):
|
||||
"""
|
||||
Regression for LIT-4165.
|
||||
|
||||
SMTP supplied purely as process env vars (helm/terraform, no UI writes) is
|
||||
live at runtime because litellm/proxy/utils.py::send_email resolves every
|
||||
field from os.getenv. The /get/config/callbacks email block only read the
|
||||
config/DB environment_variables overlay though, so those deployments saw an
|
||||
empty Email Server Settings page and could not tell SMTP was configured.
|
||||
The slack block one branch above already fell back to os.getenv.
|
||||
"""
|
||||
smtp_password = "env-only-app-password"
|
||||
monkeypatch.setenv("SMTP_HOST", "smtp.env-host.com")
|
||||
monkeypatch.setenv("SMTP_PORT", "2525")
|
||||
monkeypatch.setenv("SMTP_TLS", "False")
|
||||
monkeypatch.setenv("SMTP_USERNAME", "env-user")
|
||||
monkeypatch.setenv("SMTP_PASSWORD", smtp_password)
|
||||
monkeypatch.setenv("SMTP_SENDER_EMAIL", "alerts@env-host.com")
|
||||
monkeypatch.setenv("TEST_EMAIL_ADDRESS", "admin@env-host.com")
|
||||
|
||||
variables = _get_email_alert_variables(
|
||||
monkeypatch,
|
||||
{
|
||||
"litellm_settings": {},
|
||||
"general_settings": {"alerting": ["email"]},
|
||||
"environment_variables": {},
|
||||
},
|
||||
)
|
||||
|
||||
# Every one of these was None before the fix, despite SMTP working.
|
||||
assert variables["SMTP_HOST"] == "smtp.env-host.com"
|
||||
assert variables["SMTP_PORT"] == "2525"
|
||||
assert variables["SMTP_TLS"] == "False"
|
||||
assert variables["SMTP_USERNAME"] == "env-user"
|
||||
assert variables["SMTP_SENDER_EMAIL"] == "alerts@env-host.com"
|
||||
assert variables["TEST_EMAIL_ADDRESS"] == "admin@env-host.com"
|
||||
|
||||
# An env-sourced secret is masked exactly like a stored one.
|
||||
assert variables["SMTP_PASSWORD"] not in (None, smtp_password)
|
||||
assert "*" in variables["SMTP_PASSWORD"]
|
||||
|
||||
|
||||
def test_get_config_email_settings_prefer_stored_over_process_env(monkeypatch):
|
||||
"""
|
||||
Stored environment_variables win over the process environment, matching the
|
||||
load order in ProxyConfig.get_config, which pushes stored values into
|
||||
os.environ. Only a field with no stored entry falls back to os.getenv.
|
||||
"""
|
||||
monkeypatch.setenv("SMTP_HOST", "smtp.env-host.com")
|
||||
monkeypatch.setenv("SMTP_SENDER_EMAIL", "alerts@env-host.com")
|
||||
|
||||
variables = _get_email_alert_variables(
|
||||
monkeypatch,
|
||||
{
|
||||
"litellm_settings": {},
|
||||
"general_settings": {"alerting": ["email"]},
|
||||
"environment_variables": {"SMTP_HOST": "smtp.stored-host.com"},
|
||||
},
|
||||
)
|
||||
|
||||
assert variables["SMTP_HOST"] == "smtp.stored-host.com"
|
||||
assert variables["SMTP_SENDER_EMAIL"] == "alerts@env-host.com"
|
||||
|
||||
|
||||
def test_get_config_email_settings_absent_everywhere_stay_none(monkeypatch):
|
||||
"""A field set in neither source is reported unset rather than invented."""
|
||||
for var in ("SMTP_HOST", "SMTP_PORT", "SMTP_TLS", "SMTP_USERNAME", "SMTP_PASSWORD", "SMTP_SENDER_EMAIL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
variables = _get_email_alert_variables(
|
||||
monkeypatch,
|
||||
{
|
||||
"litellm_settings": {},
|
||||
"general_settings": {"alerting": ["email"]},
|
||||
"environment_variables": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert variables["SMTP_HOST"] is None
|
||||
assert variables["SMTP_PASSWORD"] is None
|
||||
|
||||
|
||||
def test_get_config_returns_slack_webhook(monkeypatch):
|
||||
"""
|
||||
Same double-decryption regression as the email block (issue #19221): the
|
||||
|
|
|
|||
|
|
@ -709,6 +709,39 @@ def test_create_model_info_response_reads_real_cost_map():
|
|||
assert response["max_output_tokens"] > 0
|
||||
|
||||
|
||||
def test_create_model_info_response_includes_mode_from_lookup():
|
||||
response = create_model_info_response(
|
||||
model_id="text-embedding-3-small",
|
||||
provider="openai",
|
||||
llm_router=None,
|
||||
get_model_info=lambda _model: _fake_model_info(mode="embedding"),
|
||||
)
|
||||
|
||||
assert response["mode"] == "embedding"
|
||||
|
||||
|
||||
def test_create_model_info_response_omits_mode_when_lookup_raises():
|
||||
response = create_model_info_response(
|
||||
model_id="my-custom-deployment",
|
||||
provider="openai",
|
||||
llm_router=None,
|
||||
get_model_info=_raise_unmapped,
|
||||
)
|
||||
|
||||
assert "mode" not in response
|
||||
|
||||
|
||||
def test_create_model_info_response_omits_non_string_mode():
|
||||
response = create_model_info_response(
|
||||
model_id="some-model",
|
||||
provider="openai",
|
||||
llm_router=None,
|
||||
get_model_info=lambda _model: _fake_model_info(mode=None),
|
||||
)
|
||||
|
||||
assert "mode" not in response
|
||||
|
||||
|
||||
class TestPostCallFailureHookLLMExceptionAlerting:
|
||||
"""The llm_exceptions alert is for infra / LLM-API failures, not user
|
||||
errors (https://github.com/BerriAI/litellm/issues/3395). Already-normalized
|
||||
|
|
|
|||
|
|
@ -396,6 +396,146 @@ class TestProxySettingEndpoints:
|
|||
call_args = mock_prisma.db.litellm_ssoconfig.find_unique.call_args
|
||||
assert call_args.kwargs["where"]["id"] == "sso_config"
|
||||
|
||||
def _mock_sso_db_record(self, monkeypatch, sso_settings):
|
||||
"""Point /get/sso_settings at a stored SSO row (or None for no row)."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
if sso_settings is None:
|
||||
mock_db_record = None
|
||||
else:
|
||||
mock_db_record = MagicMock()
|
||||
mock_db_record.sso_settings = sso_settings
|
||||
mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=mock_db_record)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
|
||||
# The resolver decrypts stored values via decrypt_value_helper; make it an
|
||||
# identity so the plaintext fixtures round-trip.
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.config_resolvers.sso.decrypt_value_helper",
|
||||
lambda value, key, exception_type="error", return_original_value=False: value,
|
||||
)
|
||||
|
||||
def test_get_sso_settings_falls_back_to_process_env(
|
||||
self, mock_proxy_config, mock_auth, monkeypatch
|
||||
):
|
||||
"""
|
||||
Regression for LIT-4165.
|
||||
|
||||
SSO configured purely as process env vars (helm/terraform, no UI writes)
|
||||
logs users in successfully, because ui_sso.py resolves every setting from
|
||||
os.environ. /get/sso_settings read only the sso_config table though, so
|
||||
the Admin UI showed "not configured" for a working SSO deployment and hid
|
||||
the Edit/Delete controls behind an empty-state placeholder.
|
||||
"""
|
||||
self._mock_sso_db_record(monkeypatch, None)
|
||||
monkeypatch.setenv("GENERIC_CLIENT_ID", "env-client-id")
|
||||
monkeypatch.setenv("GENERIC_CLIENT_SECRET", "env-client-secret-value")
|
||||
monkeypatch.setenv("GENERIC_AUTHORIZATION_ENDPOINT", "https://idp.example.com/authorize")
|
||||
monkeypatch.setenv("GENERIC_TOKEN_ENDPOINT", "https://idp.example.com/token")
|
||||
monkeypatch.setenv("GENERIC_USERINFO_ENDPOINT", "https://idp.example.com/userinfo")
|
||||
monkeypatch.setenv("GENERIC_SCOPE", "openid email profile groups")
|
||||
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
|
||||
|
||||
response = client.get("/get/sso_settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
values = response.json()["values"]
|
||||
|
||||
# Every one of these was None before the fix, despite SSO working.
|
||||
assert values["generic_client_id"] == "env-client-id"
|
||||
assert values["generic_authorization_endpoint"] == "https://idp.example.com/authorize"
|
||||
assert values["generic_token_endpoint"] == "https://idp.example.com/token"
|
||||
assert values["generic_userinfo_endpoint"] == "https://idp.example.com/userinfo"
|
||||
assert values["generic_scope"] == "openid email profile groups"
|
||||
assert values["proxy_base_url"] == "https://gateway.example.com"
|
||||
|
||||
# An env-sourced secret is masked exactly like a stored one.
|
||||
assert values["generic_client_secret"] not in (None, "env-client-secret-value")
|
||||
assert "*" in values["generic_client_secret"]
|
||||
|
||||
def test_get_sso_settings_does_not_mutate_os_environ(
|
||||
self, mock_proxy_config, mock_auth, monkeypatch
|
||||
):
|
||||
"""A GET must not write os.environ. The legacy read path decrypted DB
|
||||
values straight into the environment, so opening the settings page
|
||||
repopulated env and masked any consumer that stopped reading it."""
|
||||
self._mock_sso_db_record(monkeypatch, {"generic_client_id": "db-only-id"})
|
||||
monkeypatch.delenv("GENERIC_CLIENT_ID", raising=False)
|
||||
|
||||
response = client.get("/get/sso_settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["values"]["generic_client_id"] == "db-only-id"
|
||||
# The DB value must NOT have leaked into the process environment.
|
||||
assert "GENERIC_CLIENT_ID" not in os.environ
|
||||
|
||||
def test_get_sso_settings_prefers_stored_over_process_env(
|
||||
self, mock_proxy_config, mock_auth, monkeypatch
|
||||
):
|
||||
"""A stored value wins; only fields absent from the row fall back to env."""
|
||||
self._mock_sso_db_record(monkeypatch, {"generic_client_id": "stored-client-id"})
|
||||
monkeypatch.setenv("GENERIC_CLIENT_ID", "env-client-id")
|
||||
monkeypatch.setenv("GENERIC_TOKEN_ENDPOINT", "https://idp.example.com/token")
|
||||
|
||||
response = client.get("/get/sso_settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
values = response.json()["values"]
|
||||
assert values["generic_client_id"] == "stored-client-id"
|
||||
assert values["generic_token_endpoint"] == "https://idp.example.com/token"
|
||||
|
||||
def test_get_sso_settings_blank_stored_value_falls_back_to_process_env(
|
||||
self, mock_proxy_config, mock_auth, monkeypatch
|
||||
):
|
||||
"""
|
||||
Blank means absent. update_sso_settings clears the env var for a blank
|
||||
field, so a blank row entry cannot describe a live setting; os.environ is
|
||||
the effective config and is what the UI must report.
|
||||
"""
|
||||
self._mock_sso_db_record(monkeypatch, {"generic_client_id": " ", "generic_token_endpoint": ""})
|
||||
monkeypatch.setenv("GENERIC_CLIENT_ID", "env-client-id")
|
||||
monkeypatch.setenv("GENERIC_TOKEN_ENDPOINT", "https://idp.example.com/token")
|
||||
|
||||
response = client.get("/get/sso_settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
values = response.json()["values"]
|
||||
assert values["generic_client_id"] == "env-client-id"
|
||||
assert values["generic_token_endpoint"] == "https://idp.example.com/token"
|
||||
|
||||
def test_get_sso_settings_unset_everywhere_reports_source(
|
||||
self, mock_proxy_config, mock_auth, monkeypatch
|
||||
):
|
||||
"""A field set in neither source is unset (or its effective default),
|
||||
and provenance reports which."""
|
||||
self._mock_sso_db_record(monkeypatch, None)
|
||||
for env_var in (
|
||||
"GENERIC_CLIENT_ID",
|
||||
"GENERIC_CLIENT_SECRET",
|
||||
"GENERIC_TOKEN_ENDPOINT",
|
||||
"GENERIC_SCOPE",
|
||||
"GOOGLE_CLIENT_ID",
|
||||
"MICROSOFT_CLIENT_ID",
|
||||
"PROXY_BASE_URL",
|
||||
):
|
||||
monkeypatch.delenv(env_var, raising=False)
|
||||
|
||||
response = client.get("/get/sso_settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
values = body["values"]
|
||||
provenance = body["provenance"]
|
||||
assert values["generic_client_id"] is None
|
||||
assert provenance["generic_client_id"] == "unset"
|
||||
assert values["generic_client_secret"] is None
|
||||
assert values["google_client_id"] is None
|
||||
# generic_scope carries the same effective default the login path applies,
|
||||
# so the settings page shows the scope logins would actually request.
|
||||
assert values["generic_scope"] == "openid email profile"
|
||||
assert provenance["generic_scope"] == "default"
|
||||
|
||||
def test_update_sso_settings(self, mock_proxy_config, mock_auth, monkeypatch):
|
||||
"""Test updating the SSO settings to the dedicated database table"""
|
||||
import json
|
||||
|
|
@ -1463,19 +1603,20 @@ class TestProxySettingEndpoints:
|
|||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
|
||||
# Mock the decryption method to return decrypted values
|
||||
def mock_decrypt_and_set(environment_variables):
|
||||
return {
|
||||
"google_client_id": "decrypted_google_id",
|
||||
"google_client_secret": "decrypted_google_secret",
|
||||
"microsoft_client_id": "decrypted_microsoft_id",
|
||||
"proxy_base_url": "https://decrypted.example.com",
|
||||
}
|
||||
# The resolver decrypts each stored value via decrypt_value_helper; map
|
||||
# the ciphertext fixtures to their plaintext.
|
||||
decrypted_by_ciphertext = {
|
||||
"encrypted_google_id": "decrypted_google_id",
|
||||
"encrypted_google_secret": "decrypted_google_secret",
|
||||
"encrypted_microsoft_id": "decrypted_microsoft_id",
|
||||
"encrypted_proxy_url": "https://decrypted.example.com",
|
||||
}
|
||||
|
||||
from litellm.proxy.proxy_server import proxy_config
|
||||
def mock_decrypt(value, key, exception_type="error", return_original_value=False):
|
||||
return decrypted_by_ciphertext.get(value, value)
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_config, "_decrypt_and_set_db_env_variables", mock_decrypt_and_set
|
||||
"litellm.proxy.config_resolvers.sso.decrypt_value_helper", mock_decrypt
|
||||
)
|
||||
|
||||
response = client.get("/get/sso_settings")
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Tests for gateway repository layer.
|
|||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -499,6 +499,42 @@ class TestTeamRepository:
|
|||
assert team.team_id == "team-123"
|
||||
assert team.team_alias == "Engineering"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"raw_value, expected_ids",
|
||||
[
|
||||
(
|
||||
[
|
||||
{"user_id": "a", "role": "user"},
|
||||
{"user_id": "b", "role": "admin"},
|
||||
],
|
||||
["a", "b"],
|
||||
),
|
||||
(json.dumps([{"user_id": "a", "role": "user"}]), ["a"]),
|
||||
({}, []),
|
||||
(None, []),
|
||||
],
|
||||
)
|
||||
async def test_get_members_with_roles_locked(self, repo, raw_value, expected_ids):
|
||||
tx = MagicMock()
|
||||
tx.query_raw = AsyncMock(return_value=[{"members_with_roles": raw_value}])
|
||||
|
||||
members = await repo.get_members_with_roles_locked(tx, "team-1")
|
||||
|
||||
assert [m.user_id for m in members] == expected_ids
|
||||
sql = tx.query_raw.call_args.args[0]
|
||||
assert "FOR UPDATE" in sql
|
||||
assert tx.query_raw.call_args.args[1] == "team-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_members_with_roles_locked_missing_row(self, repo):
|
||||
tx = MagicMock()
|
||||
tx.query_raw = AsyncMock(return_value=[])
|
||||
|
||||
members = await repo.get_members_with_roles_locked(tx, "missing")
|
||||
|
||||
assert members == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_team_all_fields(self, repo):
|
||||
team = await repo.create_team(
|
||||
|
|
|
|||
|
|
@ -14,13 +14,19 @@ Covers:
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import List
|
||||
from typing import List, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.anthropic_cache_control_hook import (
|
||||
AnthropicCacheControlHook,
|
||||
)
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
ResponseInputParam,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
|
|
@ -71,6 +77,56 @@ def _patch_responses_dispatch():
|
|||
]
|
||||
|
||||
|
||||
def _make_cache_control_case() -> tuple[
|
||||
ResponseInputParam,
|
||||
list[AllMessageValues],
|
||||
dict[str, object],
|
||||
]:
|
||||
system_message = cast(
|
||||
AllMessageValues,
|
||||
{"role": "system", "content": "Analyze the request"},
|
||||
)
|
||||
assistant_message = cast(
|
||||
AllMessageValues,
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "The code has a bug",
|
||||
"annotations": [],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
user_message = cast(
|
||||
AllMessageValues,
|
||||
{"role": "user", "content": "Check for security issues"},
|
||||
)
|
||||
reasoning_item = {
|
||||
"type": "reasoning",
|
||||
"id": "rs_1",
|
||||
"summary": [],
|
||||
"encrypted_content": "encrypted",
|
||||
}
|
||||
original_input = cast(
|
||||
ResponseInputParam,
|
||||
[system_message, reasoning_item, assistant_message, user_message],
|
||||
)
|
||||
_, merged_messages, _ = AnthropicCacheControlHook().get_chat_completion_prompt(
|
||||
model="azure/gpt-5-codex",
|
||||
messages=[system_message, assistant_message, user_message],
|
||||
non_default_params={"cache_control_injection_points": [{"location": "message", "role": "system"}]},
|
||||
prompt_id=None,
|
||||
prompt_variables=None,
|
||||
dynamic_callback_params={},
|
||||
)
|
||||
return original_input, merged_messages, reasoning_item
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -256,6 +312,66 @@ class TestResponsesAPIPromptManagement:
|
|||
assert all(isinstance(m, dict) and "role" in m for m in passed_messages)
|
||||
assert len(passed_messages) == 1
|
||||
|
||||
def test_cache_control_hook_preserves_reasoning_items(self):
|
||||
original_input, merged_messages, reasoning_item = _make_cache_control_case()
|
||||
logging_obj = _make_logging_obj(
|
||||
merged_model="azure/gpt-5-codex",
|
||||
merged_messages=merged_messages,
|
||||
)
|
||||
|
||||
patches = _patch_responses_dispatch()
|
||||
with patches[0], patches[1], patches[2], patches[3] as mock_handler:
|
||||
import litellm
|
||||
|
||||
litellm.responses(
|
||||
input=original_input,
|
||||
model="azure/gpt-5-codex",
|
||||
litellm_logging_obj=logging_obj,
|
||||
cache_control_injection_points=[{"location": "message", "role": "system"}],
|
||||
)
|
||||
|
||||
sent_input = mock_handler.call_args.kwargs["input"]
|
||||
assert [item.get("type") for item in sent_input] == [
|
||||
None,
|
||||
"reasoning",
|
||||
"message",
|
||||
None,
|
||||
]
|
||||
assert sent_input[0]["cache_control"] == {"type": "ephemeral"}
|
||||
assert sent_input[1] == reasoning_item
|
||||
assert sent_input[2]["id"] == "msg_1"
|
||||
|
||||
def test_all_non_message_input_items_remain_unchanged(self):
|
||||
reasoning_item = {
|
||||
"type": "reasoning",
|
||||
"id": "rs_1",
|
||||
"summary": [],
|
||||
"encrypted_content": "encrypted",
|
||||
}
|
||||
original_input = cast(ResponseInputParam, [reasoning_item])
|
||||
logging_obj = _make_logging_obj(
|
||||
merged_model="openai/gpt-4o",
|
||||
merged_messages=[
|
||||
cast(
|
||||
AllMessageValues,
|
||||
{"role": "system", "content": "Analyze the request"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
patches = _patch_responses_dispatch()
|
||||
with patches[0], patches[1], patches[2], patches[3] as mock_handler:
|
||||
import litellm
|
||||
|
||||
litellm.responses(
|
||||
input=original_input,
|
||||
model="gpt-4o",
|
||||
prompt_id="all-non-message",
|
||||
litellm_logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
assert mock_handler.call_args.kwargs["input"] == original_input
|
||||
|
||||
def test_model_override_re_resolves_provider(self):
|
||||
"""[G] When the prompt template overrides the model to a different provider,
|
||||
custom_llm_provider is re-resolved so downstream routing uses the correct provider.
|
||||
|
|
@ -393,3 +509,33 @@ class TestAsyncResponsesAPIPromptManagement:
|
|||
passed_messages = call_kwargs["messages"]
|
||||
assert all(isinstance(m, dict) and "role" in m for m in passed_messages)
|
||||
assert len(passed_messages) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_cache_control_hook_preserves_reasoning_items(self):
|
||||
original_input, merged_messages, reasoning_item = _make_cache_control_case()
|
||||
logging_obj = _make_logging_obj(
|
||||
merged_model="azure/gpt-5-codex",
|
||||
merged_messages=merged_messages,
|
||||
)
|
||||
|
||||
patches = _patch_responses_dispatch()
|
||||
with patches[0], patches[1], patches[2], patches[3] as mock_handler:
|
||||
import litellm
|
||||
|
||||
await litellm.aresponses(
|
||||
input=original_input,
|
||||
model="azure/gpt-5-codex",
|
||||
litellm_logging_obj=logging_obj,
|
||||
cache_control_injection_points=[{"location": "message", "role": "system"}],
|
||||
)
|
||||
|
||||
sent_input = mock_handler.call_args.kwargs["input"]
|
||||
assert [item.get("type") for item in sent_input] == [
|
||||
None,
|
||||
"reasoning",
|
||||
"message",
|
||||
None,
|
||||
]
|
||||
assert sent_input[0]["cache_control"] == {"type": "ephemeral"}
|
||||
assert sent_input[1] == reasoning_item
|
||||
assert sent_input[2]["id"] == "msg_1"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -19,12 +19,13 @@ const eslintConfig = [
|
|||
"unused-imports/no-unused-imports": "error",
|
||||
"local/no-large-inline-object-arg": "warn",
|
||||
"local/no-long-condition-chain": "warn",
|
||||
"local/no-complex-jsx-arrow": ["error", { maxStatements: 2 }],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-expressions": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"prefer-const": "off",
|
||||
"prefer-const": "error",
|
||||
"no-empty": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"no-useless-catch": "off",
|
||||
|
|
@ -51,13 +52,32 @@ const eslintConfig = [
|
|||
patterns: [
|
||||
{
|
||||
group: ["@tremor/react", "@tremor/react/*"],
|
||||
message: "@tremor/react is being phased out; build new UI with antd instead of adding tremor imports.",
|
||||
message:
|
||||
"@tremor/react is being phased out; build new UI with shadcn/ui primitives instead of adding tremor imports.",
|
||||
},
|
||||
{
|
||||
group: ["antd", "antd/*"],
|
||||
message:
|
||||
"antd is being phased out; build new UI with shadcn/ui primitives instead of adding antd imports.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.tsx"],
|
||||
rules: {
|
||||
"local/filename-pascal-case": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
ignores: ["src/**/*.test.{ts,tsx}", "src/**/*.spec.{ts,tsx}", "src/data/**"],
|
||||
rules: {
|
||||
"max-lines": ["error", { max: 800, skipBlankLines: true, skipComments: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/lib/http/**"],
|
||||
rules: {
|
||||
|
|
|
|||
162
ui/litellm-dashboard/package-lock.json
generated
162
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -27,7 +27,7 @@
|
|||
"jwt-decode": "4.0.0",
|
||||
"lucide-react": "0.513.0",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.2.6",
|
||||
"next": "16.2.11",
|
||||
"openai": "4.104.0",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"openapi-react-query": "^0.5.4",
|
||||
|
|
@ -61,7 +61,7 @@
|
|||
"@vitest/coverage-v8": "3.2.6",
|
||||
"@vitest/ui": "3.2.6",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"eslint-config-next": "16.2.11",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-unused-imports": "4.3.0",
|
||||
"jsdom": "27.4.0",
|
||||
|
|
@ -2244,15 +2244,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
|
||||
"integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.11.tgz",
|
||||
"integrity": "sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.6.tgz",
|
||||
"integrity": "sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.11.tgz",
|
||||
"integrity": "sha512-vMEf/aXOpzFFdtIvFYOnIDPKb0xBbrXONsz83CcKdRrekfxNdL8PNkq5qHqAHSXVlIifnX68LOMaxr3z5PkeLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2260,9 +2260,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
|
||||
"integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.11.tgz",
|
||||
"integrity": "sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2276,9 +2276,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
|
||||
"integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.11.tgz",
|
||||
"integrity": "sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2292,9 +2292,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
|
||||
"integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.11.tgz",
|
||||
"integrity": "sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2308,9 +2308,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
|
||||
"integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.11.tgz",
|
||||
"integrity": "sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2324,9 +2324,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
|
||||
"integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.11.tgz",
|
||||
"integrity": "sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2340,9 +2340,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
|
||||
"integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.11.tgz",
|
||||
"integrity": "sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2356,9 +2356,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
|
||||
"integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.11.tgz",
|
||||
"integrity": "sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2372,9 +2372,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
|
||||
"integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.11.tgz",
|
||||
"integrity": "sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -3620,6 +3620,72 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
|
||||
|
|
@ -6642,13 +6708,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.6.tgz",
|
||||
"integrity": "sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.11.tgz",
|
||||
"integrity": "sha512-FIpbK/dUyxUExchDB7eBg3k+VU8R2iR/Cx9/kqTBUTFv2bOIR9aRrpno4rvAQ9VhiPQAyFKNA2NlZwouGWtclA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "16.2.6",
|
||||
"@next/eslint-plugin-next": "16.2.11",
|
||||
"eslint-import-resolver-node": "^0.3.6",
|
||||
"eslint-import-resolver-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
|
|
@ -10309,12 +10375,12 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
|
||||
"integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.11.tgz",
|
||||
"integrity": "sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.2.6",
|
||||
"@next/env": "16.2.11",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
|
|
@ -10328,14 +10394,14 @@
|
|||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.2.6",
|
||||
"@next/swc-darwin-x64": "16.2.6",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.6",
|
||||
"@next/swc-linux-arm64-musl": "16.2.6",
|
||||
"@next/swc-linux-x64-gnu": "16.2.6",
|
||||
"@next/swc-linux-x64-musl": "16.2.6",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.6",
|
||||
"@next/swc-win32-x64-msvc": "16.2.6",
|
||||
"@next/swc-darwin-arm64": "16.2.11",
|
||||
"@next/swc-darwin-x64": "16.2.11",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.11",
|
||||
"@next/swc-linux-arm64-musl": "16.2.11",
|
||||
"@next/swc-linux-x64-gnu": "16.2.11",
|
||||
"@next/swc-linux-x64-musl": "16.2.11",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.11",
|
||||
"@next/swc-win32-x64-msvc": "16.2.11",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
"jwt-decode": "4.0.0",
|
||||
"lucide-react": "0.513.0",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.2.6",
|
||||
"next": "16.2.11",
|
||||
"openai": "4.104.0",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"openapi-react-query": "^0.5.4",
|
||||
|
|
@ -73,7 +73,7 @@
|
|||
"@vitest/coverage-v8": "3.2.6",
|
||||
"@vitest/ui": "3.2.6",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"eslint-config-next": "16.2.11",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-unused-imports": "4.3.0",
|
||||
"jsdom": "27.4.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { basename } from "path";
|
||||
|
||||
const NEXT_RESERVED = new Set([
|
||||
"page",
|
||||
"layout",
|
||||
"route",
|
||||
"template",
|
||||
"default",
|
||||
"loading",
|
||||
"error",
|
||||
"global-error",
|
||||
"not-found",
|
||||
"middleware",
|
||||
"instrumentation",
|
||||
"sitemap",
|
||||
"robots",
|
||||
"manifest",
|
||||
"icon",
|
||||
"apple-icon",
|
||||
"favicon",
|
||||
"opengraph-image",
|
||||
"twitter-image",
|
||||
]);
|
||||
|
||||
const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/;
|
||||
|
||||
const rule = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
description: "Require PascalCase filenames for .tsx modules; exempt Next.js reserved files and test/spec files.",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
notPascalCase: "Filename '{{name}}' should be PascalCase (e.g. '{{suggestion}}.tsx').",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.filename;
|
||||
const stem = basename(filename).replace(/\.tsx$/, "");
|
||||
const [head, ...rest] = stem.split(".");
|
||||
if (rest.includes("test") || rest.includes("spec")) return {};
|
||||
if (NEXT_RESERVED.has(head)) return {};
|
||||
if (PASCAL_CASE.test(head)) return {};
|
||||
const pascalHead = head
|
||||
.split(/[-_]/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
const suggestion = [pascalHead, ...rest].join(".");
|
||||
return {
|
||||
Program(node) {
|
||||
context.report({ node, messageId: "notPascalCase", data: { name: `${stem}.tsx`, suggestion } });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default rule;
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
import noLargeInlineObjectArg from "./no-large-inline-object-arg.mjs";
|
||||
import noLongConditionChain from "./no-long-condition-chain.mjs";
|
||||
import noComplexJsxArrow from "./no-complex-jsx-arrow.mjs";
|
||||
import filenamePascalCase from "./filename-pascal-case.mjs";
|
||||
|
||||
const plugin = {
|
||||
rules: {
|
||||
"no-large-inline-object-arg": noLargeInlineObjectArg,
|
||||
"no-long-condition-chain": noLongConditionChain,
|
||||
"no-complex-jsx-arrow": noComplexJsxArrow,
|
||||
"filename-pascal-case": filenamePascalCase,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
const DEFAULT_MAX_STATEMENTS = 2;
|
||||
|
||||
const isJsxAttributeValue = (node) => {
|
||||
const parent = node.parent;
|
||||
if (parent == null) return false;
|
||||
return parent.type === "JSXExpressionContainer" && parent.parent?.type === "JSXAttribute";
|
||||
};
|
||||
|
||||
const rule = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow arrow functions with block bodies over a few statements passed inline as JSX attributes; extract them into a named handler.",
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { maxStatements: { type: "integer", minimum: 1 } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
tooComplex: "Inline JSX arrow handler has {{count}} statements; extract it into a named function (max {{max}}).",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const maxStatements = context.options[0]?.maxStatements ?? DEFAULT_MAX_STATEMENTS;
|
||||
return {
|
||||
ArrowFunctionExpression(node) {
|
||||
if (node.body.type !== "BlockStatement") return;
|
||||
if (!isJsxAttributeValue(node)) return;
|
||||
const count = node.body.body.length;
|
||||
if (count <= maxStatements) return;
|
||||
context.report({ node, messageId: "tooComplex", data: { count, max: maxStatements } });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default rule;
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { render } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import APIReferenceView from "./APIReferenceView";
|
||||
|
||||
|
|
@ -44,4 +45,48 @@ describe("APIReferenceView", () => {
|
|||
expect(renderedCode).toContain(apiDocUrl);
|
||||
expect(renderedCode).not.toContain(proxyUrl);
|
||||
});
|
||||
|
||||
it("renders the page title, blurb and docs link", () => {
|
||||
render(<APIReferenceView proxySettings={{ PROXY_BASE_URL: "https://proxy.litellm.test" }} />);
|
||||
|
||||
expect(screen.getByText("OpenAI Compatible Proxy: API Reference")).toBeTruthy();
|
||||
expect(screen.getByText(/LiteLLM is OpenAI Compatible/)).toBeTruthy();
|
||||
|
||||
const docsLink = screen.getByRole("link", { name: /API Reference Docs/ });
|
||||
expect(docsLink.getAttribute("href")).toBe("https://docs.litellm.ai/docs/proxy/user_keys");
|
||||
expect(docsLink.getAttribute("target")).toBe("_blank");
|
||||
});
|
||||
|
||||
it("exposes the three SDK tabs with the first selected by default", () => {
|
||||
render(<APIReferenceView proxySettings={{ PROXY_BASE_URL: "https://proxy.litellm.test" }} />);
|
||||
|
||||
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([
|
||||
"OpenAI Python SDK",
|
||||
"LlamaIndex",
|
||||
"Langchain Py",
|
||||
]);
|
||||
expect(screen.getAllByRole("tab").map((tab) => tab.getAttribute("aria-selected"))).toEqual([
|
||||
"true",
|
||||
"false",
|
||||
"false",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["OpenAI Python SDK", "import openai"],
|
||||
["LlamaIndex", "from llama_index.llms import AzureOpenAI"],
|
||||
["Langchain Py", "from langchain.chat_models import ChatOpenAI"],
|
||||
])("selecting %s shows its snippet wired to the base url", async (tabName, marker) => {
|
||||
const proxyUrl = "https://proxy.litellm.test";
|
||||
const user = userEvent.setup();
|
||||
render(<APIReferenceView proxySettings={{ PROXY_BASE_URL: proxyUrl }} />);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: tabName }));
|
||||
|
||||
expect(screen.getByRole("tab", { name: tabName }).getAttribute("aria-selected")).toBe("true");
|
||||
|
||||
const selectedPanel = screen.getByRole("tabpanel");
|
||||
expect(selectedPanel.textContent).toContain(marker);
|
||||
expect(selectedPanel.textContent).toContain(proxyUrl);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import { Text, Tab, TabGroup, TabList, TabPanel, TabPanels, Grid } from "@tremor/react";
|
||||
import CodeBlock from "@/components/CodeBlock";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import DocLink from "./DocLink";
|
||||
|
||||
interface ApiRefProps {
|
||||
|
|
@ -21,33 +21,35 @@ const APIReferenceView: React.FC<ApiRefProps> = ({ proxySettings }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid className="gap-2 p-8 h-[80vh] w-full mt-2">
|
||||
<div className="mb-5">
|
||||
{/* Header row with Docs link on the right */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-2xl text-tremor-content-strong dark:text-dark-tremor-content-strong font-semibold">
|
||||
OpenAI Compatible Proxy: API Reference
|
||||
</p>
|
||||
<DocLink className="ml-3 shrink-0" href="https://docs.litellm.ai/docs/proxy/user_keys" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 p-8 h-[80vh] w-full mt-2">
|
||||
<div className="mb-5">
|
||||
{/* Header row with Docs link on the right */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-foreground">OpenAI Compatible Proxy: API Reference</h1>
|
||||
<DocLink className="ml-3 shrink-0" href="https://docs.litellm.ai/docs/proxy/user_keys" />
|
||||
</div>
|
||||
|
||||
<Text className="mt-2 mb-2">
|
||||
LiteLLM is OpenAI Compatible. This means your API Key works with the OpenAI SDK. Just replace the base_url
|
||||
to point to your litellm proxy. Example Below{" "}
|
||||
</Text>
|
||||
<p className="mt-2 mb-2 text-sm text-muted-foreground">
|
||||
LiteLLM is OpenAI Compatible. This means your API Key works with the OpenAI SDK. Just replace the base_url to
|
||||
point to your litellm proxy. Example Below{" "}
|
||||
</p>
|
||||
|
||||
<TabGroup>
|
||||
<TabList>
|
||||
<Tab>OpenAI Python SDK</Tab>
|
||||
<Tab>LlamaIndex</Tab>
|
||||
<Tab>Langchain Py</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<CodeBlock
|
||||
language="python"
|
||||
code={`import openai
|
||||
<Tabs defaultValue="openai">
|
||||
<TabsList variant="line" className="border-b rounded-none w-full justify-start h-auto p-0">
|
||||
<TabsTrigger value="openai" className="rounded-none px-4 py-2 flex-none">
|
||||
OpenAI Python SDK
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="llamaindex" className="rounded-none px-4 py-2 flex-none">
|
||||
LlamaIndex
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="langchain" className="rounded-none px-4 py-2 flex-none">
|
||||
Langchain Py
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="openai">
|
||||
<CodeBlock
|
||||
language="python"
|
||||
code={`import openai
|
||||
client = openai.OpenAI(
|
||||
api_key="your_api_key",
|
||||
base_url="${base_url}" # LiteLLM Proxy is OpenAI compatible, Read More: https://docs.litellm.ai/docs/proxy/user_keys
|
||||
|
|
@ -64,13 +66,13 @@ response = client.chat.completions.create(
|
|||
)
|
||||
|
||||
print(response)`}
|
||||
/>
|
||||
</TabPanel>
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabPanel>
|
||||
<CodeBlock
|
||||
language="python"
|
||||
code={`import os, dotenv
|
||||
<TabsContent value="llamaindex">
|
||||
<CodeBlock
|
||||
language="python"
|
||||
code={`import os, dotenv
|
||||
|
||||
from llama_index.llms import AzureOpenAI
|
||||
from llama_index.embeddings import AzureOpenAIEmbedding
|
||||
|
|
@ -98,13 +100,13 @@ index = VectorStoreIndex.from_documents(documents, service_context=service_conte
|
|||
query_engine = index.as_query_engine()
|
||||
response = query_engine.query("What did the author do growing up?")
|
||||
print(response)`}
|
||||
/>
|
||||
</TabPanel>
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabPanel>
|
||||
<CodeBlock
|
||||
language="python"
|
||||
code={`from langchain.chat_models import ChatOpenAI
|
||||
<TabsContent value="langchain">
|
||||
<CodeBlock
|
||||
language="python"
|
||||
code={`from langchain.chat_models import ChatOpenAI
|
||||
from langchain.prompts.chat import (
|
||||
ChatPromptTemplate,
|
||||
HumanMessagePromptTemplate,
|
||||
|
|
@ -129,13 +131,11 @@ messages = [
|
|||
response = chat(messages)
|
||||
|
||||
print(response)`}
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</div>
|
||||
</Grid>
|
||||
</>
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export interface SSOSettingsValues {
|
|||
generic_authorization_endpoint: string | null;
|
||||
generic_token_endpoint: string | null;
|
||||
generic_userinfo_endpoint: string | null;
|
||||
generic_scope: string | null;
|
||||
proxy_base_url: string | null;
|
||||
user_email: string | null;
|
||||
ui_access_mode: string | null;
|
||||
|
|
|
|||
|
|
@ -211,6 +211,7 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
auth_type={mcpServer.auth_type}
|
||||
oauth2_flow={mcpServer.oauth2_flow}
|
||||
delegate_auth_to_upstream={mcpServer.delegate_auth_to_upstream}
|
||||
dcr_bridge={mcpServer.dcr_bridge}
|
||||
tokenUrl={mcpServer.token_url}
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,12 @@ vi.mock("@/utils/mcpTokenStore", () => ({
|
|||
removeToken: vi.fn(),
|
||||
}));
|
||||
|
||||
const { toolsOAuthFlowSpy } = vi.hoisted(() => ({
|
||||
toolsOAuthFlowSpy: vi.fn(() => ({ startOAuthFlow: vi.fn(), status: "idle", error: null })),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useToolsOAuthFlow", () => ({
|
||||
useToolsOAuthFlow: () => ({ startOAuthFlow: vi.fn(), status: "idle", error: null }),
|
||||
useToolsOAuthFlow: toolsOAuthFlowSpy,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useUserMcpOAuthFlow", () => ({
|
||||
|
|
@ -54,6 +58,27 @@ const credStatus = (overrides: Record<string, unknown> = {}) => ({
|
|||
...overrides,
|
||||
});
|
||||
|
||||
describe("MCPToolsViewer gatewayMintsClient wiring", () => {
|
||||
// Pins the call site (not just the helper): the viewer must pass the bridge-AWARE
|
||||
// gatewayMintsClientFor value to useToolsOAuthFlow, so the browser skips its own register exactly
|
||||
// when the gateway mints. The oauth_delegate + dcr_bridge cell is the regression guard: with the
|
||||
// old bridge-blind predicate it would have passed true here and dead-ended.
|
||||
beforeEach(() => toolsOAuthFlowSpy.mockClear());
|
||||
|
||||
it.each([
|
||||
{ auth_type: "true_passthrough", dcr_bridge: true, gatewayMintsClient: true },
|
||||
{ auth_type: "true_passthrough", dcr_bridge: false, gatewayMintsClient: true },
|
||||
{ auth_type: "oauth_delegate", dcr_bridge: false, gatewayMintsClient: true },
|
||||
{ auth_type: "oauth_delegate", dcr_bridge: true, gatewayMintsClient: false },
|
||||
])(
|
||||
"passes gatewayMintsClient=$gatewayMintsClient for $auth_type dcr_bridge=$dcr_bridge",
|
||||
({ auth_type, dcr_bridge, gatewayMintsClient }) => {
|
||||
renderViewer({ auth_type, dcr_bridge, tokenUrl: null });
|
||||
expect(toolsOAuthFlowSpy).toHaveBeenCalledWith(expect.objectContaining({ gatewayMintsClient }));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("MCPToolsViewer auth gate routing", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listMCPTools).mockReset().mockResolvedValue({ tools: [], error: null });
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ToolTestPanel } from "./ToolTestPanel";
|
|||
import { resolveLogoSrc } from "@/lib/assetPaths";
|
||||
import {
|
||||
isClientForwardedTokenMode,
|
||||
gatewayMintsClientFor,
|
||||
MCPTool,
|
||||
MCPToolsViewerProps,
|
||||
MCPContent,
|
||||
|
|
@ -28,6 +29,7 @@ const MCPToolsViewer = ({
|
|||
auth_type,
|
||||
oauth2_flow,
|
||||
delegate_auth_to_upstream,
|
||||
dcr_bridge,
|
||||
userRole,
|
||||
userID,
|
||||
serverAlias,
|
||||
|
|
@ -76,6 +78,7 @@ const MCPToolsViewer = ({
|
|||
serverId,
|
||||
serverAlias,
|
||||
userId: userID,
|
||||
gatewayMintsClient: gatewayMintsClientFor({ auth_type, dcr_bridge }),
|
||||
onSuccess: setOauthToken,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,212 +0,0 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, fireEvent, render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelsAndEndpointsView from "./ModelsAndEndpointsView";
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: (key: string) => store[key] || null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = value;
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key];
|
||||
},
|
||||
clear: () => {
|
||||
store = {};
|
||||
},
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(window, "localStorage", { value: localStorageMock });
|
||||
|
||||
// Minimal stubs to avoid Next.js router and network usage during render
|
||||
vi.mock("@/components/networking", () => ({
|
||||
credentialListCall: vi.fn().mockResolvedValue({ credentials: [] }),
|
||||
modelInfoCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
modelCostMap: vi.fn().mockResolvedValue({}),
|
||||
getPassThroughEndpointsCall: vi.fn().mockResolvedValue({ endpoints: {} }),
|
||||
getCallbacksCall: vi.fn().mockResolvedValue({ router_settings: {} }),
|
||||
setCallbacksCall: vi.fn().mockResolvedValue(undefined),
|
||||
getUiSettings: vi.fn().mockResolvedValue({ values: {} }),
|
||||
latestHealthChecksCall: vi.fn().mockResolvedValue({ latest_health_checks: {} }),
|
||||
getModelCostMapReloadStatus: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/models-and-endpoints/components/ModelAnalyticsTab/ModelAnalyticsTab", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/add_model/add_auto_router_tab", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/add_model/AddModelForm", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
const mockHealthCheckComponent = vi.fn((_props: { all_models_on_proxy?: string[] }) => null);
|
||||
vi.mock("@/components/model_dashboard/HealthCheckComponent", () => ({
|
||||
default: (props: { all_models_on_proxy?: string[] }) => {
|
||||
mockHealthCheckComponent(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({
|
||||
default: () => ({
|
||||
teams: [],
|
||||
setTeams: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUseModelsInfo = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
|
||||
useModelsInfo: () => mockUseModelsInfo(),
|
||||
}));
|
||||
|
||||
const mockUseUISettings = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
|
||||
useUISettings: () => mockUseUISettings(),
|
||||
}));
|
||||
|
||||
const mockUseModelCostMap = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({
|
||||
useModelCostMap: () => mockUseModelCostMap(),
|
||||
}));
|
||||
|
||||
const mockUseAuthorized = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => mockUseAuthorized(),
|
||||
}));
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
|
||||
describe("ModelsAndEndpointsView", () => {
|
||||
beforeEach(() => {
|
||||
mockUseModelsInfo.mockReturnValue({
|
||||
data: { data: [] },
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
mockUseUISettings.mockReturnValue({
|
||||
data: { values: {} },
|
||||
});
|
||||
mockUseModelCostMap.mockReturnValue({
|
||||
data: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
accessToken: "123",
|
||||
token: "123",
|
||||
userRole: "Admin",
|
||||
userId: "123",
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(global as any).ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
it("should render the models and endpoints view", async () => {
|
||||
const queryClient = createQueryClient();
|
||||
const { findByText } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ModelsAndEndpointsView premiumUser={false} teams={[]} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(await findByText("Model Management", {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Cost Optimization feedback banner by default", async () => {
|
||||
localStorageMock.clear();
|
||||
const queryClient = createQueryClient();
|
||||
const { findByText } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ModelsAndEndpointsView premiumUser={false} teams={[]} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(await findByText("Help shape cost optimization", {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide Cost Optimization feedback banner when dismiss button is clicked and persist to localStorage", async () => {
|
||||
localStorageMock.clear();
|
||||
const queryClient = createQueryClient();
|
||||
const { findByText, queryByText, container } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ModelsAndEndpointsView premiumUser={false} teams={[]} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Wait for banner to appear
|
||||
expect(await findByText("Help shape cost optimization", {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
|
||||
// Find and click dismiss button (X button)
|
||||
const dismissButton = container.querySelector('button[aria-label="Dismiss banner"]');
|
||||
expect(dismissButton).not.toBeNull();
|
||||
fireEvent.click(dismissButton!);
|
||||
|
||||
// Banner should be hidden
|
||||
expect(queryByText("Help shape cost optimization")).not.toBeInTheDocument();
|
||||
|
||||
// LocalStorage should be updated
|
||||
expect(localStorageMock.getItem("hideCostOptimizationFeedbackBanner")).toBe("true");
|
||||
});
|
||||
|
||||
it("should keep Cost Optimization feedback banner hidden across remounts once dismissed", async () => {
|
||||
// Set localStorage to hide banner
|
||||
localStorageMock.setItem("hideCostOptimizationFeedbackBanner", "true");
|
||||
const queryClient = createQueryClient();
|
||||
const { findByText, queryByText } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ModelsAndEndpointsView premiumUser={false} teams={[]} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Wait for component to render
|
||||
await findByText("Model Management", {}, { timeout: 10000 });
|
||||
|
||||
// Banner should not be visible
|
||||
expect(queryByText("Help shape cost optimization")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should pass model IDs (not model names) to HealthCheckComponent as all_models_on_proxy", async () => {
|
||||
mockHealthCheckComponent.mockClear();
|
||||
const modelDataWithIds = {
|
||||
data: [
|
||||
{ model_name: "gpt-4", model_info: { id: "deployment-id-1" } },
|
||||
{ model_name: "gpt-4", model_info: { id: "deployment-id-2" } },
|
||||
],
|
||||
};
|
||||
mockUseModelsInfo.mockReturnValue({
|
||||
data: { data: modelDataWithIds.data },
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
const { getByRole } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ModelsAndEndpointsView premiumUser={false} teams={[]} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const healthStatusTab = getByRole("tab", { name: "Health Status" });
|
||||
await act(async () => {
|
||||
healthStatusTab.click();
|
||||
});
|
||||
|
||||
expect(mockHealthCheckComponent).toHaveBeenCalled();
|
||||
const healthCheckProps = mockHealthCheckComponent.mock.calls[0][0];
|
||||
expect(healthCheckProps.all_models_on_proxy).toEqual(["deployment-id-1", "deployment-id-2"]);
|
||||
expect(healthCheckProps.all_models_on_proxy).not.toContain("gpt-4");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,487 +0,0 @@
|
|||
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
|
||||
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
|
||||
import { useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { useUpdateRetryPolicy } from "@/app/(dashboard)/hooks/routerSettings/useUpdateRetryPolicy";
|
||||
import AllModelsTab from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTab";
|
||||
import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner";
|
||||
import ModelRetrySettingsTab from "@/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab";
|
||||
import PriceDataManagementTab from "@/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab";
|
||||
import { handleAddModelSubmit } from "@/components/add_model/handle_add_model_submit";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import CredentialsPanel from "@/components/model_add/CredentialsPanel";
|
||||
import { getCallbacksCall } from "@/components/networking";
|
||||
import { Providers, getPlaceholder, getProviderModels } from "@/components/provider_info_helpers";
|
||||
import { getDisplayModelName } from "@/components/view_model/model_name_display";
|
||||
import { transformModelData } from "./utils/modelDataTransformer";
|
||||
import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
|
||||
import { RefreshIcon } from "@heroicons/react/outline";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { Col, Grid, Icon, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
|
||||
import type { UploadProps } from "antd";
|
||||
import { Form } from "antd";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import AddModelTab from "../../../components/add_model/add_model_tab";
|
||||
import HealthCheckComponent from "../../../components/model_dashboard/HealthCheckComponent";
|
||||
import ModelGroupAliasSettings from "../../../components/model_group_alias_settings";
|
||||
import ModelInfoView from "../../../components/model_info_view";
|
||||
import NotificationsManager from "../../../components/molecules/notifications_manager";
|
||||
import PassThroughSettings from "../../../components/PassThroughSettings/PassThroughSettings";
|
||||
import TeamInfoView from "../../../components/team/TeamInfo";
|
||||
import useAuthorized from "../hooks/useAuthorized";
|
||||
|
||||
interface ModelDashboardProps {
|
||||
premiumUser: boolean;
|
||||
teams: Team[] | null;
|
||||
}
|
||||
|
||||
interface RetryPolicyObject {
|
||||
[key: string]: { [retryPolicyKey: string]: number } | undefined;
|
||||
}
|
||||
|
||||
interface GlobalRetryPolicyObject {
|
||||
[retryPolicyKey: string]: number;
|
||||
}
|
||||
|
||||
interface RouterSettings {
|
||||
model_group_retry_policy?: RetryPolicyObject | null;
|
||||
retry_policy?: GlobalRetryPolicyObject | null;
|
||||
num_retries?: number | null;
|
||||
model_group_alias?: { [key: string]: string } | null;
|
||||
}
|
||||
|
||||
const HEALTH_PAGE_SIZE = 50;
|
||||
|
||||
const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, teams }) => {
|
||||
const { accessToken, token, userRole, userId: userID } = useAuthorized();
|
||||
const [addModelForm] = Form.useForm();
|
||||
const [lastRefreshed, setLastRefreshed] = useState("");
|
||||
const [providerModels, setProviderModels] = useState<Array<string>>([]);
|
||||
const [selectedProvider, setSelectedProvider] = useState<Providers>(Providers.Anthropic);
|
||||
const [selectedModelGroup, setSelectedModelGroup] = useState<string | null>(null);
|
||||
|
||||
const [retryScope, setRetryScope] = useState<string | null>("global");
|
||||
const [modelGroupRetryPolicy, setModelGroupRetryPolicy] = useState<RetryPolicyObject | null>(null);
|
||||
const [globalRetryPolicy, setGlobalRetryPolicy] = useState<GlobalRetryPolicyObject | null>(null);
|
||||
const [defaultRetry, setDefaultRetry] = useState<number>(0);
|
||||
const [modelGroupAlias, setModelGroupAlias] = useState<{ [key: string]: string }>({});
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState<boolean>(false);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||
const [healthPagination, setHealthPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: HEALTH_PAGE_SIZE,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { data: modelDataResponse, isLoading: isLoadingModels, refetch: refetchModels } = useModelsInfo();
|
||||
const { data: healthModelDataResponse, isLoading: isLoadingHealthModels } = useModelsInfo(
|
||||
healthPagination.pageIndex + 1,
|
||||
healthPagination.pageSize,
|
||||
);
|
||||
const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap();
|
||||
const { data: credentialsResponse, isLoading: isLoadingCredentials } = useCredentials();
|
||||
const credentialsList = credentialsResponse?.credentials || [];
|
||||
const { data: uiSettings, isLoading: isLoadingUISettings } = useUISettings();
|
||||
const updateRetryPolicy = useUpdateRetryPolicy(accessToken);
|
||||
|
||||
const availableModelGroups = useMemo(() => {
|
||||
if (!modelDataResponse?.data) return [];
|
||||
const allModelGroups = new Set<string>();
|
||||
for (const model of modelDataResponse.data) {
|
||||
allModelGroups.add(model.model_name);
|
||||
}
|
||||
return Array.from(allModelGroups).sort();
|
||||
}, [modelDataResponse?.data]);
|
||||
|
||||
const availableModelAccessGroups = useMemo(() => {
|
||||
if (!modelDataResponse?.data) return [];
|
||||
const allModelAccessGroups = new Set<string>();
|
||||
for (const model of modelDataResponse.data) {
|
||||
const modelInfo = model.model_info;
|
||||
if (modelInfo?.access_groups) {
|
||||
for (const group of modelInfo.access_groups) {
|
||||
allModelAccessGroups.add(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(allModelAccessGroups);
|
||||
}, [modelDataResponse?.data]);
|
||||
|
||||
const allModelsOnProxy = useMemo<string[]>(() => {
|
||||
if (!modelDataResponse?.data) return [];
|
||||
return modelDataResponse.data.map((model: any) => model.model_name);
|
||||
}, [modelDataResponse?.data]);
|
||||
|
||||
const healthModelIdsOnProxy = useMemo<string[]>(() => {
|
||||
if (!healthModelDataResponse?.data) return [];
|
||||
return healthModelDataResponse.data
|
||||
.map((model: any) => model.model_info?.id)
|
||||
.filter((id: string | undefined): id is string => Boolean(id));
|
||||
}, [healthModelDataResponse?.data]);
|
||||
|
||||
const getProviderFromModel = (model: string) => {
|
||||
if (modelCostMapData !== null && modelCostMapData !== undefined) {
|
||||
if (typeof modelCostMapData == "object" && model in modelCostMapData) {
|
||||
return modelCostMapData[model]["litellm_provider"];
|
||||
}
|
||||
}
|
||||
return "openai";
|
||||
};
|
||||
|
||||
const processedModelData = useMemo(() => {
|
||||
if (!modelDataResponse?.data) return { data: [] };
|
||||
return transformModelData(modelDataResponse, getProviderFromModel);
|
||||
}, [modelDataResponse?.data, getProviderFromModel]);
|
||||
|
||||
const processedHealthModelData = useMemo(() => {
|
||||
if (!healthModelDataResponse?.data) return { data: [] };
|
||||
return transformModelData(healthModelDataResponse, getProviderFromModel);
|
||||
}, [healthModelDataResponse?.data, getProviderFromModel]);
|
||||
|
||||
const healthRowCount = healthModelDataResponse?.total_count ?? 0;
|
||||
|
||||
const isProxyAdmin = userRole && isProxyAdminRole(userRole);
|
||||
const isInternalUser = userRole && internalUserRoles.includes(userRole);
|
||||
const isUserTeamAdmin = userID && isUserTeamAdminForAnyTeam(teams, userID);
|
||||
const addModelDisabledForInternalUsers =
|
||||
isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true;
|
||||
// Hide tab if user is NOT a proxy admin AND (internal user with setting enabled OR not a team admin)
|
||||
const shouldHideAddModelTab = !isProxyAdmin && (addModelDisabledForInternalUsers || !isUserTeamAdmin);
|
||||
|
||||
const setProviderModelsFn = (provider: Providers) => {
|
||||
const _providerModels = getProviderModels(provider, modelCostMapData);
|
||||
setProviderModels(_providerModels);
|
||||
};
|
||||
|
||||
const uploadProps: UploadProps = {
|
||||
name: "file",
|
||||
accept: ".json",
|
||||
pastable: false,
|
||||
beforeUpload: (file) => {
|
||||
if (file.type === "application/json") {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (e.target) {
|
||||
const jsonStr = e.target.result as string;
|
||||
addModelForm.setFieldsValue({ vertex_credentials: jsonStr });
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onChange(info) {
|
||||
if (info.file.status === "done") {
|
||||
NotificationsManager.success(`${info.file.name} file uploaded successfully`);
|
||||
} else if (info.file.status === "error") {
|
||||
NotificationsManager.fromBackend(`${info.file.name} file upload failed.`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const handleRefreshClick = () => {
|
||||
const currentDate = new Date();
|
||||
setLastRefreshed(currentDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
|
||||
setHealthPagination((previous) => ({ ...previous, pageIndex: 0 }));
|
||||
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
refetchModels();
|
||||
};
|
||||
|
||||
const fetchRouterSettings = useCallback(async (): Promise<RouterSettings | null> => {
|
||||
if (!accessToken || !userID || !userRole) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const routerSettingsInfo = await getCallbacksCall(accessToken, userID, userRole);
|
||||
return routerSettingsInfo.router_settings;
|
||||
} catch (error) {
|
||||
console.error("Error fetching model data:", error);
|
||||
return null;
|
||||
}
|
||||
}, [accessToken, userID, userRole]);
|
||||
|
||||
const applyRouterSettings = useCallback((routerSettings: RouterSettings) => {
|
||||
setModelGroupRetryPolicy(routerSettings.model_group_retry_policy ?? null);
|
||||
setGlobalRetryPolicy(routerSettings.retry_policy ?? null);
|
||||
setDefaultRetry(routerSettings.num_retries ?? 2);
|
||||
setModelGroupAlias(routerSettings.model_group_alias || {});
|
||||
}, []);
|
||||
|
||||
const loadRetrySettings = useCallback(async () => {
|
||||
const routerSettings = await fetchRouterSettings();
|
||||
if (routerSettings) {
|
||||
applyRouterSettings(routerSettings);
|
||||
}
|
||||
}, [fetchRouterSettings, applyRouterSettings]);
|
||||
|
||||
const handleSaveRetrySettings = () => {
|
||||
updateRetryPolicy.mutate(
|
||||
{
|
||||
retry_policy: globalRetryPolicy,
|
||||
model_group_retry_policy: modelGroupRetryPolicy,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
NotificationsManager.success("Retry settings saved successfully");
|
||||
loadRetrySettings();
|
||||
},
|
||||
onError: () => {
|
||||
NotificationsManager.fromBackend("Failed to save retry settings");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !token || !userRole || !userID || !modelDataResponse) {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
void (async () => {
|
||||
const routerSettings = await fetchRouterSettings();
|
||||
if (active && routerSettings) {
|
||||
applyRouterSettings(routerSettings);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [accessToken, token, userRole, userID, modelDataResponse, fetchRouterSettings, applyRouterSettings]);
|
||||
|
||||
const isLoading = isLoadingModels || isLoadingModelCostMap || isLoadingCredentials || isLoadingUISettings;
|
||||
|
||||
// Admin Viewer can view all models read-only — page render proceeds; the
|
||||
// individual write-action tabs (Add Model, LLM Credentials, etc.) are
|
||||
// gated separately below.
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await addModelForm.validateFields();
|
||||
await handleAddModelSubmit(values, accessToken, addModelForm, handleRefreshClick);
|
||||
} catch (error: any) {
|
||||
const errorMessages =
|
||||
error.errorFields
|
||||
?.map((field: any) => {
|
||||
return `${field.name.join(".")}: ${field.errors.join(", ")}`;
|
||||
})
|
||||
.join(" | ") || "Unknown validation error";
|
||||
NotificationsManager.fromBackend(`Please fill in the following required fields: ${errorMessages}`);
|
||||
}
|
||||
};
|
||||
|
||||
Object.keys(Providers).find((key) => (Providers as { [index: string]: any })[key] === selectedProvider);
|
||||
// If a team is selected, render TeamInfoView in full page layout
|
||||
if (selectedTeamId) {
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<TeamInfoView
|
||||
teamId={selectedTeamId}
|
||||
onClose={() => setSelectedTeamId(null)}
|
||||
accessToken={accessToken}
|
||||
is_team_admin={userRole === "Admin"}
|
||||
is_proxy_admin={userRole === "Proxy Admin"}
|
||||
userModels={allModelsOnProxy}
|
||||
editTeam={false}
|
||||
onUpdate={handleRefreshClick}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-4 h-[75vh]">
|
||||
<Grid numItems={1} className="gap-2 p-8 w-full mt-2">
|
||||
<Col numColSpan={1} className="flex flex-col gap-2">
|
||||
{/* Model Management Header */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Model Management</h2>
|
||||
{!all_admin_roles.includes(userRole) ? (
|
||||
<p className="text-sm text-gray-600">Add models for teams you are an admin for.</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">Add and manage models for the proxy</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost Optimization Feedback Banner */}
|
||||
<CostOptimizationFeedbackBanner />
|
||||
{selectedModelId && !isLoading ? (
|
||||
<ModelInfoView
|
||||
modelId={selectedModelId}
|
||||
onClose={() => {
|
||||
setSelectedModelId(null);
|
||||
}}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
onModelUpdate={(updatedModel) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
handleRefreshClick();
|
||||
}}
|
||||
modelAccessGroups={availableModelAccessGroups}
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
// Build a single source-of-truth list of {tab, panel} pairs.
|
||||
// Conditionally-hidden tabs (e.g. "Add Model" for non-admin) get
|
||||
// filtered out as a unit so tab indices and panel indices can
|
||||
// never drift apart — Tremor's TabList and TabPanels filter
|
||||
// falsy children inconsistently, which previously caused
|
||||
// "click LLM Credentials, see nothing" for Admin Viewer.
|
||||
const isAdmin = all_admin_roles.includes(userRole);
|
||||
const visibleTabs: Array<{ tab: React.ReactElement; panel: React.ReactElement }> = [
|
||||
{
|
||||
tab: <Tab key="all-models">{isAdmin ? "All Models" : "Your Models"}</Tab>,
|
||||
panel: (
|
||||
<AllModelsTab
|
||||
key="all-models"
|
||||
selectedModelGroup={selectedModelGroup}
|
||||
setSelectedModelGroup={setSelectedModelGroup}
|
||||
availableModelGroups={availableModelGroups}
|
||||
availableModelAccessGroups={availableModelAccessGroups}
|
||||
setSelectedModelId={setSelectedModelId}
|
||||
setSelectedTeamId={setSelectedTeamId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
if (!shouldHideAddModelTab) {
|
||||
visibleTabs.push({
|
||||
tab: <Tab key="add-model">Add Model</Tab>,
|
||||
panel: (
|
||||
<TabPanel key="add-model" className="h-full">
|
||||
<AddModelTab
|
||||
form={addModelForm}
|
||||
handleOk={handleOk}
|
||||
selectedProvider={selectedProvider}
|
||||
setSelectedProvider={setSelectedProvider}
|
||||
providerModels={providerModels}
|
||||
setProviderModelsFn={setProviderModelsFn}
|
||||
getPlaceholder={getPlaceholder}
|
||||
uploadProps={uploadProps}
|
||||
showAdvancedSettings={showAdvancedSettings}
|
||||
setShowAdvancedSettings={setShowAdvancedSettings}
|
||||
teams={teams}
|
||||
credentials={credentialsList}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
/>
|
||||
</TabPanel>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (isAdmin) {
|
||||
visibleTabs.push(
|
||||
{
|
||||
tab: <Tab key="llm-credentials">LLM Credentials</Tab>,
|
||||
panel: (
|
||||
<TabPanel key="llm-credentials">
|
||||
<CredentialsPanel uploadProps={uploadProps} />
|
||||
</TabPanel>
|
||||
),
|
||||
},
|
||||
{
|
||||
tab: <Tab key="pass-through">Pass-Through Endpoints</Tab>,
|
||||
panel: (
|
||||
<TabPanel key="pass-through">
|
||||
<PassThroughSettings
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
</TabPanel>
|
||||
),
|
||||
},
|
||||
{
|
||||
tab: <Tab key="health-status">Health Status</Tab>,
|
||||
panel: (
|
||||
<TabPanel key="health-status">
|
||||
<HealthCheckComponent
|
||||
accessToken={accessToken}
|
||||
modelData={processedHealthModelData}
|
||||
all_models_on_proxy={healthModelIdsOnProxy}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
setSelectedModelId={setSelectedModelId}
|
||||
teams={teams}
|
||||
isLoading={isLoadingHealthModels}
|
||||
pagination={healthPagination}
|
||||
onPaginationChange={setHealthPagination}
|
||||
rowCount={healthRowCount}
|
||||
/>
|
||||
</TabPanel>
|
||||
),
|
||||
},
|
||||
{
|
||||
tab: <Tab key="model-retry-settings">Model Retry Settings</Tab>,
|
||||
panel: (
|
||||
<ModelRetrySettingsTab
|
||||
key="model-retry-settings"
|
||||
selectedModelGroup={retryScope}
|
||||
setSelectedModelGroup={setRetryScope}
|
||||
availableModelGroups={availableModelGroups}
|
||||
globalRetryPolicy={globalRetryPolicy}
|
||||
setGlobalRetryPolicy={setGlobalRetryPolicy}
|
||||
defaultRetry={defaultRetry}
|
||||
modelGroupRetryPolicy={modelGroupRetryPolicy}
|
||||
setModelGroupRetryPolicy={setModelGroupRetryPolicy}
|
||||
handleSaveRetrySettings={handleSaveRetrySettings}
|
||||
isSaving={updateRetryPolicy.isPending}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
tab: <Tab key="model-group-alias">Model Group Alias</Tab>,
|
||||
panel: (
|
||||
<TabPanel key="model-group-alias">
|
||||
<ModelGroupAliasSettings
|
||||
accessToken={accessToken}
|
||||
initialModelGroupAlias={modelGroupAlias}
|
||||
onAliasUpdate={setModelGroupAlias}
|
||||
/>
|
||||
</TabPanel>
|
||||
),
|
||||
},
|
||||
{
|
||||
tab: <Tab key="price-data-reload">Price Data Reload</Tab>,
|
||||
panel: <PriceDataManagementTab key="price-data-reload" />,
|
||||
},
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TabGroup
|
||||
index={selectedTabIndex}
|
||||
onIndexChange={setSelectedTabIndex}
|
||||
className="gap-2 h-[75vh] w-full "
|
||||
>
|
||||
<TabList className="flex justify-between mt-2 w-full items-center">
|
||||
<div className="flex">{visibleTabs.map((t) => t.tab)}</div>
|
||||
|
||||
<div className="flex items-center space-x-2 self-center">
|
||||
{lastRefreshed && <span className="text-xs text-gray-500">Last Refreshed: {lastRefreshed}</span>}
|
||||
<Icon
|
||||
icon={RefreshIcon}
|
||||
variant="shadow"
|
||||
size="xs"
|
||||
className="cursor-pointer"
|
||||
onClick={handleRefreshClick}
|
||||
/>
|
||||
</div>
|
||||
</TabList>
|
||||
<TabPanels>{visibleTabs.map((t) => t.panel)}</TabPanels>
|
||||
</TabGroup>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</Col>
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsAndEndpointsView;
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
import { Form } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import AddModelTab from "@/components/add_model/add_model_tab";
|
||||
import { handleAddModelSubmit } from "@/components/add_model/handle_add_model_submit";
|
||||
import { Providers, getPlaceholder, getProviderModels } from "@/components/provider_info_helpers";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
|
||||
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload";
|
||||
|
||||
export default function AddModelPage() {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
const [form] = Form.useForm();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: modelCostMapData } = useModelCostMap();
|
||||
const { data: credentialsResponse } = useCredentials();
|
||||
const { data: teams } = useTeams();
|
||||
const [selectedProvider, setSelectedProvider] = useState<Providers>(Providers.Anthropic);
|
||||
const [providerModels, setProviderModels] = useState<string[]>([]);
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await handleAddModelSubmit(values, accessToken, form, refresh);
|
||||
} catch (error: any) {
|
||||
const errorMessages =
|
||||
error.errorFields?.map((field: any) => `${field.name.join(".")}: ${field.errors.join(", ")}`).join(" | ") ||
|
||||
"Unknown validation error";
|
||||
NotificationsManager.fromBackend(`Please fill in the following required fields: ${errorMessages}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AddModelTab
|
||||
form={form}
|
||||
handleOk={handleOk}
|
||||
selectedProvider={selectedProvider}
|
||||
setSelectedProvider={setSelectedProvider}
|
||||
providerModels={providerModels}
|
||||
setProviderModelsFn={(provider) => setProviderModels(getProviderModels(provider, modelCostMapData))}
|
||||
getPlaceholder={getPlaceholder}
|
||||
uploadProps={vertexCredentialsUploadProps(form)}
|
||||
showAdvancedSettings={showAdvancedSettings}
|
||||
setShowAdvancedSettings={setShowAdvancedSettings}
|
||||
teams={teams ?? null}
|
||||
credentials={credentialsResponse?.credentials || []}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ import { modelDeleteCall, modelPatchUpdateCall } from "@/components/networking";
|
|||
import { InfoCircleOutlined, SettingOutlined } from "@ant-design/icons";
|
||||
import { PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Grid, TabPanel } from "@tremor/react";
|
||||
import { Grid } from "@tremor/react";
|
||||
import { Badge, Button, Select, Skeleton, Space, Typography } from "antd";
|
||||
import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
|
|
@ -232,7 +232,7 @@ const AllModelsTab = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<TabPanel>
|
||||
<div className="w-full">
|
||||
<Grid>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="bg-white rounded-lg shadow-sm">
|
||||
|
|
@ -600,7 +600,7 @@ const AllModelsTab = ({
|
|||
onCancel={() => setIsModelSettingsModalVisible(false)}
|
||||
onSuccess={() => setIsModelSettingsModalVisible(false)}
|
||||
/>
|
||||
</TabPanel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Select, SelectItem, TabPanel, Text, Title } from "@tremor/react";
|
||||
import { Button, Select, SelectItem, Text, Title } from "@tremor/react";
|
||||
import { InputNumber } from "antd";
|
||||
import React from "react";
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ const ModelRetrySettingsTab = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<TabPanel>
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="flex items-center">
|
||||
<Text>Retry Policy Scope:</Text>
|
||||
|
|
@ -132,7 +132,7 @@ const ModelRetrySettingsTab = ({
|
|||
<Button className="mt-6 mr-8" onClick={handleSaveRetrySettings} loading={isSaving} disabled={isSaving}>
|
||||
Save
|
||||
</Button>
|
||||
</TabPanel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import PriceDataManagementTab from "./PriceDataManagementTab";
|
||||
|
||||
// Deliberately do NOT mock @tremor/react. These tab components render standalone
|
||||
// (inside antd Tabs / directly as a route page), no longer inside a Tremor
|
||||
// <TabGroup>. A Tremor <TabPanel> root renders nothing without that context, so
|
||||
// this asserts the component's content is visible on its own — reverting the root
|
||||
// back to <TabPanel> makes the title disappear and fails this test.
|
||||
vi.mock("@/components/price_data_reload", () => ({ default: () => <div>reload</div> }));
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "sk-test" }) }));
|
||||
vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({
|
||||
useModelCostMap: () => ({ refetch: vi.fn() }),
|
||||
}));
|
||||
|
||||
describe("PriceDataManagementTab", () => {
|
||||
it("renders its content standalone, without a Tremor TabGroup ancestor", () => {
|
||||
const { getByText } = render(<PriceDataManagementTab />);
|
||||
expect(getByText("Price Data Management")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { TabPanel, Text, Title } from "@tremor/react";
|
||||
import { Text, Title } from "@tremor/react";
|
||||
import PriceDataReload from "@/components/price_data_reload";
|
||||
import React from "react";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
|
@ -9,7 +9,7 @@ const PriceDataManagementTab = () => {
|
|||
const { refetch: refetchModelCostMap } = useModelCostMap();
|
||||
|
||||
return (
|
||||
<TabPanel>
|
||||
<div>
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<Title>Price Data Management</Title>
|
||||
|
|
@ -28,7 +28,7 @@ const PriceDataManagementTab = () => {
|
|||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useModelDetailRouting } from "./detailNavigation";
|
||||
|
||||
// The detail overlay is driven by ?model=/?team= on the current path. Under the
|
||||
// /ui static mount a router.push to the same path (query-only change) is a no-op,
|
||||
// so navigation goes through history.pushState (client-side, no full reload).
|
||||
vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) }));
|
||||
|
||||
describe("useModelDetailRouting", () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, "", "/models-and-endpoints/");
|
||||
});
|
||||
|
||||
it("openModel sets ?model= via history.pushState (no full navigation)", () => {
|
||||
const spy = vi.spyOn(window.history, "pushState");
|
||||
const { result } = renderHook(() => useModelDetailRouting());
|
||||
act(() => result.current.openModel("abc-1"));
|
||||
expect(spy).toHaveBeenCalledWith(null, "", expect.stringContaining("model=abc-1"));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("openTeam sets ?team= and drops any model param", () => {
|
||||
window.history.pushState(null, "", "/models-and-endpoints/?model=abc-1");
|
||||
const spy = vi.spyOn(window.history, "pushState");
|
||||
const { result } = renderHook(() => useModelDetailRouting());
|
||||
act(() => result.current.openTeam("team-9"));
|
||||
const url = spy.mock.calls.at(-1)?.[2] as string;
|
||||
expect(url).toContain("team=team-9");
|
||||
expect(url).not.toContain("model=");
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("close removes both model and team params", () => {
|
||||
window.history.pushState(null, "", "/models-and-endpoints/?model=abc-1");
|
||||
const spy = vi.spyOn(window.history, "pushState");
|
||||
const { result } = renderHook(() => useModelDetailRouting());
|
||||
act(() => result.current.close());
|
||||
const url = spy.mock.calls.at(-1)?.[2] as string;
|
||||
expect(url).not.toContain("model=");
|
||||
expect(url).not.toContain("team=");
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("reads modelId and teamId from the query string", () => {
|
||||
window.history.pushState(null, "", "/models-and-endpoints/?model=xyz");
|
||||
const { result } = renderHook(() => useModelDetailRouting());
|
||||
expect(result.current.modelId).toBe("xyz");
|
||||
expect(result.current.teamId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export interface ModelDetailRouting {
|
||||
modelId: string | null;
|
||||
teamId: string | null;
|
||||
openModel: (id: string) => void;
|
||||
openTeam: (id: string) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
function navigateWithParams(mutate: (params: URLSearchParams) => void): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
mutate(params);
|
||||
const qs = params.toString();
|
||||
const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
|
||||
window.history.pushState(null, "", url);
|
||||
}
|
||||
|
||||
export function useModelDetailRouting(): ModelDetailRouting {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const openModel = useCallback((id: string) => {
|
||||
navigateWithParams((params) => {
|
||||
params.delete("team");
|
||||
params.set("model", id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openTeam = useCallback((id: string) => {
|
||||
navigateWithParams((params) => {
|
||||
params.delete("model");
|
||||
params.set("team", id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
navigateWithParams((params) => {
|
||||
params.delete("model");
|
||||
params.delete("team");
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
modelId: searchParams?.get("model") ?? null,
|
||||
teamId: searchParams?.get("team") ?? null,
|
||||
openModel,
|
||||
openTeam,
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import HealthStatusPage from "./page";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/models-and-endpoints/health",
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(""),
|
||||
}));
|
||||
|
||||
const mockHealthCheckComponent = vi.fn((_props: { all_models_on_proxy?: string[] }) => null);
|
||||
vi.mock("@/components/model_dashboard/HealthCheckComponent", () => ({
|
||||
default: (props: { all_models_on_proxy?: string[] }) => {
|
||||
mockHealthCheckComponent(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer", () => ({
|
||||
transformModelData: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
const mockUseModelsInfo = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ useModelsInfo: () => mockUseModelsInfo() }));
|
||||
vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ useModelCostMap: () => ({ data: {} }) }));
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) }));
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "123" }) }));
|
||||
|
||||
describe("HealthStatusPage", () => {
|
||||
beforeEach(() => {
|
||||
mockHealthCheckComponent.mockClear();
|
||||
});
|
||||
|
||||
it("passes deployment ids (not model names) to HealthCheckComponent as all_models_on_proxy", () => {
|
||||
mockUseModelsInfo.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{ model_name: "gpt-4", model_info: { id: "deployment-id-1" } },
|
||||
{ model_name: "gpt-4", model_info: { id: "deployment-id-2" } },
|
||||
],
|
||||
total_count: 2,
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<HealthStatusPage />);
|
||||
|
||||
expect(mockHealthCheckComponent).toHaveBeenCalled();
|
||||
const props = mockHealthCheckComponent.mock.calls[0][0];
|
||||
expect(props.all_models_on_proxy).toEqual(["deployment-id-1", "deployment-id-2"]);
|
||||
expect(props.all_models_on_proxy).not.toContain("gpt-4");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import HealthCheckComponent from "@/components/model_dashboard/HealthCheckComponent";
|
||||
import { getDisplayModelName } from "@/components/view_model/model_name_display";
|
||||
import { useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { transformModelData } from "@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer";
|
||||
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
||||
|
||||
const HEALTH_PAGE_SIZE = 50;
|
||||
|
||||
export default function HealthStatusPage() {
|
||||
const { accessToken } = useAuthorized();
|
||||
const { data: teams } = useTeams();
|
||||
const { data: modelCostMapData } = useModelCostMap();
|
||||
const { openModel } = useModelDetailRouting();
|
||||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: HEALTH_PAGE_SIZE });
|
||||
const { data: healthModelDataResponse, isLoading } = useModelsInfo(pagination.pageIndex + 1, pagination.pageSize);
|
||||
|
||||
const getProviderFromModel = useCallback(
|
||||
(model: string) => {
|
||||
if (modelCostMapData && typeof modelCostMapData === "object" && model in modelCostMapData) {
|
||||
return modelCostMapData[model]["litellm_provider"];
|
||||
}
|
||||
return "openai";
|
||||
},
|
||||
[modelCostMapData],
|
||||
);
|
||||
|
||||
const processedHealthModelData = useMemo(() => {
|
||||
if (!healthModelDataResponse?.data) {
|
||||
return { data: [] };
|
||||
}
|
||||
return transformModelData(healthModelDataResponse, getProviderFromModel);
|
||||
}, [healthModelDataResponse, getProviderFromModel]);
|
||||
|
||||
const healthModelIdsOnProxy = useMemo<string[]>(
|
||||
() =>
|
||||
healthModelDataResponse?.data
|
||||
?.map((model: any) => model.model_info?.id)
|
||||
.filter((id: string | undefined): id is string => Boolean(id)) ?? [],
|
||||
[healthModelDataResponse?.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<HealthCheckComponent
|
||||
accessToken={accessToken}
|
||||
modelData={processedHealthModelData}
|
||||
all_models_on_proxy={healthModelIdsOnProxy}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
setSelectedModelId={openModel}
|
||||
teams={teams ?? null}
|
||||
isLoading={isLoading}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
rowCount={healthModelDataResponse?.total_count ?? 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelsAndEndpointsLayout from "./layout";
|
||||
|
||||
const { mockPush, mockReplace, navState } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockReplace: vi.fn(),
|
||||
navState: { pathname: "/models-and-endpoints", search: "" },
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => navState.pathname,
|
||||
useRouter: () => ({ push: mockPush, replace: mockReplace }),
|
||||
useSearchParams: () => new URLSearchParams(navState.search),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
|
||||
|
||||
vi.mock("@/components/molecules/cost_optimization_feedback_banner", () => ({ default: () => null }));
|
||||
vi.mock("@/components/model_info_view", () => ({
|
||||
default: ({ modelId }: { modelId: string }) => <div data-testid="model-info">model:{modelId}</div>,
|
||||
}));
|
||||
vi.mock("@/components/team/TeamInfo", () => ({
|
||||
default: ({ teamId }: { teamId: string }) => <div data-testid="team-info">team:{teamId}</div>,
|
||||
}));
|
||||
|
||||
const mockUseAuthorized = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockUseAuthorized() }));
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) }));
|
||||
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
|
||||
useUISettings: () => ({ data: { values: {} } }),
|
||||
}));
|
||||
vi.mock("@/app/(dashboard)/models-and-endpoints/useModelDashboardData", () => ({
|
||||
useModelDashboardData: () => ({
|
||||
availableModelGroups: [],
|
||||
availableModelAccessGroups: [],
|
||||
allModelsOnProxy: [],
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderLayout = () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ModelsAndEndpointsLayout>
|
||||
<div data-testid="tab-content">CHILD</div>
|
||||
</ModelsAndEndpointsLayout>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("ModelsAndEndpointsLayout", () => {
|
||||
beforeEach(() => {
|
||||
navState.pathname = "/models-and-endpoints";
|
||||
navState.search = "";
|
||||
mockPush.mockClear();
|
||||
mockReplace.mockClear();
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
accessToken: "123",
|
||||
token: "123",
|
||||
userRole: "Admin",
|
||||
userId: "123",
|
||||
premiumUser: false,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(global as any).ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
it("renders the admin tab bar and the active tab's page content", () => {
|
||||
const { getByRole, getByTestId } = renderLayout();
|
||||
expect(getByRole("tab", { name: "LLM Credentials" })).toBeInTheDocument();
|
||||
expect(getByRole("tab", { name: "Health Status" })).toBeInTheDocument();
|
||||
expect(getByTestId("tab-content")).toHaveTextContent("CHILD");
|
||||
});
|
||||
|
||||
it("navigates to a tab's path when its tab is clicked", async () => {
|
||||
const { getByRole } = renderLayout();
|
||||
await act(async () => {
|
||||
getByRole("tab", { name: "Health Status" }).click();
|
||||
});
|
||||
expect(mockPush).toHaveBeenCalledWith(expect.stringMatching(/\/models-and-endpoints\/health\/$/));
|
||||
});
|
||||
|
||||
it("redirects to the base models path when the tab path is not permitted for the role", async () => {
|
||||
const replaceMock = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { replace: replaceMock, assign: vi.fn(), href: "http://localhost/", pathname: "/", search: "" },
|
||||
});
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
accessToken: "123",
|
||||
token: "123",
|
||||
userRole: "Internal User",
|
||||
userId: "123",
|
||||
premiumUser: false,
|
||||
});
|
||||
navState.pathname = "/models-and-endpoints/llm-credentials";
|
||||
await act(async () => {
|
||||
renderLayout();
|
||||
});
|
||||
expect(replaceMock).toHaveBeenCalledWith(expect.stringMatching(/\/models-and-endpoints\/$/));
|
||||
Object.defineProperty(window, "location", { configurable: true, value: originalLocation });
|
||||
});
|
||||
|
||||
it("renders the model detail overlay from ?model and hides the tabs and page content", () => {
|
||||
navState.search = "model=abc-123";
|
||||
const { getByTestId, queryByTestId, queryByRole } = renderLayout();
|
||||
expect(getByTestId("model-info")).toHaveTextContent("model:abc-123");
|
||||
expect(queryByTestId("tab-content")).toBeNull();
|
||||
expect(queryByRole("tab", { name: "Health Status" })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the team detail overlay from ?team", () => {
|
||||
navState.search = "team=team-9";
|
||||
const { getByTestId, queryByTestId } = renderLayout();
|
||||
expect(getByTestId("team-info")).toHaveTextContent("team:team-9");
|
||||
expect(queryByTestId("tab-content")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { Tabs } from "antd";
|
||||
import { RefreshIcon } from "@heroicons/react/outline";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
|
||||
import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner";
|
||||
import ModelInfoView from "@/components/model_info_view";
|
||||
import TeamInfoView from "@/components/team/TeamInfo";
|
||||
import { modelTabHref, slugFromPathname, type ModelTabSlug } from "@/app/(dashboard)/models-and-endpoints/tabRoutes";
|
||||
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
||||
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
|
||||
|
||||
const BASE_TAB_KEY = "all-models";
|
||||
|
||||
const TAB_LABELS: Record<ModelTabSlug, string> = {
|
||||
add: "Add Model",
|
||||
"llm-credentials": "LLM Credentials",
|
||||
"pass-through": "Pass-Through Endpoints",
|
||||
health: "Health Status",
|
||||
"retry-settings": "Model Retry Settings",
|
||||
"model-group-alias": "Model Group Alias",
|
||||
"price-data": "Price Data Reload",
|
||||
};
|
||||
|
||||
export default function ModelsAndEndpointsLayout({ children }: { children: ReactNode }) {
|
||||
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
|
||||
const { data: teams, isLoading: teamsLoading } = useTeams();
|
||||
const { data: uiSettings, isLoading: uiSettingsLoading } = useUISettings();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { modelId, teamId, close } = useModelDetailRouting();
|
||||
const { availableModelAccessGroups, allModelsOnProxy } = useModelDashboardData();
|
||||
|
||||
const [lastRefreshed, setLastRefreshed] = useState("");
|
||||
|
||||
const isProxyAdmin = userRole && isProxyAdminRole(userRole);
|
||||
const isInternalUser = userRole && internalUserRoles.includes(userRole);
|
||||
const isUserTeamAdmin = userID && isUserTeamAdminForAnyTeam(teams ?? null, userID);
|
||||
const addModelDisabledForInternalUsers =
|
||||
isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true;
|
||||
const shouldHideAddModelTab = !isProxyAdmin && (addModelDisabledForInternalUsers || !isUserTeamAdmin);
|
||||
const isAdmin = all_admin_roles.includes(userRole);
|
||||
|
||||
const visibleSlugs = useMemo<Array<"" | ModelTabSlug>>(
|
||||
() => [
|
||||
"",
|
||||
...(shouldHideAddModelTab ? [] : (["add"] as const)),
|
||||
...(isAdmin
|
||||
? (["llm-credentials", "pass-through", "health", "retry-settings", "model-group-alias", "price-data"] as const)
|
||||
: []),
|
||||
],
|
||||
[shouldHideAddModelTab, isAdmin],
|
||||
);
|
||||
|
||||
const activeSlug = slugFromPathname(pathname);
|
||||
const isKnownSlug = visibleSlugs.some((slug) => slug === activeSlug);
|
||||
const activeKey = isKnownSlug ? activeSlug || BASE_TAB_KEY : BASE_TAB_KEY;
|
||||
|
||||
useEffect(() => {
|
||||
if (teamsLoading || uiSettingsLoading) {
|
||||
return;
|
||||
}
|
||||
if (activeSlug !== "" && !isKnownSlug) {
|
||||
window.location.replace(modelTabHref(""));
|
||||
}
|
||||
}, [activeSlug, isKnownSlug, teamsLoading, uiSettingsLoading]);
|
||||
|
||||
const allModelsLabel = isAdmin ? "All Models" : "Your Models";
|
||||
const tabItems = visibleSlugs.map((slug) => {
|
||||
const key = slug || BASE_TAB_KEY;
|
||||
return {
|
||||
key,
|
||||
label: slug ? TAB_LABELS[slug] : allModelsLabel,
|
||||
children: key === activeKey ? children : null,
|
||||
};
|
||||
});
|
||||
|
||||
const handleRefreshClick = () => {
|
||||
setLastRefreshed(new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
|
||||
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
};
|
||||
|
||||
const invalidateModels = () => queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
|
||||
if (teamId) {
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<TeamInfoView
|
||||
teamId={teamId}
|
||||
onClose={close}
|
||||
accessToken={accessToken}
|
||||
is_team_admin={userRole === "Admin"}
|
||||
is_proxy_admin={userRole === "Proxy Admin"}
|
||||
userModels={allModelsOnProxy}
|
||||
editTeam={false}
|
||||
onUpdate={invalidateModels}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-4 h-[75vh]">
|
||||
<div className="flex flex-col gap-2 p-8 w-full mt-2">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Model Management</h2>
|
||||
{isAdmin ? (
|
||||
<p className="text-sm text-gray-600">Add and manage models for the proxy</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">Add models for teams you are an admin for.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CostOptimizationFeedbackBanner />
|
||||
|
||||
{modelId ? (
|
||||
<ModelInfoView
|
||||
modelId={modelId}
|
||||
onClose={close}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
onModelUpdate={invalidateModels}
|
||||
modelAccessGroups={availableModelAccessGroups}
|
||||
/>
|
||||
) : (
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={(key) => router.push(modelTabHref(key === BASE_TAB_KEY ? "" : key))}
|
||||
items={tabItems}
|
||||
tabBarExtraContent={{
|
||||
right: (
|
||||
<div className="flex items-center space-x-2 self-center">
|
||||
{lastRefreshed && <span className="text-xs text-gray-500">Last Refreshed: {lastRefreshed}</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefreshClick}
|
||||
aria-label="Refresh models"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<RefreshIcon className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { Form } from "antd";
|
||||
import CredentialsPanel from "@/components/model_add/CredentialsPanel";
|
||||
import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload";
|
||||
|
||||
export default function LlmCredentialsPage() {
|
||||
const [form] = Form.useForm();
|
||||
return <CredentialsPanel uploadProps={vertexCredentialsUploadProps(form)} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import ModelGroupAliasSettings from "@/components/model_group_alias_settings";
|
||||
import { getCallbacksCall } from "@/components/networking";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
export default function ModelGroupAliasPage() {
|
||||
const { accessToken, userId: userID, userRole } = useAuthorized();
|
||||
const [modelGroupAlias, setModelGroupAlias] = useState<{ [key: string]: string }>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !userID || !userRole) {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const info = await getCallbacksCall(accessToken, userID, userRole);
|
||||
if (active) {
|
||||
setModelGroupAlias(info.router_settings?.model_group_alias || {});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching model group alias:", error);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [accessToken, userID, userRole]);
|
||||
|
||||
return (
|
||||
<ModelGroupAliasSettings
|
||||
accessToken={accessToken}
|
||||
initialModelGroupAlias={modelGroupAlias}
|
||||
onAliasUpdate={setModelGroupAlias}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import ModelsAndEndpointsView from "@/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { useState } from "react";
|
||||
import AllModelsTab from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTab";
|
||||
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
|
||||
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
||||
|
||||
export default function ModelsAndEndpointsPage() {
|
||||
const { premiumUser } = useAuthorized();
|
||||
const { data: teams } = useTeams();
|
||||
return <ModelsAndEndpointsView premiumUser={premiumUser} teams={teams ?? null} />;
|
||||
export default function AllModelsPage() {
|
||||
const [selectedModelGroup, setSelectedModelGroup] = useState<string | null>(null);
|
||||
const { availableModelGroups, availableModelAccessGroups } = useModelDashboardData();
|
||||
const { openModel, openTeam } = useModelDetailRouting();
|
||||
|
||||
return (
|
||||
<AllModelsTab
|
||||
selectedModelGroup={selectedModelGroup}
|
||||
setSelectedModelGroup={setSelectedModelGroup}
|
||||
availableModelGroups={availableModelGroups}
|
||||
availableModelAccessGroups={availableModelAccessGroups}
|
||||
setSelectedModelId={openModel}
|
||||
setSelectedTeamId={openTeam}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import PassThroughSettings from "@/components/PassThroughSettings/PassThroughSettings";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
export default function PassThroughPage() {
|
||||
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
|
||||
return (
|
||||
<PassThroughSettings accessToken={accessToken} userRole={userRole} userID={userID} premiumUser={premiumUser} />
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import PriceDataManagementTab from "@/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab";
|
||||
|
||||
export default function PriceDataPage() {
|
||||
return <PriceDataManagementTab />;
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import ModelRetrySettingsTab from "@/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab";
|
||||
import { getCallbacksCall } from "@/components/networking";
|
||||
import { useUpdateRetryPolicy } from "@/app/(dashboard)/hooks/routerSettings/useUpdateRetryPolicy";
|
||||
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
|
||||
interface RetryPolicyObject {
|
||||
[key: string]: { [retryPolicyKey: string]: number } | undefined;
|
||||
}
|
||||
|
||||
interface GlobalRetryPolicyObject {
|
||||
[retryPolicyKey: string]: number;
|
||||
}
|
||||
|
||||
interface RouterSettings {
|
||||
model_group_retry_policy?: RetryPolicyObject | null;
|
||||
retry_policy?: GlobalRetryPolicyObject | null;
|
||||
num_retries?: number | null;
|
||||
}
|
||||
|
||||
export default function ModelRetrySettingsPage() {
|
||||
const { accessToken, userId: userID, userRole } = useAuthorized();
|
||||
const { availableModelGroups } = useModelDashboardData();
|
||||
const updateRetryPolicy = useUpdateRetryPolicy(accessToken);
|
||||
|
||||
const [retryScope, setRetryScope] = useState<string | null>("global");
|
||||
const [modelGroupRetryPolicy, setModelGroupRetryPolicy] = useState<RetryPolicyObject | null>(null);
|
||||
const [globalRetryPolicy, setGlobalRetryPolicy] = useState<GlobalRetryPolicyObject | null>(null);
|
||||
const [defaultRetry, setDefaultRetry] = useState<number>(0);
|
||||
|
||||
const fetchRetrySettings = useCallback(async () => {
|
||||
if (!accessToken || !userID || !userRole) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const info = await getCallbacksCall(accessToken, userID, userRole);
|
||||
return info.router_settings;
|
||||
} catch (error) {
|
||||
console.error("Error fetching router settings:", error);
|
||||
return null;
|
||||
}
|
||||
}, [accessToken, userID, userRole]);
|
||||
|
||||
const applyRetrySettings = useCallback((routerSettings: RouterSettings) => {
|
||||
setModelGroupRetryPolicy(routerSettings.model_group_retry_policy ?? null);
|
||||
setGlobalRetryPolicy(routerSettings.retry_policy ?? null);
|
||||
setDefaultRetry(routerSettings.num_retries ?? 2);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void (async () => {
|
||||
const routerSettings = await fetchRetrySettings();
|
||||
if (active && routerSettings) {
|
||||
applyRetrySettings(routerSettings);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [fetchRetrySettings, applyRetrySettings]);
|
||||
|
||||
const handleSaveRetrySettings = () => {
|
||||
updateRetryPolicy.mutate(
|
||||
{ retry_policy: globalRetryPolicy, model_group_retry_policy: modelGroupRetryPolicy },
|
||||
{
|
||||
onSuccess: () => {
|
||||
NotificationsManager.success("Retry settings saved successfully");
|
||||
void fetchRetrySettings().then((routerSettings) => {
|
||||
if (routerSettings) {
|
||||
applyRetrySettings(routerSettings);
|
||||
}
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
NotificationsManager.fromBackend("Failed to save retry settings");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModelRetrySettingsTab
|
||||
selectedModelGroup={retryScope}
|
||||
setSelectedModelGroup={setRetryScope}
|
||||
availableModelGroups={availableModelGroups}
|
||||
globalRetryPolicy={globalRetryPolicy}
|
||||
setGlobalRetryPolicy={setGlobalRetryPolicy}
|
||||
defaultRetry={defaultRetry}
|
||||
modelGroupRetryPolicy={modelGroupRetryPolicy}
|
||||
setModelGroupRetryPolicy={setModelGroupRetryPolicy}
|
||||
handleSaveRetrySettings={handleSaveRetrySettings}
|
||||
isSaving={updateRetryPolicy.isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
|
||||
|
||||
import { MODEL_TAB_SLUGS, modelTabHref, slugFromPathname } from "./tabRoutes";
|
||||
|
||||
describe("slugFromPathname", () => {
|
||||
it("returns empty string for the base path with or without a trailing slash", () => {
|
||||
expect(slugFromPathname("/models-and-endpoints")).toBe("");
|
||||
expect(slugFromPathname("/models-and-endpoints/")).toBe("");
|
||||
});
|
||||
|
||||
it("extracts the tab slug from dev and proxy-mounted (/ui) paths", () => {
|
||||
expect(slugFromPathname("/models-and-endpoints/add")).toBe("add");
|
||||
expect(slugFromPathname("/ui/models-and-endpoints/llm-credentials/")).toBe("llm-credentials");
|
||||
});
|
||||
|
||||
it("returns the raw segment for an unknown tab so the view can redirect to base", () => {
|
||||
expect(slugFromPathname("/ui/models-and-endpoints/bogus")).toBe("bogus");
|
||||
});
|
||||
|
||||
it("returns empty string when the models base segment is not in the path", () => {
|
||||
expect(slugFromPathname("/teams")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelTabHref", () => {
|
||||
it("builds the trailing-slash base href for the empty slug", () => {
|
||||
expect(modelTabHref("")).toBe("/ui/models-and-endpoints/");
|
||||
});
|
||||
|
||||
it("builds a trailing-slash href for every tab slug (required by static export)", () => {
|
||||
for (const slug of MODEL_TAB_SLUGS) {
|
||||
expect(modelTabHref(slug)).toBe(`/ui/models-and-endpoints/${slug}/`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { migratedHref } from "@/utils/migratedPages";
|
||||
|
||||
export const MODELS_BASE_SEGMENT = "models-and-endpoints";
|
||||
|
||||
export const MODEL_TAB_SLUGS = [
|
||||
"add",
|
||||
"llm-credentials",
|
||||
"pass-through",
|
||||
"health",
|
||||
"retry-settings",
|
||||
"model-group-alias",
|
||||
"price-data",
|
||||
] as const;
|
||||
|
||||
export type ModelTabSlug = (typeof MODEL_TAB_SLUGS)[number];
|
||||
|
||||
export function modelTabHref(slug: string): string {
|
||||
const base = migratedHref(MODELS_BASE_SEGMENT);
|
||||
return slug ? `${base}/${slug}/` : `${base}/`;
|
||||
}
|
||||
|
||||
export function slugFromPathname(pathname: string): string {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const idx = parts.indexOf(MODELS_BASE_SEGMENT);
|
||||
if (idx === -1) {
|
||||
return "";
|
||||
}
|
||||
return parts[idx + 1] ?? "";
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { useMemo } from "react";
|
||||
import { useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
|
||||
export interface ModelDashboardData {
|
||||
availableModelGroups: string[];
|
||||
availableModelAccessGroups: string[];
|
||||
allModelsOnProxy: string[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useModelDashboardData(): ModelDashboardData {
|
||||
const { data: modelDataResponse, isLoading } = useModelsInfo();
|
||||
|
||||
const availableModelGroups = useMemo(() => {
|
||||
const groups = new Set<string>(modelDataResponse?.data?.map((model) => model.model_name) ?? []);
|
||||
return Array.from(groups).sort();
|
||||
}, [modelDataResponse?.data]);
|
||||
|
||||
const availableModelAccessGroups = useMemo(() => {
|
||||
const groups = new Set<string>(
|
||||
modelDataResponse?.data?.flatMap((model) => model.model_info?.access_groups ?? []) ?? [],
|
||||
);
|
||||
return Array.from(groups);
|
||||
}, [modelDataResponse?.data]);
|
||||
|
||||
const allModelsOnProxy = useMemo(
|
||||
() => modelDataResponse?.data?.map((model) => model.model_name) ?? [],
|
||||
[modelDataResponse?.data],
|
||||
);
|
||||
|
||||
return { availableModelGroups, availableModelAccessGroups, allModelsOnProxy, isLoading };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue